Compare commits

...

182 Commits

Author SHA1 Message Date
Claude fa4e3c2ffc Split ManagementCockpitService god class into focused analyzers
Extract the three independent responsibilities of the 1120-line
ManagementCockpitService into dedicated classes: ExcelCockpitAnalyzer
(file-based cockpit), CentralCockpitAnalyzer (central database cockpit)
and FinanceSummaryAnalyzer (finance summary), with shared currency
conversion and value-field logic in CockpitValueAggregator.

ManagementCockpitService becomes a thin facade that preserves the
IManagementCockpitService contract and both constructors, so no callers,
DI registrations or tests need to change. Pure code move, no behaviour
change.

https://claude.ai/code/session_01Q8k7LD7JG8oMReySL3Ckhc
2026-05-21 20:09:30 +00: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 ec827a4ce8 Add connection diagnostics and visual field transformation mapping 2026-04-13 11:52:05 +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
Claude af40d87213 Merge HANA SSL/MDC support and DLL reference fix from claude/blazor-sap-sales-exporter-9VrM0 2026-04-13 09:07:41 +00:00
Claude efcf7b180c Add SSL, MDC database, and custom HANA connection parameters
Fixes 'error while parsing protocol' HanaException by supporting
SSL/TLS encryption, Multi-Tenant Database Container (MDC) database
name, and arbitrary additional connection parameters.

- HanaServer model: added DatabaseName, UseSsl, ValidateCertificate,
  AdditionalParams fields + BuildConnectionString() helper
- HanaQueryService: accepts HanaServer directly, uses
  BuildConnectionString() for full parameter support
- AppDbContext: added EnsureSchema() method that uses PRAGMA table_info
  + ALTER TABLE ADD COLUMN to add the new fields to existing SQLite
  databases without losing data (EnsureCreated does not update schema)
- Program.cs: calls EnsureSchema on startup before seeding
- Standorte.razor: server dialog now exposes DatabaseName, UseSsl,
  ValidateCertificate, AdditionalParams with helper texts; test
  connection uses new signature

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-13 08:52:10 +00:00
Claude f916c26fb4 Fix SAP HANA client reference - use direct DLL reference instead of missing NuGet package
Das Paket 'Sap.Data.Hana.v2' existiert nicht auf nuget.org. SAP liefert den
HANA .NET Client ausschliesslich ueber das SAP HANA Client Installationspaket
aus. Stattdessen wird nun Sap.Data.Hana.Core.v2.1.dll direkt aus dem
Standard-Installationspfad referenziert (via HanaClientDll MSBuild-Property
ueberschreibbar). Warnung beim Build wenn DLL nicht gefunden wird.

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-10 06:23:59 +00:00
admin c198d362b1 Merge pull request #55 from metacube2/claude/blazor-sap-sales-exporter-9VrM0
Migrate from console app to Blazor web UI with database
2026-04-09 16:07:10 +02:00
Claude 8524631508 Convert TrafagSalesExporter from console app to Blazor Server app with MudBlazor UI
- Replaced console app with .NET 8 Blazor Server architecture
- Added EF Core SQLite database (trafag_exporter.db) with auto-seed data
- Models: HanaServer, Site, SharePointConfig, ExportSettings, ExportLog, SalesRecord
- Services: HanaQueryService (with configurable dateFilter), ExcelExportService,
  SharePointUploadService, ExportOrchestrationService (with live status events),
  TimerBackgroundService (scheduled daily export)
- MudBlazor UI pages: Dashboard (export status + manual trigger), Standorte
  (HANA server + site CRUD), Settings (SharePoint + timer config), Logs (filtered view)
- SAP HANA queries unchanged (INV + CRN with exact SAP B1 table joins)
- SharePoint upload via Microsoft Graph with app registration auth

https://claude.ai/code/session_012heAXNMbbyxqYf2S2HrKLj
2026-04-09 14:00:44 +00:00
admin 2f56082adc Merge pull request #53 from metacube2/codex/create-c#-console-app-for-sap-hana-export
Add Trafag SAP HANA → Excel → SharePoint exporter (.NET 8 console)
2026-04-09 15:48:14 +02:00
admin 673bba7298 Add Trafag SAP HANA to Excel SharePoint exporter console app 2026-04-09 15:47:55 +02:00
admin 8d8b62f1f5 Merge pull request #51 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-02-09 10:47:23 +01:00
admin 99f40dd7ea Update fmt.Println message from 'Hello' to 'Goodbye' 2026-02-09 10:42:52 +01:00
admin bfcf018d33 Merge pull request #50 from metacube2/claude/php-video-converter-suite-SVNhO
Initial commit: Video Converter Suite application scaffold
2026-02-07 19:25:23 +01:00
Claude 6c56306873 Add PHP Video Converter Suite with live stream pipelines and nuclear control panel UI
Full-featured video conversion platform with:
- FFmpeg-based pipeline system with composable stages (transcode, scale, filter, audio, bitrate, framerate, trim, deinterlace, denoise, stabilize)
- Live stream management with real-time format switching (RTMP/RTSP/HTTP)
- Industrial/nuclear power plant control room themed UI with gauges, switches, LED indicators
- Format switchboard for instant conversion between 16+ video/audio formats
- Pipeline designer with visual flow editor and drag-and-drop stage composition
- Job queue with priority scheduling and batch conversion
- WebSocket server for real-time progress broadcasting
- REST API for all operations (upload, convert, streams, pipelines, queue)
- System monitoring (CPU, memory, disk) with animated gauge displays
- Docker Compose setup with web, websocket, and worker services

https://claude.ai/code/session_01WxmHGnVFXGm2bwbFREHkHb
2026-02-07 18:11:04 +00:00
admin 2cc77f5405 Merge pull request #49 from metacube2/claude/product-promotion-tool-jY9ZZ
Add PromoMaster - automatic product promotion tool in DE/EN
2026-02-07 19:06:56 +01:00
admin 47487c7bab Merge pull request #47 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-02-07 19:04:02 +01:00
admin fe502fc4b3 Merge branch 'main' into claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw 2026-02-07 19:03:54 +01:00
admin df86a5f568 Merge pull request #48 from metacube2/claude/photography-homepage-tools-1bcI6
Add comprehensive styling for photography tools application
2026-02-07 19:02:10 +01:00
Claude 3f0662c49a feat: Add Turkish/Swedish translations and 6 photography simulation tools
- Added Turkish (TR) and Swedish (SV) language support to i18n system
- Added simulation tool i18n keys to all 8 languages
- New simulation section with 6 interactive canvas-based tools:
  Bokeh, Long Exposure, White Balance, ISO Noise, Perspective, Histogram
- Updated hero stats counter from 6 to 12 tools
- Added simulation nav link and footer link

https://claude.ai/code/session_016BnRMtz5yhf7n5ZPQCMfmN
2026-02-07 13:15:58 +00:00
Claude 3b6d0c4db5 Add PromoMaster - automatic product promotion tool in DE/EN
Full-featured web tool for generating product marketing materials:
- Social media posts for Twitter/X, Instagram, Facebook, LinkedIn, TikTok
- 6 promotion styles: Professional, Casual, Urgent/FOMO, Luxury, Fun, Minimal
- Email marketing templates
- SEO texts & metadata generator
- Press release generator
- Slogan & tagline generator
- Hashtag cloud
- Landing page HTML generator with live preview
- Export to TXT, HTML, JSON, CSV
- Complete DE/EN bilingual support with language toggle
- SEO meta tags, Open Graph, Twitter Cards, JSON-LD structured data
- Fully responsive dark-mode design

https://claude.ai/code/session_01BkvFWWTbZTBY6KafPCpXGF
2026-02-07 13:14:49 +00:00
Claude 0605aee88d feat: Add PhotoPro Tools - photography homepage with 6 languages
Complete photography toolkit with:
- 6 lens calculators (DOF, FOV, crop factor, hyperfocal, flash range, magnification)
- 8 interactive composition rules with canvas demos (thirds, golden ratio, leading lines, symmetry, framing, negative space, diagonals, color theory)
- Motif recognition guide for 8 genres (portrait, landscape, street, macro, night, sport, architecture, wildlife) with optimal camera settings
- Interactive exposure triangle visualization with real-time EV meter
- Quiz system with 50 questions across 5 categories with instant feedback
- Full i18n support: Deutsch, English, Français, Italiano, Srpski, Shqip
- Modern dark UI with glassmorphism, particles, animations
- Fully responsive design

https://claude.ai/code/session_016BnRMtz5yhf7n5ZPQCMfmN
2026-02-07 12:50:44 +00:00
admin 6fba9d938a Remove OpenWeatherMap API key input and update settings
Removed API key input for OpenWeatherMap and updated weather widget settings.
2026-02-05 11:03:08 +01:00
admin 5d9ebbbc3e Refactor weather settings and timelapse checks
Removed the weather API key retrieval method and updated the weekly timelapse check logic.
2026-02-05 11:02:36 +01:00
admin 282d8b70fc Merge branch 'main' into claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw 2026-02-05 11:01:33 +01:00
admin 814494f812 Add auto-screenshot and email sharing settings 2026-02-05 10:57:02 +01:00
admin 75e5566532 Update weather widget description and remove API key field 2026-02-05 10:56:21 +01:00
admin 5d382db42e Merge pull request #46 from metacube2/codex/implement-phase-locked-phase-vocoder
Add phase-locked vocoder timestretcher
2026-01-31 12:24:13 +01:00
admin 054717fff1 Add phase-locked vocoder timestretcher 2026-01-31 12:24:02 +01:00
admin 5218c064cb Merge pull request #45 from metacube2/claude/fix-aurora-api-key-VK588
Add 4 new features: timelapse toggle, auto-screenshot, video search, …
2026-01-30 19:42:09 +01:00
Claude 6a24b564a4 Add 4 new features: timelapse toggle, auto-screenshot, video search, email sharing
- Weekly timelapse button now toggleable via settings (zoom_timelapse.weekly_timelapse_enabled)
- Auto-screenshot API for cron-based gallery capture every 10 min
- Date/time video search with filter UI in archive section
- Email sharing with share links and PHPMailer integration
- New API endpoints: auto-screenshot.php, gallery.php, video-search.php, share.php
- New settings: auto_screenshot.*, sharing.* for feature configuration
2026-01-30 18:33:54 +00:00
admin cc85523c9c Merge pull request #44 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
a
2026-01-23 21:24:58 +01:00
admin bb27cb151e Merge pull request #43 from metacube2/claude/fix-aurora-api-key-VK588
Claude/fix aurora api key vk588
2026-01-23 21:24:00 +01:00
Claude 16673b91d3 Add Billing/Stripe integration and Landing Page (Phase 4+5)
Phase 4 - Billing/Stripe:
- src/Billing/StripeService.php: Stripe API wrapper
  - Checkout session creation
  - Customer management
  - Billing portal sessions
  - Webhook signature verification
- src/Billing/SubscriptionManager.php: Subscription logic
  - Plan management (CRUD)
  - Trial handling
  - Feature access checks
  - Invoice storage
- src/Billing/WebhookHandler.php: Stripe webhook processing
  - checkout.session.completed
  - customer.subscription.* events
  - invoice.paid / payment_failed
- api/stripe-webhook.php: Webhook endpoint
- dashboard/billing.php: Billing dashboard
  - Current plan display with features
  - Plan comparison grid
  - Upgrade buttons with Stripe Checkout
  - Invoice history

Phase 5 - Landing Page:
- landing/index.php: Marketing homepage
  - Hero section with CTA
  - Feature grid (6 features)
  - How it works (3 steps)
  - Final CTA section
  - Responsive design
- landing/pricing.php: Pricing page
  - Dynamic plan cards from DB
  - Monthly/yearly toggle (2 months free)
  - Feature comparison
  - FAQ accordion

All features respect saas_features toggles in settings.
2026-01-23 19:16:18 +00:00
Claude ac77e27089 Add automatic onboarding system (Phase 3)
Onboarding Wizard:
- register.php: User registration with validation
- verify.php: Email verification (with demo mode)
- stream.php: Stream URL configuration & validation
- branding.php: Quick branding setup with live preview
- complete.php: Success page with confetti animation

Backend Classes (src/Onboarding/):
- OnboardingManager.php: Orchestrates the onboarding flow
  - Registration with automatic subdomain generation
  - Email verification tokens
  - Step tracking in tenant_onboarding table
- StreamValidator.php: Validates stream URLs
  - HLS (.m3u8) validation with playlist check
  - RTMP format validation
  - iframe/embed URL detection (YouTube, Vimeo, Twitch)
  - Generic HTTP reachability check

Features:
- 4-step wizard with progress indicator
- Stream type auto-detection
- Live branding preview
- Skip options for optional steps
- Trial period display
2026-01-23 18:41:53 +00:00
Claude 7bd62b3527 Add tenant dashboard (Phase 2)
Dashboard Features:
- Login page with session-based auth
- Overview page with live stats (viewers, stream status)
- Stream settings (URL, type configuration)
- Branding editor (colors, texts, custom CSS)
- Settings page (weather, content toggles, UI options)

New Files:
- dashboard/index.php: Main overview with stats
- dashboard/login.php: Authentication page
- dashboard/logout.php: Session cleanup
- dashboard/stream.php: Stream configuration
- dashboard/branding.php: Visual customization
- dashboard/settings.php: Feature toggles
- dashboard/templates/layout.php: Shared layout
- dashboard/api/stats.php: Stats API endpoint
- dashboard/assets/dashboard.css: Modern dashboard UI
- dashboard/assets/dashboard.js: Client-side functionality
- src/Auth/AuthManager.php: Secure auth with Argon2, remember-me

Auth Features:
- Secure password hashing (Argon2ID)
- Remember-me tokens
- Role-based access (super_admin, tenant_admin, tenant_user)
- Legacy fallback for existing admin credentials
2026-01-23 17:09:38 +00:00
Claude 402604b4cc Add Multi-Tenant SaaS foundation for customer management
Phase 1 implementation includes:

Database:
- schema.sql with tables for tenants, domains, settings, branding,
  streams, users, subscriptions, plans, invoices, viewer_stats

Core Classes (src/Core/):
- Database.php: PDO wrapper with singleton pattern
- TenantResolver.php: Domain-to-tenant resolution with fallback

Tenant Classes (src/Tenant/):
- TenantManager.php: CRUD operations for tenants
- TenantSettingsManager.php: DB-based settings per tenant

Configuration:
- config.example.php: Template for database/stripe/mail config
- bootstrap.php: Initializes multi-tenant environment
- .gitignore: Excludes config.php and cache files

Integration:
- SettingsManager.php: Added saas_features toggles (all off by default)
- index.php: Uses getSiteConfig() from bootstrap when multi-tenant enabled,
  falls back to legacy hardcoded domains when disabled

All SaaS features are disabled by default (saas_features.multi_tenant_enabled = false),
ensuring zero breaking changes to existing installations.
2026-01-23 16:40:42 +00:00
admin 4acdf89588 Merge pull request #42 from metacube2/codex/fix-missing-api-key-in-index.php
Add optional OpenWeatherMap API key support for weather widget
2026-01-22 23:01:34 +01:00
admin 20c0569731 Add optional weather API key support 2026-01-22 23:00:41 +01:00
admin 144c813acf Merge pull request #41 from metacube2/claude/fix-aurora-api-key-VK588
Prevent caching of API errors in WeatherManager
2026-01-22 22:40:57 +01:00
Claude 328b5b5b15 Fix cached weather error being returned
The weather cache was returning old errors (like "API Key fehlt"
from the previous OpenWeatherMap implementation) even after
switching to Open-Meteo which doesn't require an API key.

Changes:
- Delete cache file if it contains an error
- Prevent errors from being cached in the first place
2026-01-22 21:40:09 +00:00
admin 6472bbf162 Merge pull request #40 from metacube2/claude/mail-finetuning-webapp-01BsRXQNeVFrCBky8aw35YHw
asdf
2026-01-22 22:30:35 +01:00
admin c66a5b9f64 Merge pull request #39 from metacube2/claude/fix-layout-centering-cdX7d
Add error handling to weather widget
2026-01-22 22:29:27 +01:00
Claude 7e468d51ca Add error handling to weather widget
- Wrap getCurrentWeather() in try-catch block
- Prevents white screen if weather API fails
- Shows error message in widget instead
- Add test-weather.php for debugging
2026-01-22 21:06:50 +00:00
admin 7b3f99e837 Merge pull request #38 from metacube2/claude/fix-layout-centering-cdX7d
Switch weather API from OpenWeatherMap to Open-Meteo
2026-01-22 22:00:25 +01:00
Claude 20704b3cd8 Switch weather API from OpenWeatherMap to Open-Meteo
🎉 **100% KOSTENLOS - KEIN API KEY MEHR NÖTIG!**

**Warum Open-Meteo?**
-  Komplett kostenlos ohne Limits
-  KEIN API Key erforderlich
-  Keine Kreditkarte, keine Anmeldung
-  Open Source & Non-Profit
-  Sehr präzise Daten für die Schweiz
-  Schnell & zuverlässig

**Technische Änderungen:**

**WeatherManager.php:**
- API URL auf api.open-meteo.com gewechselt
- WMO Weather Codes (0-99) implementiert
- Deutsche Wetterbeschreibungen für alle Codes
- Temperatur, Wind, Luftdruck, Feuchtigkeit, Niederschlag
- Icon-Mapping von WMO Codes zu Emojis
- Timezone: Europe/Zurich

**index.php Admin-Panel:**
- API Key Feld entfernt (nicht mehr nötig!)
- Überschrift: "Open-Meteo - 100% kostenlos!"
- Event Listener für API Key entfernt
- Funktioniert sofort ohne Setup!

**API Endpoint:**
https://api.open-meteo.com/v1/forecast
- Keine Authentication nötig
- Parameter: lat, lon, current weather vars
- Response: JSON mit aktuellen Wetterdaten

**Unterstützte Wetter-Codes:**
- 0: Klar
- 1-3: Bewölkt (verschiedene Grade)
- 45-48: Nebel
- 51-65: Regen (Niesel bis stark)
- 71-77: Schnee
- 80-86: Schauer
- 95-99: Gewitter & Hagel

**Resultat:**
Widget funktioniert SOFORT ohne jegliches Setup!
Einfach aktivieren und fertig! 🌤️
2026-01-22 20:59:07 +00:00
admin 6fa64baa35 Merge pull request #37 from metacube2/claude/fix-layout-centering-cdX7d
Fix undefined siteConfig variable in admin panel
2026-01-22 21:52:18 +01:00
Claude 6bca898488 Fix undefined siteConfig variable in admin panel
- Add global $siteConfig declaration in displayAdminContent()
- Fixes warning in SEO settings placeholder
2026-01-22 20:50:53 +00:00
admin 8bd90629f9 Merge pull request #36 from metacube2/claude/fix-layout-centering-cdX7d
Claude/fix layout centering cd x7d
2026-01-22 21:39:19 +01:00
Claude 7eda2fbbe8 Add weather widget with OpenWeatherMap integration
Implementiert vollständiges Wetter-Widget oberhalb des Webcam-Videos:

**Features:**
🌤️ **Wetter-Anzeige:**
- Temperatur (°C)
- Windgeschwindigkeit & Richtung (km/h)
- Luftdruck (hPa)
- Luftfeuchtigkeit (%)
- Wetterbeschreibung mit Emoji-Icons
- Niederschlag (Regen/Schnee) wenn vorhanden

⚙️ **Technisch:**
- OpenWeatherMap API Integration
- 5 Minuten Cache (konfigurierbar)
- Auto-Update alle X Minuten (Frontend)
- WeatherManager Klasse für Backend
- Schönes Gradient-Design mit Hover-Effekten
- Responsive für Mobile

🎛️ **Admin-Settings:**
- Wetter-Widget ein/aus
- API Key Eingabefeld + Registrierungs-Link
- Standort konfigurierbar (Stadt,Land)
- GPS-Koordinaten (Lat/Lon)
- Update-Intervall (5-60 Minuten)
- Einheiten (Metrisch/Imperial)

**Dateien:**
- WeatherManager.php: Neue Klasse für API-Calls & Caching
- SettingsManager.php: Weather Settings Defaults & Helper
- index.php: Widget HTML, CSS, JavaScript Auto-Update
- settings.json: Weather Defaults initialisiert

**Koordinaten Oberdürnten:**
Lat: 47.2833, Lon: 8.7167

**Setup für User:**
1. Gratis Account auf openweathermap.org erstellen
2. API Key im Admin-Panel einfügen
3. Fertig! Widget zeigt Live-Wetter an
2026-01-22 18:50:16 +00:00
Claude 5b8200a4ff Add cache clear utility script
- Add clear-cache.php for clearing PHP OPcache and realpath cache
- Useful for debugging and ensuring latest code changes are visible
2026-01-22 17:57:17 +00:00
Claude ac6632e24f Initialize settings.json with all new defaults
- Add all new settings groups (ui_display, zoom_timelapse, content, technical, theme, seo)
- Set default values for all new admin settings
- Enable all features by default for smooth transition
2026-01-22 17:54:57 +00:00
Claude 0ce527c69e Add comprehensive admin settings control panel
Erweitere Admin-Bereich um umfangreiche Settings-Steuerung:

**Punkt 2 - UI Anzeige:**
- Empfehlungs-Banner ein/aus
- QR-Code Section ein/aus
- Social Media Links ein/aus
- Patrouille Suisse Section ein/aus

**Punkt 3 - Zoom & Timelapse:**
- Zoom-Controls anzeigen/verstecken
- Max Zoom-Level konfigurierbar (1.5x - 4.0x)
- Timelapse Rückwärts-Modus ein/aus

**Punkt 5 - Content Management:**
- Gästebuch aktivieren/deaktivieren
- Galerie aktivieren/deaktivieren
- KI-Events anzeigen/verstecken
- Max Gästebuch-Einträge limit

**Punkt 6 - Technische Settings:**
- Viewer Update-Intervall konfigurierbar
- Session Timeout einstellbar

**Punkt 7 - Theme & Design:**
- Standard-Theme auswählbar (Legacy/Alpine/Modern)
- Theme-Switcher anzeigen/verstecken (war auskommentiert)

**Punkt 8 - SEO & Meta:**
- Custom Title konfigurierbar
- Meta Description editierbar
- Meta Keywords verwaltbar

**Technische Änderungen:**
- SettingsManager.php: Neue Defaults und Helper-Methoden
- Admin-Panel: Neue Settings-Gruppen mit Toggle-Switches
- JavaScript: Live-Apply ohne Reload für alle Settings
- HTML: Sections mit PHP-Settings verbunden
- CSS: Admin-Panel Styling hinzugefügt
- TimelapseController: reverseEnabled Setting integriert
2026-01-22 17:47:56 +00:00
admin 8f46ffb695 Merge pull request #35 from metacube2/claude/fix-layout-centering-cdX7d
Center header layout and adjust navigation alignment
2026-01-22 18:26:26 +01:00
Claude 36558e97cb Fix layout centering in aurora-livecam
- Center header container and navigation
- Add padding-right to header to prevent overlap with language selector buttons
- Change nav ul justify-content from space-around to center for better alignment
2026-01-22 17:25:58 +00:00
admin 1cf30a0c8b Update fmt.Println message from 'Hello' to 'Goodbye' 2026-01-22 18:17:50 +01:00
admin ff4bda1e53 Merge pull request #34 from metacube2/codex/create-power-bi-training-manual-for-hr
Add HR Power BI consumer and trainer manuals
2026-01-20 13:11:19 +01:00
admin af87ef329b Merge branch 'main' into codex/create-power-bi-training-manual-for-hr 2026-01-20 13:11:10 +01:00
admin 9c1d820876 Add HR Power BI consumer and trainer manuals 2026-01-20 13:08:29 +01:00
admin cbe215cfc2 Merge pull request #33 from metacube2/codex/create-power-bi-training-manual-for-hr
Add HTML Power BI HR manual with embedded SVG diagram
2026-01-20 09:52:43 +01:00
admin 12d64bf009 Expand HTML Power BI HR manual 2026-01-20 09:02:03 +01:00
admin 0871068ff8 Add HTML Power BI HR manual 2026-01-20 08:08:49 +01:00
admin 3d7cee81da Merge pull request #32 from metacube2/codex/create-power-bi-training-manual-for-hr
Provide Power BI HR training manual (Markdown; omit Word binary)
2026-01-20 07:49:14 +01:00
admin 0aa5a62754 Provide Power BI HR training manual 2026-01-20 07:48:59 +01:00
admin f487713a92 Add Power BI training manual for HR
Created a comprehensive training manual for Power BI tailored for HR staff, including step-by-step instructions, target audience details, data sources, KPIs, and troubleshooting tips.
2026-01-20 07:41:01 +01:00
admin 5eb27d1c28 Merge pull request #31 from metacube2/codex/check-translations-in-index2.php
Complete missing translations for language switcher and page content (IT/FR/ZH)
2026-01-19 22:25:50 +01:00
admin 4060115749 Complete missing translations in index2 2026-01-19 22:25:15 +01:00
admin b98a6761c2 Merge pull request #30 from metacube2/claude/seecam-domain-config-njiwU
Add missing translations for Patrouille Suisse and Blog sections
2026-01-18 17:14:56 +01:00
Claude 6a8e879898 Add missing translations for Patrouille Suisse and Blog sections
- About section: additional paragraph translated
- Admin section: headings translated
- Patrouille Suisse: complete section with all texts, headings and lists
- Blog section: subtitle, all 3 articles with titles, dates and content
- Footer blog text translated

All elements now have data-en and data-de attributes for language switching
2026-01-18 13:23:33 +00:00
admin b9407a9f13 Merge pull request #28 from metacube2/claude/seecam-domain-config-njiwU
Add domain-based site configuration for seecam.ch
2026-01-18 10:08:40 +01:00
Claude 53ae1ba76f Add domain-based site configuration for seecam.ch
- Detect domain (seecam.ch vs aurora-weather-livecam.com) via PHP
- Load different logo (seecam.jpg vs logo.png) based on domain
- Dynamic meta tags (title, og:*, twitter:*, canonical URL)
- Dynamic Schema.org JSON-LD (WebSite, LocalBusiness, VideoObject)
- Dynamic site texts (welcome, about, blog title, footer, copyright)
- QR-code URL adapts to current domain
- Email sender name remains unchanged (Aurora Livecam)
2026-01-18 09:06:02 +00:00
admin 9a418aa213 Merge pull request #27 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-17 18:18:37 +01:00
Claude 9a88b5cad2 Add SEO-optimized index2.php with comprehensive improvements
- Add SEO meta tags (title, description, keywords, robots)
- Add Schema.org JSON-LD structured data (WebSite, LocalBusiness, VideoObject)
- Add local SEO geo tags for Zürich Oberland/Dürnten
- Improve Open Graph and Twitter Card meta tags
- Add optimized alt-texts for images
- Add social media links to footer (Instagram, Facebook, YouTube, TikTok)
- Add Patrouille Suisse dedicated section with training info
- Add Blog section with weather and webcam articles
- Add accessibility CSS for screen-readers
2026-01-17 17:13:46 +00:00
admin 3e6a584f4f Merge pull request #25 from metacube2/claude/add-macos-support-uYBaj
Add macOS Catalyst support for RollkofferSimulator
2026-01-15 15:24:17 +01:00
admin 6ad6167c52 Merge pull request #24 from metacube2/main-aurora
Main aurora
2026-01-15 15:10:46 +01:00
Claude f9b84e4d3c Add separate zoom wrapper layers for all video modes
- Added live-video-wrapper around webcam-player
- Added timelapse-wrapper inside timelapse-viewer
- Added daily-video-wrapper inside daily-video-player
- Zoom now applies to wrapper divs, not video elements directly
- Pan works by dragging inside video container when zoomed
- Double-click to reset zoom
- Cursor changes to grab when zoomed > 1x
- Touch support for mobile pan
2026-01-15 14:09:42 +00:00
admin 2e6fd332ac Merge pull request #23 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-15 14:54:33 +01:00
Claude b5376f46e5 Merge zoom & pan improvements from main-aurora 2026-01-15 13:51:42 +00:00
Claude 98f1fcae14 Add zoom & pan for all video modes
- Zoom now works for livestream, timelapse and daily videos
- Added pan function: drag to move zoomed area with mouse
- Added touch support for mobile pan
- Added +/- zoom buttons and reset button
- Reduced max zoom from 100x to 4x
- Dynamically detects active video element
- Pan limits based on zoom level
- Cursor changes to grab when zoomed
2026-01-15 13:51:25 +00:00
Claude 14c064de64 Fix video overlay issue - add z-index and disable zoom temporarily
- Added z-index: 10 to webcam-player (higher than overlays)
- Added z-index: 5 and display: none to timelapse/daily-video overlays
- Disabled zoom script temporarily for testing
2026-01-13 09:39:44 +00:00
Claude 313c2108a9 Fix live video player - add native HLS support and debugging
- Added display:block and background:#000 to video element
- Added native HLS detection for Safari (canPlayType check)
- Added console.log debugging for video loading
- Added error event handlers for better debugging
- Added fallback for browsers without HLS support
- Improved HLS.js error handling
2026-01-13 09:34:42 +00:00
admin c12ac16557 Merge pull request #22 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-13 10:27:17 +01:00
Claude b686d4506c Update aurora-livecam with new design and fixed zoom
- New design from main-aurora branch
- Fixed zoom: maxZoom reduced from 100 to 4
- Added zoom +/- buttons
- Added zoom slider with step 0.5
- Fixed video-zoom.js to not apply transform at 1x
2026-01-13 09:26:08 +00:00
admin c38bd130e5 Merge pull request #21 from metacube2/codex/fix-saving-changes-in-audora-project-tgc6un
Add 1–100x zoom controls for all video modes and wire video-zoom.js
2026-01-12 12:59:49 +01:00
admin de343364ad Merge branch 'main-aurora' into codex/fix-saving-changes-in-audora-project-tgc6un 2026-01-12 12:58:08 +01:00
admin e8385adb87 Add zoom controls for video modes 2026-01-12 12:40:22 +01:00
admin f7843e5e35 Merge pull request #20 from metacube2/codex/fix-saving-changes-in-audora-project-4bnojk
Add design switcher (Alpine/Modern) with Swiss-cross and sun overlay; harden settings save
2026-01-12 12:27:48 +01:00
admin 28d2032f23 Merge pull request #19 from metacube2/codex/fix-saving-changes-in-audora-project
Fix settings save path and improve save reliability
2026-01-12 11:33:52 +01:00
admin 1ec8d734ee Fix settings save path 2026-01-12 11:33:33 +01:00
Claude 9e175fdf56 Add indexmiau.php as copy of index.php 2026-01-11 03:23:53 +00:00
admin 13024c5ae8 Merge pull request #18 from metacube2/claude/add-video-download-sppLI
Claude/add video download spp li
2026-01-11 03:49:25 +01:00
Claude 42b12c5c36 Add zoom for all video modes and fix settings saving
- Zoom now works on live video, timelapse images, and archive video player
- Added zoom level indicator (shows percentage)
- Increased max zoom from 3x to 4x
- Fixed settings AJAX handler using FormData for reliable POST
- Settings event handlers now properly bound after DOM load
- Added error handling and visual feedback for settings changes
2026-01-11 02:48:33 +00:00
Claude a033d15912 Refactor index.php with cleaner AJAX handling and simplified code
- Inline AJAX settings handler for better control flow
- Simplify video download logic with condensed code
- Clean up domain redirect handling
- Remove redundant headers and verbose comments
2026-01-11 02:34:26 +00:00
admin 1f9bc08682 Merge pull request #17 from metacube2/main-aurora
Merge pull request #16 from metacube2/claude/mail-finetuning-webapp-0…
2026-01-10 11:49:50 +01:00
Claude 9363a2dd99 Add macOS Catalyst support for RollkofferSimulator
- Enable Mac Catalyst in Xcode project (SUPPORTS_MACCATALYST=YES)
- Set macOS deployment target to 13.0 (Ventura+)
- Add keyboard support for all scenes (Escape, Space, Enter)
- Add macOS menu bar with game commands (Cmd+P pause, Cmd+R restart)
- Configure window size restrictions for macOS
- Update Info.plist with macOS minimum version
2025-12-20 17:46:26 +00:00
1033 changed files with 290644 additions and 104 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/
+379
View File
@@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="de" data-lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PromoMaster - Produkt-Promotion-Tool | Product Promotion Tool</title>
<!-- SEO Meta Tags -->
<meta name="description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch. Generate professional product promotions in German and English.">
<meta name="keywords" content="Produktwerbung, Product Promotion, Marketing, Werbung, Advertising, Social Media, SEO, Product Launch">
<meta name="author" content="PromoMaster">
<meta name="robots" content="index, follow">
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:title" content="PromoMaster - Automatische Produktwerbung">
<meta property="og:description" content="Erstellen Sie automatisch professionelle Produktwerbung in Deutsch und Englisch.">
<meta property="og:locale" content="de_DE">
<meta property="og:locale:alternate" content="en_US">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="PromoMaster - Product Promotion Tool">
<meta name="twitter:description" content="Generate professional product promotions in German and English automatically.">
<!-- JSON-LD Structured Data -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "PromoMaster",
"description": "Automatisches Produkt-Promotion-Tool in Deutsch und Englisch",
"applicationCategory": "Marketing",
"operatingSystem": "Web Browser",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "EUR"
}
}
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Language Toggle -->
<div class="lang-toggle">
<button id="btn-de" class="lang-btn active" onclick="setLanguage('de')">DE</button>
<button id="btn-en" class="lang-btn" onclick="setLanguage('en')">EN</button>
</div>
<!-- Hero Section -->
<header class="hero">
<div class="hero-bg"></div>
<div class="container">
<h1 class="hero-title">
<span class="hero-icon">&#9889;</span>
PromoMaster
</h1>
<p class="hero-subtitle" data-de="Dein Produkt. Weltweit bekannt. Vollautomatisch." data-en="Your Product. Worldwide Fame. Fully Automatic.">Dein Produkt. Weltweit bekannt. Vollautomatisch.</p>
</div>
</header>
<!-- Main App -->
<main class="container">
<!-- Step 1: Product Input -->
<section class="card step-card" id="step1">
<div class="step-header">
<span class="step-number">1</span>
<h2 data-de="Produkt-Informationen" data-en="Product Information"></h2>
</div>
<div class="form-grid">
<div class="form-group">
<label for="productName" data-de="Produktname *" data-en="Product Name *"></label>
<input type="text" id="productName" data-de-placeholder="z.B. SuperWidget Pro" data-en-placeholder="e.g. SuperWidget Pro">
</div>
<div class="form-group">
<label for="productCategory" data-de="Kategorie" data-en="Category"></label>
<select id="productCategory">
<option value="tech" data-de="Technologie" data-en="Technology"></option>
<option value="fashion" data-de="Mode & Bekleidung" data-en="Fashion & Apparel"></option>
<option value="food" data-de="Lebensmittel & Getr&auml;nke" data-en="Food & Beverages"></option>
<option value="health" data-de="Gesundheit & Wellness" data-en="Health & Wellness"></option>
<option value="home" data-de="Haus & Garten" data-en="Home & Garden"></option>
<option value="sport" data-de="Sport & Fitness" data-en="Sports & Fitness"></option>
<option value="beauty" data-de="Sch&ouml;nheit & Pflege" data-en="Beauty & Care"></option>
<option value="education" data-de="Bildung & Kurse" data-en="Education & Courses"></option>
<option value="software" data-de="Software & Apps" data-en="Software & Apps"></option>
<option value="other" data-de="Sonstiges" data-en="Other"></option>
</select>
</div>
<div class="form-group full-width">
<label for="productDescription" data-de="Kurzbeschreibung *" data-en="Short Description *"></label>
<textarea id="productDescription" rows="3" data-de-placeholder="Was macht dein Produkt besonders? (max. 200 Zeichen)" data-en-placeholder="What makes your product special? (max. 200 characters)" maxlength="200"></textarea>
<span class="char-count"><span id="charCount">0</span>/200</span>
</div>
<div class="form-group">
<label for="productPrice" data-de="Preis (optional)" data-en="Price (optional)"></label>
<input type="text" id="productPrice" data-de-placeholder="z.B. 29,99 EUR" data-en-placeholder="e.g. $29.99">
</div>
<div class="form-group">
<label for="productUrl" data-de="Website / Link (optional)" data-en="Website / Link (optional)"></label>
<input type="url" id="productUrl" data-de-placeholder="https://dein-produkt.de" data-en-placeholder="https://your-product.com">
</div>
<div class="form-group full-width">
<label for="productFeatures" data-de="Top-Features (kommagetrennt)" data-en="Top Features (comma-separated)"></label>
<input type="text" id="productFeatures" data-de-placeholder="z.B. Schnell, Zuverl&auml;ssig, Einfach zu bedienen" data-en-placeholder="e.g. Fast, Reliable, Easy to use">
</div>
<div class="form-group full-width">
<label for="targetAudience" data-de="Zielgruppe" data-en="Target Audience"></label>
<input type="text" id="targetAudience" data-de-placeholder="z.B. Unternehmer, Studenten, Eltern" data-en-placeholder="e.g. Entrepreneurs, Students, Parents">
</div>
</div>
</section>
<!-- Step 2: Promotion Style -->
<section class="card step-card" id="step2">
<div class="step-header">
<span class="step-number">2</span>
<h2 data-de="Werbestil w&auml;hlen" data-en="Choose Promotion Style"></h2>
</div>
<div class="style-grid">
<div class="style-option selected" data-style="professional" onclick="selectStyle(this)">
<span class="style-icon">&#128188;</span>
<span class="style-label" data-de="Professionell" data-en="Professional"></span>
</div>
<div class="style-option" data-style="casual" onclick="selectStyle(this)">
<span class="style-icon">&#128075;</span>
<span class="style-label" data-de="Locker & Freundlich" data-en="Casual & Friendly"></span>
</div>
<div class="style-option" data-style="urgent" onclick="selectStyle(this)">
<span class="style-icon">&#9889;</span>
<span class="style-label" data-de="Dringend & FOMO" data-en="Urgent & FOMO"></span>
</div>
<div class="style-option" data-style="luxury" onclick="selectStyle(this)">
<span class="style-icon">&#10024;</span>
<span class="style-label" data-de="Premium & Luxus" data-en="Premium & Luxury"></span>
</div>
<div class="style-option" data-style="fun" onclick="selectStyle(this)">
<span class="style-icon">&#127881;</span>
<span class="style-label" data-de="Spa&szlig;ig & Kreativ" data-en="Fun & Creative"></span>
</div>
<div class="style-option" data-style="minimal" onclick="selectStyle(this)">
<span class="style-icon">&#9711;</span>
<span class="style-label" data-de="Minimalistisch" data-en="Minimalist"></span>
</div>
</div>
</section>
<!-- Generate Button -->
<div class="generate-section">
<button class="btn-generate" onclick="generateAll()" id="generateBtn">
<span class="btn-icon">&#9889;</span>
<span data-de="Alle Werbematerialien generieren" data-en="Generate All Promotion Materials"></span>
</button>
</div>
<!-- Results Section -->
<section id="results" class="results-section hidden">
<!-- Social Media Posts -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128241; Social Media Posts" data-en="&#128241; Social Media Posts"></h3>
<button class="btn-copy-all" onclick="copyAll('social')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="result-tabs">
<button class="tab-btn active" onclick="switchTab(this, 'twitter')">Twitter/X</button>
<button class="tab-btn" onclick="switchTab(this, 'instagram')">Instagram</button>
<button class="tab-btn" onclick="switchTab(this, 'facebook')">Facebook</button>
<button class="tab-btn" onclick="switchTab(this, 'linkedin')">LinkedIn</button>
<button class="tab-btn" onclick="switchTab(this, 'tiktok')">TikTok</button>
</div>
<div class="tab-content" id="tab-twitter">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="twitter-de"></div>
<button class="btn-copy" onclick="copyText('twitter-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="twitter-en"></div>
<button class="btn-copy" onclick="copyText('twitter-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-instagram">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="instagram-de"></div>
<button class="btn-copy" onclick="copyText('instagram-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="instagram-en"></div>
<button class="btn-copy" onclick="copyText('instagram-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-facebook">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="facebook-de"></div>
<button class="btn-copy" onclick="copyText('facebook-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="facebook-en"></div>
<button class="btn-copy" onclick="copyText('facebook-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-linkedin">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="linkedin-de"></div>
<button class="btn-copy" onclick="copyText('linkedin-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="linkedin-en"></div>
<button class="btn-copy" onclick="copyText('linkedin-en')">&#128203;</button>
</div>
</div>
</div>
<div class="tab-content hidden" id="tab-tiktok">
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="tiktok-de"></div>
<button class="btn-copy" onclick="copyText('tiktok-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="tiktok-en"></div>
<button class="btn-copy" onclick="copyText('tiktok-en')">&#128203;</button>
</div>
</div>
</div>
</div>
<!-- Email Marketing -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#9993; E-Mail Marketing" data-en="&#9993; Email Marketing"></h3>
<button class="btn-copy-all" onclick="copyAll('email')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="email-de"></div>
<button class="btn-copy" onclick="copyText('email-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="email-en"></div>
<button class="btn-copy" onclick="copyText('email-en')">&#128203;</button>
</div>
</div>
</div>
<!-- SEO Texts -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128270; SEO-Texte & Metadaten" data-en="&#128270; SEO Texts & Metadata"></h3>
<button class="btn-copy-all" onclick="copyAll('seo')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="seo-de"></div>
<button class="btn-copy" onclick="copyText('seo-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="seo-en"></div>
<button class="btn-copy" onclick="copyText('seo-en')">&#128203;</button>
</div>
</div>
</div>
<!-- Press Release -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128240; Pressemitteilung" data-en="&#128240; Press Release"></h3>
<button class="btn-copy-all" onclick="copyAll('press')" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="lang-results">
<div class="lang-result">
<span class="lang-badge">DE</span>
<div class="result-text" id="press-de"></div>
<button class="btn-copy" onclick="copyText('press-de')">&#128203;</button>
</div>
<div class="lang-result">
<span class="lang-badge en">EN</span>
<div class="result-text" id="press-en"></div>
<button class="btn-copy" onclick="copyText('press-en')">&#128203;</button>
</div>
</div>
</div>
<!-- Slogan Generator -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#128161; Slogans & Taglines" data-en="&#128161; Slogans & Taglines"></h3>
</div>
<div class="slogan-grid" id="sloganGrid"></div>
</div>
<!-- Hashtag Cloud -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="# Hashtag-Wolke" data-en="# Hashtag Cloud"></h3>
<button class="btn-copy-all" onclick="copyHashtags()" data-de="Alle kopieren" data-en="Copy All"></button>
</div>
<div class="hashtag-cloud" id="hashtagCloud"></div>
</div>
<!-- Landing Page Preview -->
<div class="card result-card">
<div class="result-header">
<h3 data-de="&#127760; Landing Page HTML" data-en="&#127760; Landing Page HTML"></h3>
<button class="btn-copy-all" onclick="copyText('landing-code')" data-de="HTML kopieren" data-en="Copy HTML"></button>
</div>
<div class="landing-preview-container">
<div class="preview-toggle">
<button class="preview-btn active" onclick="togglePreview('preview')" data-de="Vorschau" data-en="Preview"></button>
<button class="preview-btn" onclick="togglePreview('code')" data-de="HTML-Code" data-en="HTML Code"></button>
</div>
<div id="landing-preview" class="landing-preview"></div>
<pre id="landing-code" class="landing-code hidden"></pre>
</div>
</div>
<!-- Export Section -->
<div class="card result-card export-card">
<div class="result-header">
<h3 data-de="&#128229; Alles exportieren" data-en="&#128229; Export Everything"></h3>
</div>
<div class="export-grid">
<button class="btn-export" onclick="exportAs('txt')">
<span class="export-icon">&#128196;</span>
<span>TXT</span>
</button>
<button class="btn-export" onclick="exportAs('html')">
<span class="export-icon">&#127760;</span>
<span>HTML</span>
</button>
<button class="btn-export" onclick="exportAs('json')">
<span class="export-icon">&#128218;</span>
<span>JSON</span>
</button>
<button class="btn-export" onclick="exportAs('csv')">
<span class="export-icon">&#128202;</span>
<span>CSV</span>
</button>
</div>
</div>
</section>
</main>
<!-- Toast Notification -->
<div class="toast hidden" id="toast"></div>
<!-- Footer -->
<footer class="footer">
<p data-de="PromoMaster - Vollautomatisches Produkt-Promotion-Tool" data-en="PromoMaster - Fully Automatic Product Promotion Tool"></p>
</footer>
<script src="promo.js"></script>
</body>
</html>
+813
View File
@@ -0,0 +1,813 @@
// === PromoMaster - Product Promotion Tool ===
// Vollautomatisches Produkt-Promotion-Tool in Deutsch und Englisch
(function () {
'use strict';
// --- State ---
let currentLang = 'de';
let selectedStyle = 'professional';
let generatedData = null;
// --- Language System ---
function setLanguage(lang) {
currentLang = lang;
document.documentElement.setAttribute('data-lang', lang);
document.getElementById('btn-de').classList.toggle('active', lang === 'de');
document.getElementById('btn-en').classList.toggle('active', lang === 'en');
document.querySelectorAll('[data-de]').forEach(function (el) {
el.textContent = el.getAttribute('data-' + lang);
});
document.querySelectorAll('[data-de-placeholder]').forEach(function (el) {
el.placeholder = el.getAttribute('data-' + lang + '-placeholder');
});
document.querySelectorAll('select option').forEach(function (opt) {
var val = opt.getAttribute('data-' + lang);
if (val) opt.textContent = val;
});
}
window.setLanguage = setLanguage;
// --- Style Selection ---
function selectStyle(el) {
document.querySelectorAll('.style-option').forEach(function (s) {
s.classList.remove('selected');
});
el.classList.add('selected');
selectedStyle = el.getAttribute('data-style');
}
window.selectStyle = selectStyle;
// --- Character Counter ---
var descInput = document.getElementById('productDescription');
var charCount = document.getElementById('charCount');
if (descInput && charCount) {
descInput.addEventListener('input', function () {
charCount.textContent = descInput.value.length;
});
}
// --- Text Templates ---
var templates = {
professional: {
twitter: {
de: function (p) {
return 'Entdecken Sie ' + p.name + ' \u2013 ' + p.desc + (p.features.length ? '\n\n\u2705 ' + p.features.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtags.slice(0, 4).join(' ');
},
en: function (p) {
return 'Discover ' + p.name + ' \u2013 ' + p.descEn + (p.features.length ? '\n\n\u2705 ' + p.featuresEn.slice(0, 3).join('\n\u2705 ') : '') + (p.url ? '\n\n\ud83d\udc49 ' + p.url : '') + (p.price ? '\n\ud83d\udcb0 ' + p.price : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' ');
}
},
instagram: {
de: function (p) {
return '\u2728 ' + p.name + ' \u2013 Die Zukunft beginnt jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was ' + p.name + ' besonders macht:\n' + p.features.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfekt f\u00fcr: ' + p.audience + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Jetzt f\u00fcr nur ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio \u2b06\ufe0f\n\n' : '') + p.hashtags.join(' ');
},
en: function (p) {
return '\u2728 ' + p.name + ' \u2013 The future starts now!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes ' + p.name + ' special:\n' + p.featuresEn.map(function (f) { return '\ud83d\udd39 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? '\ud83c\udfaf Perfect for: ' + p.audienceEn + '\n\n' : '') + (p.price ? '\ud83d\udcb0 Now only ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio \u2b06\ufe0f\n\n' : '') + p.hashtagsEn.join(' ');
}
},
facebook: {
de: function (p) {
return '\ud83d\ude80 Neu: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udc47 Das sind die Highlights:\n\n' + p.features.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfekt f\u00fcr alle, die ' + p.audience + ' sind.\n\n' : '') + (p.price ? '\ud83d\udcb5 Preis: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Mehr erfahren: ' + p.url + '\n\n' : '') + 'Was denkt ihr? Lasst es uns in den Kommentaren wissen! \ud83d\udc47\n\n' + p.hashtags.slice(0, 5).join(' ');
},
en: function (p) {
return '\ud83d\ude80 New: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udc47 Here are the highlights:\n\n' + p.featuresEn.map(function (f) { return '\u2714\ufe0f ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Perfect for everyone who is ' + p.audienceEn + '.\n\n' : '') + (p.price ? '\ud83d\udcb5 Price: ' + p.price + '\n\n' : '') + (p.url ? '\ud83c\udf10 Learn more: ' + p.url + '\n\n' : '') + 'What do you think? Let us know in the comments! \ud83d\udc47\n\n' + p.hashtagsEn.slice(0, 5).join(' ');
}
},
linkedin: {
de: function (p) {
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation trifft Effizienz\n\nIch freue mich, Ihnen ' + p.name + ' vorzustellen.\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die wichtigsten Vorteile:\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Zielgruppe: ' + p.audience + '\n\n' : '') + (p.price ? 'Investition: ' + p.price + '\n\n' : '') + (p.url ? 'Erfahren Sie mehr: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtags.slice(0, 3).join(' ');
},
en: function (p) {
return '\ud83d\udca1 ' + p.name + ' \u2013 Innovation meets Efficiency\n\nI\'m excited to introduce ' + p.name + ' to you.\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key benefits:\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + (p.audience ? 'Target audience: ' + p.audienceEn + '\n\n' : '') + (p.price ? 'Investment: ' + p.price + '\n\n' : '') + (p.url ? 'Learn more: ' + p.url + '\n\n' : '') + '#Innovation #Business ' + p.hashtagsEn.slice(0, 3).join(' ');
}
},
tiktok: {
de: function (p) {
return '\ud83d\udd25 POV: Du entdeckst gerade ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in Bio!\n\n' : '') + p.hashtags.join(' ') + ' #fyp #viral #musthave';
},
en: function (p) {
return '\ud83d\udd25 POV: You just discovered ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\u2728 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + (p.url ? '\ud83d\udd17 Link in bio!\n\n' : '') + p.hashtagsEn.join(' ') + ' #fyp #viral #musthave';
}
}
},
casual: {
twitter: {
de: function (p) { return 'Hey Leute! \ud83d\udc4b Kennt ihr schon ' + p.name + '? ' + p.desc + (p.features.length ? '\n\nDas Beste daran:\n' + p.features.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nSchaut mal vorbei: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return 'Hey everyone! \ud83d\udc4b Have you heard of ' + p.name + '? ' + p.descEn + (p.features.length ? '\n\nBest thing about it:\n' + p.featuresEn.slice(0, 3).map(function (f) { return '\ud83d\udc4d ' + f; }).join('\n') : '') + (p.url ? '\n\nCheck it out: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return 'Schaut mal was ich gefunden habe! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + (p.features.length ? 'Warum ich es liebe:\n' + p.features.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Wer will es auch haben? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtags.join(' '); },
en: function (p) { return 'Look what I found! \ud83e\udd29\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + (p.features.length ? 'Why I love it:\n' + p.featuresEn.map(function (f) { return '\u2764\ufe0f ' + f; }).join('\n') + '\n\n' : '') + 'Who else wants this? \ud83d\ude4b\u200d\u2640\ufe0f\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return 'Hey Freunde! \ud83d\udc4b\n\nIch muss euch unbedingt von ' + p.name + ' erz\u00e4hlen!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Was mich \u00fcberzeugt hat:\n' + p.features.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das f\u00fcr ' + p.price + ' \u2013 richtig fair!\n\n' : '') + (p.url ? 'Hier gehts lang: ' + p.url + '\n\n' : '') + 'Kennt ihr das schon? \ud83d\ude0d'; },
en: function (p) { return 'Hey friends! \ud83d\udc4b\n\nI have to tell you about ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What convinced me:\n' + p.featuresEn.map(function (f) { return '\ud83d\udc49 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'And all that for ' + p.price + ' \u2013 such a great deal!\n\n' : '') + (p.url ? 'Check it here: ' + p.url + '\n\n' : '') + 'Have you heard of this? \ud83d\ude0d'; }
},
linkedin: {
de: function (p) { return 'Moin zusammen! \ud83d\ude4c\n\nDarf ich vorstellen: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Das macht es so cool:\n' + p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wer hat Lust, das mal auszuprobieren?\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return 'Hey everyone! \ud83d\ude4c\n\nLet me introduce: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'What makes it awesome:\n' + p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Who wants to give it a try?\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return 'OK das m\u00fcsst ihr sehen!! \ud83d\ude31\n\n' + p.name + ' ist einfach WILD!\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Kommentiert wenn ihrs auch braucht! \ud83d\udc47\n\n' + p.hashtags.join(' ') + ' #fyp #musthave'; },
en: function (p) { return 'OK you NEED to see this!! \ud83d\ude31\n\n' + p.name + ' is absolutely WILD!\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 2).map(function (f) { return '\ud83e\udd2f ' + f; }).join('\n') + '\n\n' : '') + 'Comment if you need this too! \ud83d\udc47\n\n' + p.hashtagsEn.join(' ') + ' #fyp #musthave'; }
}
},
urgent: {
twitter: {
de: function (p) { return '\u26a0\ufe0f NUR F\u00dcR KURZE ZEIT: ' + p.name + '!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 Jetzt zuschlagen: ' + p.price + '\n' : '') + '\u23f0 Begrenztes Angebot \u2013 nicht verpassen!\n\n' + (p.url ? '\ud83d\udc49 Sofort sichern: ' + p.url + '\n\n' : '') + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return '\u26a0\ufe0f LIMITED TIME ONLY: ' + p.name + '!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 Get it now: ' + p.price + '\n' : '') + '\u23f0 Limited offer \u2013 don\'t miss out!\n\n' + (p.url ? '\ud83d\udc49 Grab yours: ' + p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return '\ud83d\udea8 ACHTUNG! \ud83d\udea8\n\n' + p.name + ' ist DA!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.features.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Nur solange der Vorrat reicht!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'JETZT HANDELN bevor es zu sp\u00e4t ist! \ud83d\udc47\n\n' + p.hashtags.join(' '); },
en: function (p) { return '\ud83d\udea8 ATTENTION! \ud83d\udea8\n\n' + p.name + ' is HERE!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\udd25 ' + p.featuresEn.join(' \u2022 ') + '\n\n' : '') + '\u23f3 Only while supplies last!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n\n' : '') + 'ACT NOW before it\'s too late! \ud83d\udc47\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 EILMELDUNG \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' ist endlich verf\u00fcgbar!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Fakten:\n' + p.features.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 ACHTUNG: Angebot endet bald!\n' + (p.price ? '\ud83d\udcb5 Nur ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 SOFORT ZUSCHLAGEN: ' + p.url : ''); },
en: function (p) { return '\ud83d\udea8\ud83d\udea8\ud83d\udea8 BREAKING \ud83d\udea8\ud83d\udea8\ud83d\udea8\n\n' + p.name + ' is finally available!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The facts:\n' + p.featuresEn.map(function (f) { return '\u26a1 ' + f; }).join('\n') + '\n\n' : '') + '\u23f0 WARNING: Offer ends soon!\n' + (p.price ? '\ud83d\udcb5 Only ' + p.price + '\n' : '') + (p.url ? '\n\ud83d\udc49 GRAB IT NOW: ' + p.url : ''); }
},
linkedin: {
de: function (p) { return '\ud83d\udea8 Dringende Marktchance: ' + p.name + '\n\n' + p.desc + '\n\n' + (p.features.length ? 'Schl\u00fcsselvorteile:\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'Dieses Angebot ist zeitlich begrenzt. Wer jetzt nicht handelt, verpasst eine einmalige Gelegenheit.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return '\ud83d\udea8 Urgent Market Opportunity: ' + p.name + '\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Key advantages:\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') + '\n\n' : '') + 'This offer is time-limited. Those who don\'t act now will miss a unique opportunity.\n\n' + (p.url ? p.url + '\n\n' : '') + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.desc + '\n\n' + '\u23f0 Letzte Chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEVOR ES WEG IST!\n\n' + p.hashtags.join(' ') + ' #fyp #limitedoffer'; },
en: function (p) { return '\ud83d\udea8 STOP SCROLLING! \ud83d\udea8\n\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' + '\u23f0 Last chance!\n' + (p.price ? '\ud83d\udcb0 ' + p.price + '\n' : '') + '\nLINK IN BIO BEFORE IT\'S GONE!\n\n' + p.hashtagsEn.join(' ') + ' #fyp #limitedoffer'; }
}
},
luxury: {
twitter: {
de: function (p) { return '\u2728 ' + p.name + '\n\nExklusivit\u00e4t neu definiert.\n' + p.desc + '\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' ') + ' #Luxus #Premium'; },
en: function (p) { return '\u2728 ' + p.name + '\n\nRedefining exclusivity.\n' + p.descEn + '\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' ') + ' #Luxury #Premium'; }
},
instagram: {
de: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.desc + '\n\n' + (p.features.length ? 'Exklusive Merkmale:\n' + p.features.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'F\u00fcr alle, die das Beste verdienen.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #Luxury #Exclusive'; },
en: function (p) { return '\u2726 ' + p.name.toUpperCase() + ' \u2726\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Exclusive features:\n' + p.featuresEn.map(function (f) { return '\u2726 ' + f; }).join('\n') + '\n\n' : '') + 'For those who deserve the finest.\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #Luxury #Exclusive'; }
},
facebook: {
de: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfektion kennt keine Kompromisse.\n\n' + (p.price ? 'Ab ' + p.price + '\n' : '') + (p.url ? '\nEntdecken Sie mehr: ' + p.url : ''); },
en: function (p) { return '\u2014\u2014\u2014 ' + p.name.toUpperCase() + ' \u2014\u2014\u2014\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u25c7 ' + f; }).join('\n') + '\n\n' : '') + 'Perfection knows no compromise.\n\n' + (p.price ? 'From ' + p.price + '\n' : '') + (p.url ? '\nDiscover more: ' + p.url : ''); }
},
linkedin: {
de: function (p) { return p.name + ' \u2013 Exzellenz in jeder Hinsicht\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'Wir setzen Ma\u00dfst\u00e4be f\u00fcr Premium-Qualit\u00e4t.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtags.slice(0, 2).join(' '); },
en: function (p) { return p.name + ' \u2013 Excellence in every way\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.map(function (f) { return '\u2022 ' + f; }).join('\n') + '\n\n' : '') + 'We set the standard for premium quality.\n\n' + (p.url ? p.url + '\n\n' : '') + '#Premium #Excellence ' + p.hashtagsEn.slice(0, 2).join(' '); }
},
tiktok: {
de: function (p) { return '\u2728 ' + p.name + ' \u2013 Luxus der n\u00e4chsten Generation\n\n' + p.desc + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtags.join(' ') + ' #luxury #aesthetic #premium'; },
en: function (p) { return '\u2728 ' + p.name + ' \u2013 Next generation luxury\n\n' + p.descEn + '\n\n' + (p.price ? '\u2726 ' + p.price + '\n\n' : '') + p.hashtagsEn.join(' ') + ' #luxury #aesthetic #premium'; }
}
},
fun: {
twitter: {
de: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' ist da und es ist der HAMMER! \ud83d\udd28\n\n' + p.desc + '\n\n' + (p.features.length ? p.features.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Ab gehts: ' + p.url : '') + '\n\n' + p.hashtags.slice(0, 4).join(' '); },
en: function (p) { return '\ud83c\udf89 YOOO! ' + p.name + ' is here and it\'s AMAZING! \ud83d\udd28\n\n' + p.descEn + '\n\n' + (p.features.length ? p.featuresEn.slice(0, 3).map(function (f) { return '\ud83c\udf1f ' + f; }).join('\n') + '\n' : '') + (p.url ? '\n\ud83d\ude80 Let\'s go: ' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 4).join(' '); }
},
instagram: {
de: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' hat mein Leben ver\u00e4ndert und ich bin NICHT dramatisch! \ud83d\ude02\n\n' + p.desc + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.features.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Wer ist dabei?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtags.join(' '); },
en: function (p) { return '\ud83e\udd2f OKAY WOW \ud83e\udd2f\n\n' + p.name + ' changed my life and I\'m NOT being dramatic! \ud83d\ude02\n\n' + p.descEn + '\n\n' + (p.features.length ? 'Reasons to love it:\n' + p.featuresEn.map(function (f) { return '\ud83d\udcab ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in?! \ud83d\ude4b\u200d\u2642\ufe0f\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 ES IST SOWEIT! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' ist gelandet und wir k\u00f6nnen nicht aufh\u00f6ren dar\u00fcber zu reden!\n\n' + p.desc + '\n\n' + (p.features.length ? '\ud83d\ude0d Das ist alles drin:\n' + p.features.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Und das Beste? Nur ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAGGT jemanden der das braucht! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; },
en: function (p) { return '\ud83c\udf89\ud83c\udf89\ud83c\udf89 IT\'S HERE! \ud83c\udf89\ud83c\udf89\ud83c\udf89\n\n' + p.name + ' has landed and we can\'t stop talking about it!\n\n' + p.descEn + '\n\n' + (p.features.length ? '\ud83d\ude0d Here\'s what you get:\n' + p.featuresEn.map(function (f) { return '\ud83d\udca5 ' + f; }).join('\n') + '\n\n' : '') + (p.price ? 'Best part? Only ' + p.price + '! \ud83e\udd11\n\n' : '') + 'TAG someone who needs this! \ud83d\udc47\ud83d\udc47\ud83d\udc47'; }
},
linkedin: {
de: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' existiert jetzt!\n\n' + p.desc + '\n\n' + (p.features.length ? 'Die Highlights (ja, es wird noch besser):\n' + p.features.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Wer will mitmachen? Schreibt mir! \ud83d\ude0e\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return '\ud83d\ude80 Plot Twist: ' + p.name + ' now exists!\n\n' + p.descEn + '\n\n' + (p.features.length ? 'The highlights (yes, it gets even better):\n' + p.featuresEn.map(function (f) { return '\ud83d\udcaa ' + f; }).join('\n') + '\n\n' : '') + 'Who\'s in? DM me! \ud83d\ude0e\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
tiktok: {
de: function (p) { return '\ud83e\udee3 Wenn du ' + p.name + ' noch nicht kennst, lebst du unter einem Stein!\n\n' + p.desc + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 SCHNAPPER!\n' : '') + '\nSpeichern & Teilen nicht vergessen! \ud83d\ude4f\n\n' + p.hashtags.join(' ') + ' #fyp #gamechanger'; },
en: function (p) { return '\ud83e\udee3 If you don\'t know ' + p.name + ' yet, you\'re living under a rock!\n\n' + p.descEn + '\n\n' + (p.price ? '\ud83d\udcb0 ' + p.price + ' \u2013 STEAL!\n' : '') + '\nSave & Share! \ud83d\ude4f\n\n' + p.hashtagsEn.join(' ') + ' #fyp #gamechanger'; }
}
},
minimal: {
twitter: {
de: function (p) { return p.name + '.\n' + p.desc + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtags.slice(0, 3).join(' '); },
en: function (p) { return p.name + '.\n' + p.descEn + (p.url ? '\n\n' + p.url : '') + '\n\n' + p.hashtagsEn.slice(0, 3).join(' '); }
},
instagram: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' / ') : '') + '\n\n' + p.hashtags.join(' '); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' / ') : '') + '\n\n' + p.hashtagsEn.join(' '); }
},
facebook: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.join(' \u2022 ') : '') + (p.price ? '\n\n' + p.price : '') + (p.url ? '\n\n' + p.url : ''); }
},
linkedin: {
de: function (p) { return p.name + '\n\n' + p.desc + (p.features.length ? '\n\n' + p.features.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); },
en: function (p) { return p.name + '\n\n' + p.descEn + (p.features.length ? '\n\n' + p.featuresEn.map(function (f) { return '\u2192 ' + f; }).join('\n') : '') + (p.url ? '\n\n' + p.url : ''); }
},
tiktok: {
de: function (p) { return p.name + '\n' + p.desc + '\n\n' + p.hashtags.slice(0, 5).join(' ') + ' #minimal'; },
en: function (p) { return p.name + '\n' + p.descEn + '\n\n' + p.hashtagsEn.slice(0, 5).join(' ') + ' #minimal'; }
}
}
};
// --- Email Templates ---
var emailTemplates = {
de: function (p) {
return 'Betreff: Entdecken Sie ' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '...\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Sehr geehrte Damen und Herren,\n\n' +
'wir freuen uns, Ihnen ' + p.name + ' vorzustellen \u2013 ' + p.desc + '\n\n' +
(p.features.length ?
'Die wichtigsten Vorteile auf einen Blick:\n\n' +
p.features.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? 'Ideal f\u00fcr: ' + p.audience + '\n\n' : '') +
(p.price ? 'Unser Angebot: ' + p.price + '\n\n' : '') +
(p.url ? '\u27a1 Jetzt mehr erfahren: ' + p.url + '\n\n' : '') +
'Haben Sie Fragen? Antworten Sie einfach auf diese E-Mail \u2013 wir helfen Ihnen gerne weiter.\n\n' +
'Mit freundlichen Gr\u00fc\u00dfen,\n' +
'Ihr ' + p.name + ' Team\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
'Sie erhalten diese E-Mail, weil Sie sich f\u00fcr ' + p.name + ' interessieren.\n' +
'Abmelden | Datenschutz | Impressum';
},
en: function (p) {
return 'Subject: Discover ' + p.name + ' \u2013 ' + p.descEn.substring(0, 60) + '...\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Dear Customer,\n\n' +
'We are excited to introduce ' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
(p.features.length ?
'Key benefits at a glance:\n\n' +
p.featuresEn.map(function (f) { return ' \u2714 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? 'Ideal for: ' + p.audienceEn + '\n\n' : '') +
(p.price ? 'Our offer: ' + p.price + '\n\n' : '') +
(p.url ? '\u27a1 Learn more: ' + p.url + '\n\n' : '') +
'Have questions? Simply reply to this email \u2013 we\'re happy to help.\n\n' +
'Best regards,\n' +
'The ' + p.name + ' Team\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n' +
'You received this email because you showed interest in ' + p.name + '.\n' +
'Unsubscribe | Privacy Policy | Legal';
}
};
// --- SEO Templates ---
var seoTemplates = {
de: function (p) {
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.desc.substring(0, 50) + ' | Jetzt entdecken\n\n' +
'META DESCRIPTION:\n' + p.desc + (p.features.length ? ' \u2714 ' + p.features.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' Ab ' + p.price + '.' : '') + ' Jetzt informieren!\n\n' +
'SEO KEYWORDS:\n' + p.name + ', ' + p.name.toLowerCase() + ' kaufen, ' + p.name.toLowerCase() + ' test, ' + p.name.toLowerCase() + ' erfahrungen, ' +
(p.category ? p.categoryDe + ', ' : '') + 'beste ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' vergleich, ' + p.name.toLowerCase() + ' angebot\n\n' +
'H1 \u00dcBERSCHRIFT:\n' + p.name + ' \u2013 ' + p.desc + '\n\n' +
'H2 \u00dcBERSCHRIFTEN:\n' +
'Warum ' + p.name + '?\n' +
'Funktionen & Vorteile\n' +
'F\u00fcr wen ist ' + p.name + ' geeignet?\n' +
'Jetzt ' + p.name + ' bestellen\n\n' +
'ALT-TEXT F\u00dcR BILDER:\n' +
p.name + ' Produktbild \u2013 ' + p.desc.substring(0, 60);
},
en: function (p) {
return 'META TITLE:\n' + p.name + ' \u2013 ' + p.descEn.substring(0, 50) + ' | Discover Now\n\n' +
'META DESCRIPTION:\n' + p.descEn + (p.features.length ? ' \u2714 ' + p.featuresEn.slice(0, 3).join(' \u2714 ') : '') + (p.price ? ' From ' + p.price + '.' : '') + ' Learn more now!\n\n' +
'SEO KEYWORDS:\n' + p.name + ', buy ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' review, ' + p.name.toLowerCase() + ' features, ' +
(p.category ? p.categoryEn + ', ' : '') + 'best ' + p.name.toLowerCase() + ', ' + p.name.toLowerCase() + ' comparison, ' + p.name.toLowerCase() + ' deal\n\n' +
'H1 HEADING:\n' + p.name + ' \u2013 ' + p.descEn + '\n\n' +
'H2 HEADINGS:\n' +
'Why ' + p.name + '?\n' +
'Features & Benefits\n' +
'Who is ' + p.name + ' for?\n' +
'Order ' + p.name + ' Now\n\n' +
'IMAGE ALT TEXT:\n' +
p.name + ' product image \u2013 ' + p.descEn.substring(0, 60);
}
};
// --- Press Release Templates ---
var pressTemplates = {
de: function (p) {
var today = new Date().toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' });
return 'PRESSEMITTEILUNG\n' +
'Datum: ' + today + '\n' +
'Zur sofortigen Ver\u00f6ffentlichung\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
p.name + ': ' + p.desc + '\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'Wir freuen uns, die Verf\u00fcgbarkeit von ' + p.name + ' bekannt zu geben. ' + p.desc + '\n\n' +
(p.features.length ?
'Hauptmerkmale von ' + p.name + ':\n\n' +
p.features.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? '"' + p.name + ' wurde speziell f\u00fcr ' + p.audience + ' entwickelt", erkl\u00e4rt das Entwicklerteam.\n\n' : '') +
(p.price ? 'Verf\u00fcgbarkeit & Preis:\n' + p.name + ' ist ab sofort zum Preis von ' + p.price + ' erh\u00e4ltlich.\n\n' : '') +
(p.url ? 'Weitere Informationen finden Sie unter: ' + p.url + '\n\n' : '') +
'Pressekontakt:\n' +
'E-Mail: presse@' + p.name.toLowerCase().replace(/\s+/g, '') + '.de\n' +
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.de') + '\n\n' +
'###';
},
en: function (p) {
var today = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
return 'PRESS RELEASE\n' +
'Date: ' + today + '\n' +
'For Immediate Release\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
p.name + ': ' + p.descEn + '\n\n' +
'\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\n' +
'We are pleased to announce the availability of ' + p.name + '. ' + p.descEn + '\n\n' +
(p.features.length ?
'Key Features of ' + p.name + ':\n\n' +
p.featuresEn.map(function (f) { return ' \u2022 ' + f; }).join('\n') + '\n\n' : '') +
(p.audience ? '"' + p.name + ' was specifically designed for ' + p.audienceEn + '," says the development team.\n\n' : '') +
(p.price ? 'Availability & Pricing:\n' + p.name + ' is available now at ' + p.price + '.\n\n' : '') +
(p.url ? 'For more information, visit: ' + p.url + '\n\n' : '') +
'Press Contact:\n' +
'Email: press@' + p.name.toLowerCase().replace(/\s+/g, '') + '.com\n' +
'Web: ' + (p.url || 'www.' + p.name.toLowerCase().replace(/\s+/g, '') + '.com') + '\n\n' +
'###';
}
};
// --- Category Mapping ---
var categoryNames = {
tech: { de: 'Technologie', en: 'Technology' },
fashion: { de: 'Mode & Bekleidung', en: 'Fashion & Apparel' },
food: { de: 'Lebensmittel & Getr\u00e4nke', en: 'Food & Beverages' },
health: { de: 'Gesundheit & Wellness', en: 'Health & Wellness' },
home: { de: 'Haus & Garten', en: 'Home & Garden' },
sport: { de: 'Sport & Fitness', en: 'Sports & Fitness' },
beauty: { de: 'Sch\u00f6nheit & Pflege', en: 'Beauty & Care' },
education: { de: 'Bildung & Kurse', en: 'Education & Courses' },
software: { de: 'Software & Apps', en: 'Software & Apps' },
other: { de: 'Sonstiges', en: 'Other' }
};
// --- Gather Product Data ---
function getProductData() {
var name = document.getElementById('productName').value.trim();
var desc = document.getElementById('productDescription').value.trim();
var price = document.getElementById('productPrice').value.trim();
var url = document.getElementById('productUrl').value.trim();
var features = document.getElementById('productFeatures').value.trim();
var audience = document.getElementById('targetAudience').value.trim();
var category = document.getElementById('productCategory').value;
if (!name || !desc) {
showToast(currentLang === 'de' ? 'Bitte Produktname und Beschreibung eingeben!' : 'Please enter product name and description!');
return null;
}
var featureList = features ? features.split(',').map(function (f) { return f.trim(); }).filter(Boolean) : [];
var catInfo = categoryNames[category] || { de: '', en: '' };
return {
name: name,
desc: desc,
descEn: desc, // User provides in their language; used as-is
price: price,
url: url,
features: featureList,
featuresEn: featureList,
audience: audience,
audienceEn: audience,
category: category,
categoryDe: catInfo.de,
categoryEn: catInfo.en,
hashtags: generateHashtags(name, featureList, category, 'de'),
hashtagsEn: generateHashtags(name, featureList, category, 'en')
};
}
// --- Generate Hashtags ---
function generateHashtags(name, features, category, lang) {
var tags = [];
tags.push('#' + name.replace(/\s+/g, ''));
var catTags = {
tech: { de: ['#Technologie', '#Innovation', '#TechNews', '#Digital', '#Gadget'], en: ['#Technology', '#Innovation', '#TechNews', '#Digital', '#Gadget'] },
fashion: { de: ['#Mode', '#Fashion', '#Style', '#OOTD', '#Trend'], en: ['#Fashion', '#Style', '#OOTD', '#Trend', '#Outfit'] },
food: { de: ['#Foodie', '#Lecker', '#Essen', '#Kochen', '#Genuss'], en: ['#Foodie', '#Delicious', '#FoodLover', '#Cooking', '#Yummy'] },
health: { de: ['#Gesundheit', '#Wellness', '#Fitness', '#Wohlbefinden'], en: ['#Health', '#Wellness', '#Fitness', '#Wellbeing'] },
home: { de: ['#Zuhause', '#Wohnen', '#Interior', '#HomeDecor'], en: ['#Home', '#Living', '#Interior', '#HomeDecor'] },
sport: { de: ['#Sport', '#Fitness', '#Training', '#Motivation'], en: ['#Sports', '#Fitness', '#Training', '#Motivation'] },
beauty: { de: ['#Beauty', '#Pflege', '#Skincare', '#Sch\u00f6nheit'], en: ['#Beauty', '#Skincare', '#SelfCare', '#Glow'] },
education: { de: ['#Bildung', '#Lernen', '#Wissen', '#Weiterbildung'], en: ['#Education', '#Learning', '#Knowledge', '#Growth'] },
software: { de: ['#Software', '#App', '#Digital', '#SaaS', '#Produktivit\u00e4t'], en: ['#Software', '#App', '#Digital', '#SaaS', '#Productivity'] },
other: { de: ['#Neu', '#MustHave', '#Empfehlung'], en: ['#New', '#MustHave', '#Recommended'] }
};
var ct = catTags[category];
if (ct) {
tags = tags.concat(ct[lang] || ct.en);
}
features.slice(0, 2).forEach(function (f) {
tags.push('#' + f.replace(/\s+/g, '').replace(/[^a-zA-Z0-9\u00c0-\u017e]/g, ''));
});
return tags.filter(function (t, i, arr) { return arr.indexOf(t) === i; });
}
// --- Generate Slogans ---
function generateSlogans(p) {
var slogans = [];
var sloganTemplatesDe = [
p.name + ' \u2013 Weil du das Beste verdienst.',
p.name + '. Einfach. Besser. Anders.',
'Die Zukunft hei\u00dft ' + p.name + '.',
p.name + ' \u2013 Dein n\u00e4chster Schritt nach vorn.',
'Erlebe den Unterschied mit ' + p.name + '.',
p.name + '. Mehr als du erwartest.'
];
var sloganTemplatesEn = [
p.name + ' \u2013 Because you deserve the best.',
p.name + '. Simple. Better. Different.',
'The future is called ' + p.name + '.',
p.name + ' \u2013 Your next step forward.',
'Experience the difference with ' + p.name + '.',
p.name + '. More than you expect.'
];
for (var i = 0; i < sloganTemplatesDe.length; i++) {
slogans.push({ de: sloganTemplatesDe[i], en: sloganTemplatesEn[i] });
}
return slogans;
}
// --- Generate Landing Page HTML ---
function generateLandingPage(p) {
var accentColor = '#6C5CE7';
return '<!DOCTYPE html>\n' +
'<html lang="de">\n<head>\n' +
' <meta charset="UTF-8">\n' +
' <meta name="viewport" content="width=device-width, initial-scale=1.0">\n' +
' <title>' + p.name + ' \u2013 ' + p.desc.substring(0, 60) + '</title>\n' +
' <meta name="description" content="' + p.desc + '">\n' +
' <meta property="og:title" content="' + p.name + '">\n' +
' <meta property="og:description" content="' + p.desc + '">\n' +
' <style>\n' +
' * { margin: 0; padding: 0; box-sizing: border-box; }\n' +
' body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #333; }\n' +
' .hero { background: linear-gradient(135deg, ' + accentColor + ', #00CEC9); color: #fff; padding: 80px 20px; text-align: center; }\n' +
' .hero h1 { font-size: 3rem; margin-bottom: 16px; }\n' +
' .hero p { font-size: 1.3rem; opacity: 0.9; max-width: 600px; margin: 0 auto 32px; }\n' +
' .cta-btn { display: inline-block; padding: 16px 40px; background: #fff; color: ' + accentColor + '; font-size: 18px; font-weight: 700; border-radius: 50px; text-decoration: none; transition: transform 0.3s; }\n' +
' .cta-btn:hover { transform: scale(1.05); }\n' +
' .features { padding: 60px 20px; max-width: 800px; margin: 0 auto; }\n' +
' .features h2 { text-align: center; font-size: 2rem; margin-bottom: 40px; }\n' +
' .feature-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 30px; }\n' +
' .feature { text-align: center; padding: 24px; }\n' +
' .feature h3 { color: ' + accentColor + '; margin-bottom: 8px; }\n' +
(p.price ? ' .pricing { text-align: center; padding: 60px 20px; background: #f8f9fa; }\n' +
' .pricing h2 { font-size: 2rem; margin-bottom: 16px; }\n' +
' .price-tag { font-size: 3rem; font-weight: 900; color: ' + accentColor + '; }\n' : '') +
' .footer { text-align: center; padding: 30px; color: #888; font-size: 14px; }\n' +
' </style>\n' +
'</head>\n<body>\n' +
' <section class="hero">\n' +
' <h1>' + p.name + '</h1>\n' +
' <p>' + p.desc + '</p>\n' +
(p.url ? ' <a href="' + escapeHtml(p.url) + '" class="cta-btn">Jetzt entdecken / Discover Now</a>\n' : ' <a href="#features" class="cta-btn">Mehr erfahren / Learn More</a>\n') +
' </section>\n' +
(p.features.length ? ' <section class="features" id="features">\n' +
' <h2>Features</h2>\n' +
' <div class="feature-grid">\n' +
p.features.map(function (f) { return ' <div class="feature">\n <h3>' + escapeHtml(f) + '</h3>\n </div>'; }).join('\n') + '\n' +
' </div>\n' +
' </section>\n' : '') +
(p.price ? ' <section class="pricing">\n' +
' <h2>Preis / Price</h2>\n' +
' <div class="price-tag">' + escapeHtml(p.price) + '</div>\n' +
' </section>\n' : '') +
' <footer class="footer">\n' +
' &copy; ' + new Date().getFullYear() + ' ' + escapeHtml(p.name) + '. All rights reserved.\n' +
' </footer>\n' +
'</body>\n</html>';
}
function escapeHtml(str) {
var div = document.createElement('div');
div.appendChild(document.createTextNode(str));
return div.innerHTML;
}
// --- Main Generate Function ---
function generateAll() {
var p = getProductData();
if (!p) return;
var btn = document.getElementById('generateBtn');
btn.classList.add('loading');
setTimeout(function () {
var style = templates[selectedStyle] || templates.professional;
var platforms = ['twitter', 'instagram', 'facebook', 'linkedin', 'tiktok'];
platforms.forEach(function (platform) {
var tpl = style[platform];
if (tpl) {
setText(platform + '-de', tpl.de(p));
setText(platform + '-en', tpl.en(p));
}
});
// Email
setText('email-de', emailTemplates.de(p));
setText('email-en', emailTemplates.en(p));
// SEO
setText('seo-de', seoTemplates.de(p));
setText('seo-en', seoTemplates.en(p));
// Press Release
setText('press-de', pressTemplates.de(p));
setText('press-en', pressTemplates.en(p));
// Slogans
var slogans = generateSlogans(p);
var sloganGrid = document.getElementById('sloganGrid');
sloganGrid.innerHTML = '';
slogans.forEach(function (s) {
var div = document.createElement('div');
div.className = 'slogan-item';
div.innerHTML = '<span class="slogan-lang">DE</span>' + escapeHtml(s.de);
div.onclick = function () { copyToClipboard(s.de); };
sloganGrid.appendChild(div);
var divEn = document.createElement('div');
divEn.className = 'slogan-item';
divEn.innerHTML = '<span class="slogan-lang">EN</span>' + escapeHtml(s.en);
divEn.onclick = function () { copyToClipboard(s.en); };
sloganGrid.appendChild(divEn);
});
// Hashtags
var hashtagCloud = document.getElementById('hashtagCloud');
hashtagCloud.innerHTML = '';
var allTags = p.hashtags.concat(p.hashtagsEn).filter(function (t, i, arr) { return arr.indexOf(t) === i; });
allTags.forEach(function (tag) {
var span = document.createElement('span');
span.className = 'hashtag';
span.textContent = tag;
span.onclick = function () { copyToClipboard(tag); };
hashtagCloud.appendChild(span);
});
// Landing Page
var landingHtml = generateLandingPage(p);
document.getElementById('landing-code').textContent = landingHtml;
var iframe = document.createElement('iframe');
iframe.srcdoc = landingHtml;
var previewDiv = document.getElementById('landing-preview');
previewDiv.innerHTML = '';
previewDiv.appendChild(iframe);
// Store data for export
generatedData = {
product: p,
style: selectedStyle,
social: {},
email: { de: emailTemplates.de(p), en: emailTemplates.en(p) },
seo: { de: seoTemplates.de(p), en: seoTemplates.en(p) },
press: { de: pressTemplates.de(p), en: pressTemplates.en(p) },
slogans: slogans,
hashtags: allTags,
landingPage: landingHtml
};
platforms.forEach(function (platform) {
var tpl = style[platform];
if (tpl) {
generatedData.social[platform] = { de: tpl.de(p), en: tpl.en(p) };
}
});
// Show results
document.getElementById('results').classList.remove('hidden');
document.getElementById('results').scrollIntoView({ behavior: 'smooth', block: 'start' });
btn.classList.remove('loading');
showToast(currentLang === 'de' ? 'Alle Werbematerialien wurden generiert!' : 'All promotion materials generated!');
}, 600);
}
window.generateAll = generateAll;
// --- Helper Functions ---
function setText(id, text) {
var el = document.getElementById(id);
if (el) el.textContent = text;
}
function copyToClipboard(text) {
navigator.clipboard.writeText(text).then(function () {
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
}).catch(function () {
fallbackCopy(text);
});
}
function fallbackCopy(text) {
var textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
showToast(currentLang === 'de' ? 'Kopiert!' : 'Copied!');
}
function copyText(id) {
var el = document.getElementById(id);
if (el) copyToClipboard(el.textContent);
}
window.copyText = copyText;
function copyAll(section) {
if (!generatedData) return;
var text = '';
if (section === 'social') {
Object.keys(generatedData.social).forEach(function (platform) {
text += '=== ' + platform.toUpperCase() + ' (DE) ===\n' + generatedData.social[platform].de + '\n\n';
text += '=== ' + platform.toUpperCase() + ' (EN) ===\n' + generatedData.social[platform].en + '\n\n';
});
} else if (section === 'email') {
text = '=== EMAIL (DE) ===\n' + generatedData.email.de + '\n\n=== EMAIL (EN) ===\n' + generatedData.email.en;
} else if (section === 'seo') {
text = '=== SEO (DE) ===\n' + generatedData.seo.de + '\n\n=== SEO (EN) ===\n' + generatedData.seo.en;
} else if (section === 'press') {
text = '=== PRESS (DE) ===\n' + generatedData.press.de + '\n\n=== PRESS (EN) ===\n' + generatedData.press.en;
}
copyToClipboard(text);
}
window.copyAll = copyAll;
function copyHashtags() {
if (generatedData) copyToClipboard(generatedData.hashtags.join(' '));
}
window.copyHashtags = copyHashtags;
// --- Tabs ---
function switchTab(btn, tab) {
var parent = btn.closest('.result-card');
parent.querySelectorAll('.tab-btn').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
parent.querySelectorAll('.tab-content').forEach(function (c) { c.classList.add('hidden'); });
document.getElementById('tab-' + tab).classList.remove('hidden');
}
window.switchTab = switchTab;
// --- Landing Page Preview Toggle ---
function togglePreview(mode) {
var preview = document.getElementById('landing-preview');
var code = document.getElementById('landing-code');
var buttons = document.querySelectorAll('.preview-btn');
buttons.forEach(function (b) { b.classList.remove('active'); });
if (mode === 'preview') {
preview.classList.remove('hidden');
code.classList.add('hidden');
buttons[0].classList.add('active');
} else {
preview.classList.add('hidden');
code.classList.remove('hidden');
buttons[1].classList.add('active');
}
}
window.togglePreview = togglePreview;
// --- Export Functions ---
function exportAs(format) {
if (!generatedData) {
showToast(currentLang === 'de' ? 'Bitte zuerst generieren!' : 'Please generate first!');
return;
}
var content = '';
var filename = 'promo-' + generatedData.product.name.replace(/\s+/g, '-').toLowerCase();
var mimeType = 'text/plain';
if (format === 'txt') {
content = buildTextExport();
filename += '.txt';
} else if (format === 'html') {
content = generatedData.landingPage;
filename += '-landingpage.html';
mimeType = 'text/html';
} else if (format === 'json') {
content = JSON.stringify(generatedData, null, 2);
filename += '.json';
mimeType = 'application/json';
} else if (format === 'csv') {
content = buildCsvExport();
filename += '.csv';
mimeType = 'text/csv';
}
downloadFile(content, filename, mimeType);
showToast((currentLang === 'de' ? 'Export als ' : 'Exported as ') + format.toUpperCase() + '!');
}
window.exportAs = exportAs;
function buildTextExport() {
var d = generatedData;
var lines = [];
lines.push('========================================');
lines.push('PROMOMASTER - WERBEMATERIALIEN / PROMOTION MATERIALS');
lines.push('Produkt / Product: ' + d.product.name);
lines.push('Erstellt am / Generated: ' + new Date().toLocaleString());
lines.push('========================================\n');
Object.keys(d.social).forEach(function (platform) {
lines.push('\n--- ' + platform.toUpperCase() + ' (DE) ---');
lines.push(d.social[platform].de);
lines.push('\n--- ' + platform.toUpperCase() + ' (EN) ---');
lines.push(d.social[platform].en);
});
lines.push('\n\n--- E-MAIL MARKETING (DE) ---');
lines.push(d.email.de);
lines.push('\n--- E-MAIL MARKETING (EN) ---');
lines.push(d.email.en);
lines.push('\n\n--- SEO (DE) ---');
lines.push(d.seo.de);
lines.push('\n--- SEO (EN) ---');
lines.push(d.seo.en);
lines.push('\n\n--- PRESSEMITTEILUNG / PRESS RELEASE (DE) ---');
lines.push(d.press.de);
lines.push('\n--- PRESS RELEASE (EN) ---');
lines.push(d.press.en);
lines.push('\n\n--- SLOGANS ---');
d.slogans.forEach(function (s) {
lines.push('DE: ' + s.de);
lines.push('EN: ' + s.en);
});
lines.push('\n\n--- HASHTAGS ---');
lines.push(d.hashtags.join(' '));
return lines.join('\n');
}
function buildCsvExport() {
var d = generatedData;
var rows = [['Platform', 'Language', 'Content']];
Object.keys(d.social).forEach(function (platform) {
rows.push([platform, 'DE', '"' + d.social[platform].de.replace(/"/g, '""') + '"']);
rows.push([platform, 'EN', '"' + d.social[platform].en.replace(/"/g, '""') + '"']);
});
rows.push(['email', 'DE', '"' + d.email.de.replace(/"/g, '""') + '"']);
rows.push(['email', 'EN', '"' + d.email.en.replace(/"/g, '""') + '"']);
rows.push(['seo', 'DE', '"' + d.seo.de.replace(/"/g, '""') + '"']);
rows.push(['seo', 'EN', '"' + d.seo.en.replace(/"/g, '""') + '"']);
rows.push(['press', 'DE', '"' + d.press.de.replace(/"/g, '""') + '"']);
rows.push(['press', 'EN', '"' + d.press.en.replace(/"/g, '""') + '"']);
d.slogans.forEach(function (s, i) {
rows.push(['slogan_' + (i + 1), 'DE', '"' + s.de.replace(/"/g, '""') + '"']);
rows.push(['slogan_' + (i + 1), 'EN', '"' + s.en.replace(/"/g, '""') + '"']);
});
return rows.map(function (r) { return r.join(','); }).join('\n');
}
function downloadFile(content, filename, mimeType) {
var blob = new Blob([content], { type: mimeType + ';charset=utf-8' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
// --- Toast ---
function showToast(msg) {
var toast = document.getElementById('toast');
toast.textContent = msg;
toast.classList.remove('hidden');
toast.classList.add('show');
setTimeout(function () {
toast.classList.remove('show');
setTimeout(function () { toast.classList.add('hidden'); }, 400);
}, 2500);
}
// --- Initialize ---
setLanguage('de');
})();
+724
View File
@@ -0,0 +1,724 @@
/* === PromoMaster - Product Promotion Tool === */
:root {
--primary: #6C5CE7;
--primary-dark: #5A4BD1;
--primary-light: #A29BFE;
--accent: #00CEC9;
--accent-dark: #00B5B0;
--bg: #0F0F1A;
--bg-card: #1A1A2E;
--bg-card-hover: #222240;
--text: #EAEAEA;
--text-muted: #8B8BA3;
--border: #2D2D4A;
--success: #00E676;
--warning: #FFD93D;
--danger: #FF6B6B;
--radius: 16px;
--radius-sm: 10px;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 16px 48px rgba(0, 0, 0, 0.4);
--glow: 0 0 30px rgba(108, 92, 231, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
.container {
max-width: 960px;
margin: 0 auto;
padding: 0 20px;
}
/* === Language Toggle === */
.lang-toggle {
position: fixed;
top: 20px;
right: 20px;
z-index: 100;
display: flex;
gap: 4px;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 30px;
padding: 4px;
box-shadow: var(--shadow);
}
.lang-btn {
padding: 8px 16px;
border: none;
border-radius: 26px;
background: transparent;
color: var(--text-muted);
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.3s ease;
}
.lang-btn.active {
background: var(--primary);
color: #fff;
}
.lang-btn:hover:not(.active) {
color: var(--text);
background: var(--border);
}
/* === Hero === */
.hero {
position: relative;
padding: 80px 0 50px;
text-align: center;
overflow: hidden;
}
.hero-bg {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(ellipse at 50% 50%, rgba(108, 92, 231, 0.15) 0%, transparent 60%),
radial-gradient(ellipse at 80% 20%, rgba(0, 206, 201, 0.1) 0%, transparent 40%);
animation: heroPulse 8s ease-in-out infinite;
}
@keyframes heroPulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.05); opacity: 1; }
}
.hero-title {
font-size: clamp(2.5rem, 6vw, 4rem);
font-weight: 900;
letter-spacing: -2px;
position: relative;
background: linear-gradient(135deg, var(--primary-light), var(--accent));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-icon {
font-size: 0.8em;
-webkit-text-fill-color: initial;
}
.hero-subtitle {
font-size: clamp(1rem, 2.5vw, 1.3rem);
color: var(--text-muted);
margin-top: 12px;
position: relative;
font-weight: 300;
}
/* === Cards === */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 32px;
margin-bottom: 24px;
box-shadow: var(--shadow);
transition: border-color 0.3s ease;
}
.card:hover {
border-color: var(--primary);
}
/* === Step Header === */
.step-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 28px;
}
.step-number {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--primary), var(--accent));
border-radius: 12px;
font-weight: 800;
font-size: 20px;
color: #fff;
flex-shrink: 0;
}
.step-header h2 {
font-size: 1.4rem;
font-weight: 700;
}
/* === Form === */
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-group label {
font-size: 14px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-group input,
.form-group textarea,
.form-group select {
padding: 14px 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 15px;
transition: all 0.3s ease;
outline: none;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(108, 92, 231, 0.2);
}
.form-group select {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238B8BA3' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 16px center;
padding-right: 40px;
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.char-count {
font-size: 12px;
color: var(--text-muted);
text-align: right;
}
/* === Style Options === */
.style-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.style-option {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px 14px;
background: var(--bg);
border: 2px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.3s ease;
text-align: center;
}
.style-option:hover {
border-color: var(--primary-light);
background: var(--bg-card-hover);
}
.style-option.selected {
border-color: var(--primary);
background: rgba(108, 92, 231, 0.1);
box-shadow: var(--glow);
}
.style-icon {
font-size: 32px;
}
.style-label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
}
.style-option.selected .style-label {
color: var(--text);
}
/* === Generate Button === */
.generate-section {
text-align: center;
margin: 40px 0;
}
.btn-generate {
padding: 18px 48px;
font-size: 18px;
font-weight: 700;
font-family: inherit;
color: #fff;
background: linear-gradient(135deg, var(--primary), var(--accent));
border: none;
border-radius: 50px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 8px 30px rgba(108, 92, 231, 0.4);
display: inline-flex;
align-items: center;
gap: 10px;
}
.btn-generate:hover {
transform: translateY(-3px);
box-shadow: 0 12px 40px rgba(108, 92, 231, 0.5);
}
.btn-generate:active {
transform: translateY(0);
}
.btn-generate.loading {
pointer-events: none;
opacity: 0.8;
}
.btn-generate.loading .btn-icon {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.btn-icon {
font-size: 22px;
display: inline-block;
}
/* === Results === */
.results-section {
animation: fadeInUp 0.6s ease;
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(30px); }
to { opacity: 1; transform: translateY(0); }
}
.result-card {
position: relative;
}
.result-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
flex-wrap: wrap;
gap: 10px;
}
.result-header h3 {
font-size: 1.2rem;
font-weight: 700;
}
.btn-copy-all {
padding: 8px 18px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--primary-light);
background: rgba(108, 92, 231, 0.1);
border: 1px solid var(--primary);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-copy-all:hover {
background: var(--primary);
color: #fff;
}
/* === Tabs === */
.result-tabs {
display: flex;
gap: 6px;
margin-bottom: 20px;
overflow-x: auto;
padding-bottom: 4px;
}
.tab-btn {
padding: 10px 20px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
}
.tab-btn:hover {
color: var(--text);
border-color: var(--primary-light);
}
.tab-btn.active {
color: #fff;
background: var(--primary);
border-color: var(--primary);
}
.tab-content {
transition: all 0.3s ease;
}
/* === Language Results === */
.lang-results {
display: flex;
flex-direction: column;
gap: 16px;
}
.lang-result {
position: relative;
padding: 20px;
padding-left: 56px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.lang-badge {
position: absolute;
top: 20px;
left: 16px;
padding: 3px 10px;
font-size: 11px;
font-weight: 800;
color: #fff;
background: linear-gradient(135deg, #E74C3C, #C0392B);
border-radius: 6px;
letter-spacing: 1px;
}
.lang-badge.en {
background: linear-gradient(135deg, #2980B9, #2471A3);
}
.result-text {
font-size: 14px;
line-height: 1.8;
white-space: pre-wrap;
color: var(--text);
padding-right: 40px;
}
.btn-copy {
position: absolute;
top: 16px;
right: 16px;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
cursor: pointer;
font-size: 16px;
transition: all 0.3s ease;
color: var(--text);
}
.btn-copy:hover {
background: var(--primary);
border-color: var(--primary);
}
/* === Slogans === */
.slogan-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.slogan-item {
padding: 18px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
text-align: center;
font-weight: 600;
font-size: 15px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
}
.slogan-item:hover {
border-color: var(--accent);
background: var(--bg-card-hover);
}
.slogan-item .slogan-lang {
font-size: 10px;
font-weight: 800;
color: var(--text-muted);
letter-spacing: 1px;
display: block;
margin-bottom: 6px;
}
/* === Hashtags === */
.hashtag-cloud {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.hashtag {
padding: 8px 16px;
background: rgba(108, 92, 231, 0.1);
border: 1px solid var(--primary);
border-radius: 20px;
font-size: 14px;
font-weight: 600;
color: var(--primary-light);
cursor: pointer;
transition: all 0.3s ease;
}
.hashtag:hover {
background: var(--primary);
color: #fff;
}
/* === Landing Page Preview === */
.landing-preview-container {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.preview-toggle {
display: flex;
background: var(--bg);
border-bottom: 1px solid var(--border);
}
.preview-btn {
flex: 1;
padding: 12px;
font-size: 13px;
font-weight: 600;
font-family: inherit;
color: var(--text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: all 0.3s ease;
}
.preview-btn.active {
color: var(--text);
background: var(--bg-card);
border-bottom: 2px solid var(--primary);
}
.landing-preview {
min-height: 300px;
background: #fff;
}
.landing-preview iframe {
width: 100%;
height: 500px;
border: none;
}
.landing-code {
padding: 20px;
background: var(--bg);
color: var(--accent);
font-size: 13px;
line-height: 1.6;
overflow-x: auto;
max-height: 400px;
white-space: pre;
margin: 0;
cursor: pointer;
}
/* === Export === */
.export-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
}
.btn-export {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px 16px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
}
.btn-export:hover {
border-color: var(--accent);
background: var(--bg-card-hover);
transform: translateY(-2px);
}
.export-icon {
font-size: 28px;
}
/* === Toast === */
.toast {
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%) translateY(20px);
padding: 14px 28px;
background: var(--success);
color: #000;
font-weight: 600;
font-size: 14px;
border-radius: 30px;
z-index: 1000;
opacity: 0;
transition: all 0.4s ease;
box-shadow: 0 8px 30px rgba(0, 230, 118, 0.3);
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* === Footer === */
.footer {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 14px;
border-top: 1px solid var(--border);
margin-top: 60px;
}
/* === Hidden === */
.hidden {
display: none !important;
}
/* === Responsive === */
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.style-grid {
grid-template-columns: repeat(2, 1fr);
}
.slogan-grid {
grid-template-columns: 1fr;
}
.export-grid {
grid-template-columns: repeat(2, 1fr);
}
.card {
padding: 24px 18px;
}
.hero {
padding: 60px 0 30px;
}
.lang-toggle {
top: 12px;
right: 12px;
}
.result-tabs {
gap: 4px;
}
.tab-btn {
padding: 8px 14px;
font-size: 12px;
}
}
@media (max-width: 480px) {
.style-grid {
grid-template-columns: 1fr;
}
.export-grid {
grid-template-columns: 1fr 1fr;
}
.btn-generate {
padding: 16px 32px;
font-size: 16px;
}
}
+54
View File
@@ -14,6 +14,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
#if targetEnvironment(macCatalyst)
// Configure for macOS
configureMacOS()
#endif
return true
}
@@ -33,10 +37,60 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
func applicationDidBecomeActive(_ application: UIApplication) {
// Resume game if needed
}
#if targetEnvironment(macCatalyst)
// MARK: - macOS Configuration
private func configureMacOS() {
// Set minimum window size for macOS
UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.forEach { windowScene in
windowScene.sizeRestrictions?.minimumSize = CGSize(width: 400, height: 600)
windowScene.sizeRestrictions?.maximumSize = CGSize(width: 600, height: 900)
}
}
override func buildMenu(with builder: UIMenuBuilder) {
super.buildMenu(with: builder)
// Remove unnecessary menus for a game
builder.remove(menu: .format)
builder.remove(menu: .edit)
// Add Game menu
let pauseCommand = UIKeyCommand(
title: "Pause",
action: #selector(handlePauseCommand),
input: "p",
modifierFlags: .command
)
let restartCommand = UIKeyCommand(
title: "Neustart",
action: #selector(handleRestartCommand),
input: "r",
modifierFlags: .command
)
let gameMenu = UIMenu(
title: "Spiel",
children: [pauseCommand, restartCommand]
)
builder.insertSibling(gameMenu, afterMenu: .file)
}
@objc private func handlePauseCommand() {
NotificationCenter.default.post(name: .pauseGame, object: nil)
}
@objc private func handleRestartCommand() {
NotificationCenter.default.post(name: .restartGame, object: nil)
}
#endif
}
// MARK: - Notification Names
extension Notification.Name {
static let pauseGame = Notification.Name("pauseGame")
static let resumeGame = Notification.Name("resumeGame")
static let restartGame = Notification.Name("restartGame")
}
@@ -36,6 +36,10 @@ class GameViewController: UIViewController {
// Setup notification observers
setupNotificationObservers()
#if targetEnvironment(macCatalyst)
setupMacCatalyst()
#endif
}
private func setupNotificationObservers() {
@@ -45,6 +49,13 @@ class GameViewController: UIViewController {
name: .pauseGame,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(handleRestartNotification),
name: .restartGame,
object: nil
)
}
@objc private func handlePauseNotification() {
@@ -57,12 +68,46 @@ class GameViewController: UIViewController {
// This is just a notification that the app is going to background
}
@objc private func handleRestartNotification() {
guard let skView = self.view as? SKView else { return }
let menuScene = MenuScene(size: skView.bounds.size)
menuScene.scaleMode = .aspectFill
let transition = SKTransition.fade(withDuration: 0.5)
skView.presentScene(menuScene, transition: transition)
}
#if targetEnvironment(macCatalyst)
private func setupMacCatalyst() {
// Configure window appearance for macOS
if let windowScene = view.window?.windowScene {
windowScene.title = "Rollkoffer Simulator"
// Set window style
if let titlebar = windowScene.titlebar {
titlebar.titleVisibility = .visible
titlebar.toolbarStyle = .unified
}
}
}
// Enable keyboard input
override var canBecomeFirstResponder: Bool {
return true
}
#endif
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
#if targetEnvironment(macCatalyst)
return .all
#else
if UIDevice.current.userInterfaceIdiom == .phone {
return .portrait
} else {
return .all
}
#endif
}
override var prefersStatusBarHidden: Bool {
+4
View File
@@ -50,5 +50,9 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>LSMinimumSystemVersion</key>
<string>13.0</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright 2024 Ingo K. All rights reserved.</string>
</dict>
</plist>
@@ -381,6 +381,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -393,9 +394,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -409,6 +413,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Info.plist;
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
@@ -421,9 +426,12 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.ingok.RollkofferSimulator;
PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@@ -223,6 +223,27 @@ class GameOverScene: SKScene {
}
}
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
retryGame()
case .keyboardEscape:
returnToMenu()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func retryGame() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
@@ -318,6 +318,29 @@ class GameScene: SKScene {
isDragging = false
}
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardEscape:
togglePause()
case .keyboardSpacebar:
if gameState.currentState == .paused {
resumeGame()
}
default:
super.pressesBegan(presses, with: event)
}
}
#endif
// MARK: - Pause Handling
private func togglePause() {
if gameState.currentState == .playing {
@@ -245,6 +245,25 @@ class MenuScene: SKScene {
}
}
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
startGame()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func startGame() {
// Button press effect
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
@@ -280,6 +280,27 @@ class VictoryScene: SKScene {
}
}
// MARK: - Keyboard Handling (macOS)
#if targetEnvironment(macCatalyst)
override var canBecomeFirstResponder: Bool { true }
override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
guard let key = presses.first?.key else {
super.pressesBegan(presses, with: event)
return
}
switch key.keyCode {
case .keyboardSpacebar, .keyboardReturnOrEnter:
playAgain()
case .keyboardEscape:
returnToMenu()
default:
super.pressesBegan(presses, with: event)
}
}
#endif
private func playAgain() {
let pressDown = SKAction.scale(to: 0.9, duration: 0.1)
let pressUp = SKAction.scale(to: 1.0, duration: 0.1)
+8
View File
@@ -0,0 +1,8 @@
# Build artifacts
bin/
obj/
# Visual Studio user/IDE files
.vs/
*.user
*.suo
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.

After

Width:  |  Height:  |  Size: 61 KiB

+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trafag Finanze/Sales Management Cockpit</title>
<base href="@BaseHref" />
<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="_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="_content/MudBlazor/MudBlazor.min.js"></script>
<script src="js/download.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,47 @@
@using TrafagSalesExporter.Services
@inject IFinanceCockpitAccessService FinanceAccess
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject IUiTextService UiText
<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>
}
<MudTextField @bind-Value="_username" Label="@T("Name", "Name")" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudTextField @bind-Value="_password" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!FinanceAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!FinanceAccess.IsConfigured)">
@T("Finance Cockpit entsperren", "Unlock Finance Cockpit")
</MudButton>
</MudStack>
</MudPaper>
@code {
private string? _username;
private string? _password;
private Task UnlockAsync()
{
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 string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,870 @@
@using Microsoft.AspNetCore.Components
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IUiTextService UiText
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Ueberblick", "Overview")" Icon="@Icons.Material.Filled.Dashboard">
@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>
</MudTabPanel>
<MudTabPanel Text="@T("Fluktuation", "Turnover")" Icon="@Icons.Material.Filled.TrendingDown">
@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>
</MudTabPanel>
<MudTabPanel Text="@T("Ampel", "Status")" Icon="@Icons.Material.Filled.Traffic">
@TrafficLightPanel(Result.TrafficLights)
@MetricGrid(Result.PeriodComparisonMetrics)
</MudTabPanel>
<MudTabPanel Text="@T("Absenzen", "Absences")" Icon="@Icons.Material.Filled.Sick">
@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>
</MudTabPanel>
<MudTabPanel Text="@T("Zeit / Ferien", "Time / Vacation")" Icon="@Icons.Material.Filled.EventAvailable">
@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>
</MudTabPanel>
<MudTabPanel Text="@T("Mitarbeitende", "Employees")" Icon="@Icons.Material.Filled.Groups">
@EmployeesTable(Result.Employees)
</MudTabPanel>
<MudTabPanel Text="@T("Datenstatus", "Data status")" Icon="@Icons.Material.Filled.FactCheck">
@FileStatusTable(Result.FileStatuses)
<MudGrid Class="mt-4">
<MudItem xs="12">
@DataQualityTable(Result.DataQualityIssues)
</MudItem>
</MudGrid>
</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 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-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>
@@ -0,0 +1,87 @@
@inherits LayoutComponentBase
@implements IDisposable
@using System.Security.Claims
@inject TrafagSalesExporter.Services.IUiTextService UiText
<MudThemeProvider Theme="_theme" />
<MudPopoverProvider />
<MudDialogProvider />
<MudSnackbarProvider />
<MudLayout>
<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 app-title">@T("Trafag Finanze/Sales Management Cockpit", "Trafag Finance/Sales Management Cockpit")</MudText>
<MudSpacer />
<MudSelect T="string"
Value="@UiText.CurrentLanguage"
ValueChanged="ChangeLanguage"
Dense
Variant="Variant.Outlined"
Class="mr-3"
Style="min-width:100px; color:white;">
<MudSelectItem Value="@("de")">DE</MudSelectItem>
<MudSelectItem Value="@("en")">EN</MudSelectItem>
</MudSelect>
<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" @key="UiText.CurrentLanguage">
@Body
</MudMainContent>
</MudLayout>
@code {
private bool _drawerOpen = true;
private readonly MudTheme _theme = new()
{
PaletteLight = new PaletteLight
{
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 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;
}
}
@@ -0,0 +1,68 @@
@using TrafagSalesExporter.Security
@inject TrafagSalesExporter.Services.IUiTextService UiText
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
@inject IConfiguration Configuration
@inject NavigationManager Navigation
<MudNavMenu>
<MudNavGroup Title="@T("Finance Cockpit", "Finance Cockpit")" Icon="@Icons.Material.Filled.Analytics" Expanded="true">
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Dashboard">
@T("Export Dashboard", "Export dashboard")
</MudNavLink>
<MudNavLink Href="/management-cockpit" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.QueryStats">
@T("Management Analyse", "Management analysis")
</MudNavLink>
@if (ShowFinanceComparison)
{
<MudNavLink Href="/finance-cockpit/vergleich" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.CompareArrows">
@T("Soll/Ist Vergleich", "Actual/reference comparison")
</MudNavLink>
}
<MudNavLink Href="/manual-imports" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.UploadFile">
@T("Manuelle Importe", "Manual imports")
</MudNavLink>
<MudNavGroup Title="@T("Admin", "Admin")" Icon="@Icons.Material.Filled.AdminPanelSettings">
<AuthorizeView Policy="@SecurityPolicies.AdminOnly">
<Authorized>
<MudNavLink Href="/standorte" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.LocationOn">
@T("Standorte", "Sites")
</MudNavLink>
<MudNavLink Href="/transformations" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Transform">
@T("Transformationen", "Transformations")
</MudNavLink>
<MudNavLink Href="/finance-rules" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Rule">
@T("Finance Regeln", "Finance rules")
</MudNavLink>
<MudNavLink Href="/settings" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Settings">
@T("Settings", "Settings")
</MudNavLink>
</Authorized>
</AuthorizeView>
<MudNavLink Href="/logs" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.List">
@T("Logs", "Logs")
</MudNavLink>
</MudNavGroup>
@if (FinanceAccess.IsEnabled && FinanceAccess.IsUnlocked)
{
<MudButton Variant="Variant.Text" Color="Color.Secondary" Size="Size.Small"
StartIcon="@Icons.Material.Filled.Lock" OnClick="LockFinanceCockpit" Class="ml-3">
@T("Finance sperren", "Lock finance")
</MudButton>
}
</MudNavGroup>
<MudNavLink Href="/hr-kpi" Match="NavLinkMatch.Prefix" Icon="@Icons.Material.Filled.Groups">
@T("HR KPI (Login)", "HR KPI (login)")
</MudNavLink>
</MudNavMenu>
@code {
private bool ShowFinanceComparison => Configuration.GetValue("Navigation:ShowFinanceComparison", true);
private void LockFinanceCockpit()
{
FinanceAccess.Lock();
Navigation.NavigateTo("/");
}
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,482 @@
@page "/"
@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">
<MudStack Row AlignItems="AlignItems.Center" Spacing="4">
<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>
</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>
@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,216 @@
@page "/finance-cockpit/vergleich"
@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 CentralSalesRecords", "Authoritative finance view from CentralSalesRecords")</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,167 @@
@page "/finance-rules"
@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,263 @@
@page "/hr-kpi"
@using Microsoft.Extensions.Options
@using TrafagSalesExporter.Components.HrKpi
@using TrafagSalesExporter.Services
@inject IHrKpiService HrKpiService
@inject IOptions<HrKpiDataSourceOptions> DataSourceOptions
@inject IHrKpiAccessService HrKpiAccess
@inject ISnackbar Snackbar
@inject IUiTextService UiText
@inject IJSRuntime JsRuntime
<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>
}
<MudTextField @bind-Value="_hrUsername" Label="@T("Name", "Name")" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudTextField @bind-Value="_hrPassword" Label="@T("Passwort", "Password")" InputType="InputType.Password" Disabled="@(!HrKpiAccess.IsConfigured)" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="UnlockHrKpiAsync"
StartIcon="@Icons.Material.Filled.LockOpen" Disabled="@(!HrKpiAccess.IsConfigured)">
@T("HR KPI entsperren", "Unlock HR KPI")
</MudButton>
</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("Standard ist C:\\temp. Der Ordner kann hier fuer den aktuellen Lauf angepasst oder dauerhaft in appsettings.json unter HrKpi:DataFolder geaendert werden.", "Default is C:\\temp. The folder can be changed here for the current run or permanently in appsettings.json under HrKpi:DataFolder.")" />
</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 Austritt", "Exit from")" Clearable DateFormat="dd.MM.yyyy" />
</MudItem>
<MudItem xs="12" md="3">
<MudDatePicker @bind-Date="_toDate" Label="@T("Bis Austritt", "Exit to")" Clearable DateFormat="dd.MM.yyyy" />
</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>
</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 bool _loading;
private HrKpiResult? _result;
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"];
protected override async Task OnInitializedAsync()
{
_dataFolder = DataSourceOptions.Value.Normalize().DataFolder;
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
});
}
catch (Exception ex)
{
Snackbar.Add(ex.Message, Severity.Error);
}
finally
{
_loading = false;
}
}
private async Task UnlockHrKpiAsync()
{
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 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);
}
@@ -0,0 +1,154 @@
@page "/logs"
@using TrafagSalesExporter.Services
@inject ILogsPageService LogsPageActions
@inject ISnackbar Snackbar
@inject IDialogService DialogService
@inject TrafagSalesExporter.Services.IUiTextService UiText
<PageTitle>@T("Logs", "Logs")</PageTitle>
<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="@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="@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="@T("Datum", "Date")" Clearable Dense Style="max-width:200px;" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFilter"
StartIcon="@Icons.Material.Filled.FilterAlt">
@T("Filtern", "Filter")
</MudButton>
<MudSpacer />
<MudButton Variant="Variant.Outlined" Color="Color.Error" OnClick="DeleteOldLogs"
StartIcon="@Icons.Material.Filled.DeleteSweep">
@T("Alte Logs loeschen", "Delete old logs")
</MudButton>
</MudStack>
</MudPaper>
<MudTable Items="_logs" Dense Hover Striped Loading="_loading">
<HeaderContent>
<MudTh>@T("Zeitpunkt", "Timestamp")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>TSC</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>
<MudTd>@context.Land</MudTd>
<MudTd>@context.TSC</MudTd>
<MudTd>
@if (context.Status == "OK")
{
<MudChip T="string" Size="Size.Small" Color="Color.Success">OK</MudChip>
}
else
{
<MudChip T="string" Size="Size.Small" Color="Color.Error">Error</MudChip>
}
</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
<MudTd>@($"{context.DurationSeconds:F1}s")</MudTd>
<MudTd>@context.FileName</MudTd>
<MudTd>
@if (!string.IsNullOrEmpty(context.ErrorMessage))
{
<MudTooltip Text="@context.ErrorMessage">
<MudText Typo="Typo.caption" Color="Color.Error" Style="max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; display:block;">
@context.ErrorMessage
</MudText>
</MudTooltip>
}
</MudTd>
</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;
private DateTime? _filterDate;
private bool _loading = true;
protected override async Task OnInitializedAsync()
{
await LoadLogsAsync();
}
private async Task LoadLogsAsync()
{
_loading = true;
var state = await LogsPageActions.LoadAsync(_filterLand, _filterStatus, _filterDate);
_availableLands = state.AvailableLands;
_logs = state.Logs;
_appLogs = state.AppLogs;
_loading = false;
}
private async Task ApplyFilter()
{
await LoadLogsAsync();
}
private async Task DeleteOldLogs()
{
var result = await DialogService.ShowMessageBox(
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;
var deletedCount = await LogsPageActions.DeleteOldLogsAsync(90);
await LoadLogsAsync();
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);
}
@@ -0,0 +1,673 @@
@page "/management-cockpit"
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject IManagementCockpitPageService CockpitPageService
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>@T("Management Analyse", "Management analysis")</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">@T("Management Analyse", "Management analysis")</MudText>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="3">
<MudSelect T="int" @bind-Value="_selectedFinanceYear" Label="@T("Finance-Jahr", "Finance year")" Dense>
@foreach (var year in _financeYearOptions)
{
<MudSelectItem Value="@year">@year</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFinanceCountryKey" Label="@T("Land", "Country")" Dense Clearable>
@foreach (var option in _financeCountryOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFinanceCurrency" Label="@T("Waehrung", "Currency")" Dense Clearable>
@foreach (var option in _financeCurrencyOptions)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AnalyzeFinanceSummary"
StartIcon="@Icons.Material.Filled.FactCheck" Disabled="_analyzingFinance" FullWidth>
@(_analyzingFinance ? T("Lade...", "Loading...") : T("Finance Summary laden", "Load finance summary"))
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
@if (_financeResult is not null)
{
<MudTabs Elevation="1" Rounded="false" PanelClass="pt-4">
<MudTabPanel Text="@T("Finance Summary", "Finance summary")" Icon="@Icons.Material.Filled.Dashboard">
<MudGrid Class="mb-4">
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Net Sales Actual", "Net sales actual")</MudText>
<MudText Typo="Typo.h5">@FormatValue(_financeResult.NetSalesActual, _financeResult.DisplayCurrency)</MudText>
<MudText Typo="Typo.body2">@T("gefiltertes Endergebnis", "filtered final result")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Enthaltene Zeilen", "Included rows")</MudText>
<MudText Typo="Typo.h5">@_financeResult.IncludedRows.ToString("N0")</MudText>
<MudText Typo="Typo.body2">@T("Finance Include = TRUE", "Finance Include = TRUE")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Ausgeschlossen", "Excluded")</MudText>
<MudText Typo="Typo.h5">@_financeResult.ExcludedRows.ToString("N0")</MudText>
<MudText Typo="Typo.body2">@T("Finance-Regeln", "Finance rules")</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.caption">@T("Laender / Waehrungen", "Countries / currencies")</MudText>
<MudText Typo="Typo.h5">@($"{_financeResult.CountryCount:N0} / {_financeResult.CurrencyCount:N0}")</MudText>
<MudText Typo="Typo.body2">@($"{_financeResult.Filter.Year}")</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudGrid Class="mb-4">
<MudItem xs="12" md="8">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Summen wie im Excel-Blatt Finance Summary", "Totals matching the Finance Summary Excel sheet")</MudText>
<MudTable Items="_financeResult.Rows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Jahr", "Year")</MudTh>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Net Sales Actual", "Net sales actual")</MudTh>
<MudTh>@T("Enthalten", "Included")</MudTh>
<MudTh>@T("Ausgeschlossen", "Excluded")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Year</MudTd>
<MudTd>@context.CountryKey</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.NetSalesActual, context.Currency)</MudTd>
<MudTd>@context.IncludedRows.ToString("N0")</MudTd>
<MudTd>@context.ExcludedRows.ToString("N0")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.body2">
@T("Keine Finance-Summary-Daten fuer diese Filter.", "No finance summary data for these filters.")
</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
@foreach (var notice in _financeResult.Notices)
{
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
}
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahresvergleich mit aktuellem Filter", "Year comparison with current filter")</MudText>
<MudTable Items="_financeResult.YearRows" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Jahr", "Year")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@T("Net Sales Actual", "Net sales actual")</MudTh>
<MudTh>@T("Enthalten", "Included")</MudTh>
<MudTh>@T("Ausgeschlossen", "Excluded")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Year</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.NetSalesActual, context.Currency)</MudTd>
<MudTd>@context.IncludedRows.ToString("N0")</MudTd>
<MudTd>@context.ExcludedRows.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudTabPanel>
<MudTabPanel Text="@T("Rohdaten Diagnose", "Raw-data diagnostics")" Icon="@Icons.Material.Filled.QueryStats">
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudSelect T="string" @bind-Value="_selectedFilePath" Label="@T("Vorhandene Excel-Datei", "Available Excel file")" Dense>
@foreach (var file in _files)
{
<MudSelectItem Value="@file.Path">@file.DisplayName</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFileValueField" Label="@T("Summenfeld", "Value field")" Dense>
@foreach (var option in _valueFieldOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedFileTargetCurrency" Label="@T("Anzeige-Waehrung", "Display currency")" Dense>
@foreach (var option in _currencyOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="ReloadFiles"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_loadingFiles">
@T("Dateien laden", "Load files")
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="Analyze"
StartIcon="@Icons.Material.Filled.Analytics" Disabled="_analyzing || string.IsNullOrWhiteSpace(_selectedFilePath)">
@(_analyzing ? T("Analysiere...", "Analyzing...") : T("Cockpit erzeugen", "Build cockpit"))
</MudButton>
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-3">@T("Zentrale Roh-Auswertung", "Central raw analysis")</MudText>
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-3">
@T("Diese Sicht arbeitet direkt auf `CentralSalesRecords`. Summenfeld und Anzeige-Waehrung koennen gewaehlt werden; fachliche Filter wie Intercompany, Budget und Spartenlogik sind weiterhin nicht enthalten.", "This view works directly on `CentralSalesRecords`. Value field and display currency can be selected; business filters such as intercompany, budget and divisional logic are still not included.")
</MudAlert>
<MudAlert Severity="Severity.Warning" Dense Variant="Variant.Outlined" Class="mb-3">
@T("Diese Analyse ist eine Plausibilitaets- und Rohdatensicht. Fuer den verbindlichen Finance-Abgleich bitte `Soll/Ist Vergleich` oder im Endexcel die `Finance | ...`-Spalten verwenden.",
"This analysis is a plausibility/raw-data view. For the authoritative finance reconciliation, use `Actual/reference comparison` or the `Finance | ...` columns in the final Excel.")
</MudAlert>
<MudGrid>
<MudItem xs="12" md="2">
<MudSelect T="int" @bind-Value="_selectedCentralYear" Label='@T("Jahr", "Year")' Dense>
@foreach (var year in _centralYears)
{
<MudSelectItem Value="@year">@year</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudTextField @bind-Value="_centralLandFilter" Label="@T("Landfilter", "Country filter")" />
</MudItem>
<MudItem xs="12" md="2">
<MudTextField @bind-Value="_centralTscFilter" Label="TSC" />
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="int?" @bind-Value="_selectedCentralMonth" Label='@T("Monat (optional)", "Month (optional)")' Dense Clearable>
@foreach (var month in Enumerable.Range(1, 12))
{
<MudSelectItem Value="@((int?)month)">@($"{month:D2}")</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string" @bind-Value="_selectedCentralValueField" Label="@T("Summenfeld", "Value field")" Dense>
@foreach (var option in _valueFieldOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="3">
<MudSelect T="string"
SelectedValues="_selectedCentralAdditionalValueFields"
SelectedValuesChanged="SetSelectedCentralAdditionalValueFields"
MultiSelection="true"
Label="@T("Weitere Summenfelder", "Additional value fields")"
Dense>
@foreach (var option in _valueFieldOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" md="2">
<MudSelect T="string" @bind-Value="_selectedCentralTargetCurrency" Label="@T("Anzeige-Waehrung", "Display currency")" Dense>
@foreach (var option in _currencyOptions)
{
<MudSelectItem Value="@option.Key">@option.Label</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" OnClick="AnalyzeCentral"
StartIcon="@Icons.Material.Filled.QueryStats" Disabled="_analyzingCentral || _selectedCentralYear == 0">
@(_analyzingCentral ? T("Analysiere...", "Analyzing...") : T("Zentrale Auswertung laden", "Load central analysis"))
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Default" OnClick="ClearCentralScope"
StartIcon="@Icons.Material.Filled.FilterAltOff">
@T("Global", "Global")
</MudButton>
@if (!string.IsNullOrWhiteSpace(_centralLandFilter) || !string.IsNullOrWhiteSpace(_centralTscFilter))
{
<MudChip T="string" Size="Size.Small" Color="Color.Info" Variant="Variant.Outlined">
@T("Gefiltert", "Filtered"): @($"{(_centralLandFilter ?? "-")} / {(_centralTscFilter ?? "-")}")
</MudChip>
}
</MudStack>
</MudItem>
</MudGrid>
</MudPaper>
@if (_result is not null)
{
<MudGrid Class="mb-4">
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Land", "Country")</MudText><MudText Typo="Typo.h6">@_result.Summary.Land</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">TSC</MudText><MudText Typo="Typo.h6">@_result.Summary.Tsc</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@_result.Summary.ValueFieldLabel</MudText><MudText Typo="Typo.h6">@FormatValue(_result.Summary.AggregatedValueTotal, _result.Summary.DisplayCurrency)</MudText></MudPaper></MudItem>
<MudItem xs="12" md="3"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Nicht umgerechnet", "Not converted")</MudText><MudText Typo="Typo.h6">@_result.Summary.MissingExchangeRateCount.ToString("N0")</MudText></MudPaper></MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Management Aussagen", "Management statements")</MudText>
@foreach (var finding in _result.Findings)
{
<MudAlert Severity="@MapSeverity(finding.Severity)" Dense Variant="Variant.Outlined" Class="mb-2">
<b>@finding.Title:</b> @finding.Detail
</MudAlert>
}
</MudPaper>
<MudGrid Class="mb-4">
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Kunden", "Top customers")</MudText>
@foreach (var item in _result.TopCustomers)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Produktgruppen", "Top product groups")</MudText>
@foreach (var item in _result.TopProductGroups)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="4">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Top Sales Owner", "Top sales owner")</MudText>
@foreach (var item in _result.TopSalesEmployees)
{
<MudText Typo="Typo.body2">@($"{item.Label}: {FormatValue(item.Value, _result.Summary.DisplayCurrency)} ({item.SharePercent:F1}%)")</MudText>
}
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Datenqualitaet", "Data quality")</MudText>
@foreach (var entry in _result.DataQualityCounts.OrderByDescending(x => x.Value))
{
<MudText Typo="Typo.body2">@($"{entry.Key}: {entry.Value}")</MudText>
}
</MudPaper>
}
@if (_centralResult is not null)
{
<MudGrid Class="mb-4">
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Zeilen", "Rows")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.RowCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Rechnungen", "Invoices")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.InvoiceCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Standorte", "Sites")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.SiteCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Laender", "Countries")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.CountryCount.ToString("N0")</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@_centralResult.Summary.ValueFieldLabel</MudText><MudText Typo="Typo.h6">@FormatValue(_centralResult.Summary.ValueTotal, _centralResult.Summary.DisplayCurrency)</MudText></MudPaper></MudItem>
<MudItem xs="12" md="2"><MudPaper Class="pa-4"><MudText Typo="Typo.caption">@T("Nicht umgerechnet", "Not converted")</MudText><MudText Typo="Typo.h6">@_centralResult.Summary.MissingExchangeRateCount.ToString("N0")</MudText></MudPaper></MudItem>
</MudGrid>
<MudPaper Class="pa-4 mb-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Hinweise", "Notes")</MudText>
@foreach (var notice in _centralResult.Notices)
{
<MudAlert Severity="Severity.Info" Dense Variant="Variant.Outlined" Class="mb-2">@notice</MudAlert>
}
</MudPaper>
<MudGrid Class="mb-4">
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Jahreswerte", "Yearly values")</MudText>
<MudTable Items="_centralResult.YearlyTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Jahr", "Year")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Year</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Monatswerte", "Monthly values")</MudText>
<MudTable Items="_centralResult.MonthlyTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Monat", "Month")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
</MudGrid>
<MudGrid Class="mb-4">
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Tageswerte im ausgewaehlten Monat", "Daily values in selected month")</MudText>
<MudTable Items="_centralResult.DailyTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Tag", "Day")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTh>@field.Label</MudTh>
}
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
@foreach (var field in _centralResult.AdditionalValueFields)
{
<MudTd>@FormatAdditionalValue(context, field.Key)</MudTd>
}
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
<NoRecordsContent>
<MudText Typo="Typo.caption">@T("Fuer die Tagessicht bitte zusaetzlich einen Monat waehlen.", "Please select a month as well for the daily view.")</MudText>
</NoRecordsContent>
</MudTable>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Werte nach Quelle", "Values by source")</MudText>
<MudTable Items="_centralResult.SourceSystemTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Quelle", "Source")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
<MudTh>@T("Rechnungen", "Invoices")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
<MudTd>@context.InvoiceCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
</MudItem>
</MudGrid>
<MudPaper Class="pa-4" Elevation="1">
<MudText Typo="Typo.h6" Class="mb-2">@T("Werte nach Land", "Values by country")</MudText>
<MudTable Items="_centralResult.CountryTotals" Dense Hover Striped>
<HeaderContent>
<MudTh>@T("Land", "Country")</MudTh>
<MudTh>@T("Waehrung", "Currency")</MudTh>
<MudTh>@_centralResult.Summary.ValueFieldLabel</MudTh>
<MudTh>@T("Rechnungen", "Invoices")</MudTh>
<MudTh>@T("Zeilen", "Rows")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Label</MudTd>
<MudTd>@context.Currency</MudTd>
<MudTd>@FormatValue(context.SalesValue, context.Currency)</MudTd>
<MudTd>@context.InvoiceCount.ToString("N0")</MudTd>
<MudTd>@context.RowCount.ToString("N0")</MudTd>
</RowTemplate>
</MudTable>
</MudPaper>
}
</MudTabPanel>
</MudTabs>
}
@code {
private List<ManagementCockpitFileOption> _files = [];
private List<int> _centralYears = [];
private List<int> _financeYearOptions = [];
private List<string> _financeCountryOptions = [];
private List<string> _financeCurrencyOptions = [];
private List<ManagementCockpitValueFieldOption> _valueFieldOptions = [];
private readonly List<CurrencySelectOption> _currencyOptions =
[
new(ManagementCockpitCurrencyOptions.Eur, "EUR"),
new(ManagementCockpitCurrencyOptions.Usd, "USD"),
new(ManagementCockpitCurrencyOptions.Native, "Original")
];
private string? _selectedFilePath;
private ManagementCockpitResult? _result;
private ManagementCockpitCentralResult? _centralResult;
private ManagementFinanceSummaryResult? _financeResult;
private int _selectedFinanceYear;
private string? _selectedFinanceCountryKey;
private string? _selectedFinanceCurrency;
private int _selectedCentralYear;
private int? _selectedCentralMonth;
private string? _centralLandFilter;
private string? _centralTscFilter;
private string _selectedFileValueField = ManagementCockpitValueFieldKeys.SalesPriceValue;
private string _selectedCentralValueField = ManagementCockpitValueFieldKeys.SalesPriceValue;
private IEnumerable<string> _selectedCentralAdditionalValueFields = [];
private string _selectedFileTargetCurrency = ManagementCockpitCurrencyOptions.Eur;
private string _selectedCentralTargetCurrency = ManagementCockpitCurrencyOptions.Native;
private bool _loadingFiles;
private bool _analyzing;
private bool _analyzingCentral;
private bool _analyzingFinance;
protected override async Task OnInitializedAsync()
{
var state = await CockpitPageService.InitializeAsync(_selectedFilePath, _selectedCentralYear);
_files = state.Files;
_valueFieldOptions = state.ValueFieldOptions;
_centralYears = state.CentralYears;
_selectedFilePath = state.SelectedFilePath;
_selectedCentralYear = state.SelectedCentralYear;
_selectedFinanceYear = _selectedCentralYear;
await AnalyzeFinanceSummary();
}
private async Task ReloadFiles()
{
_loadingFiles = true;
try
{
_files = await CockpitPageService.LoadFilesAsync();
_selectedFilePath ??= _files.FirstOrDefault()?.Path;
}
finally
{
_loadingFiles = false;
}
}
private async Task ReloadCentralYears()
{
_centralYears = await CockpitPageService.LoadCentralYearsAsync();
if (_selectedCentralYear == 0)
_selectedCentralYear = _centralYears.LastOrDefault();
}
private async Task Analyze()
{
if (string.IsNullOrWhiteSpace(_selectedFilePath))
return;
_analyzing = true;
try
{
_result = await CockpitPageService.AnalyzeAsync(_selectedFilePath, new ManagementCockpitAnalysisOptions
{
ValueField = _selectedFileValueField,
TargetCurrency = _selectedFileTargetCurrency
});
_centralLandFilter = _result.Summary.Land;
_centralTscFilter = _result.Summary.Tsc;
}
catch (Exception ex)
{
Snackbar.Add(string.Format(T("Cockpit konnte nicht erzeugt werden: {0}", "Could not build cockpit: {0}"), ex.Message), Severity.Error);
}
finally
{
_analyzing = false;
}
}
private async Task AnalyzeCentral()
{
if (_selectedCentralYear == 0)
return;
_analyzingCentral = true;
try
{
_centralResult = await CockpitPageService.AnalyzeCentralAsync(_selectedCentralYear, _selectedCentralMonth, new ManagementCockpitAnalysisOptions
{
ValueField = _selectedCentralValueField,
AdditionalValueFields = _selectedCentralAdditionalValueFields.ToList(),
TargetCurrency = _selectedCentralTargetCurrency,
LandFilter = _centralLandFilter,
TscFilter = _centralTscFilter
});
}
catch (Exception ex)
{
Snackbar.Add(string.Format(T("Zentrale Auswertung konnte nicht erzeugt werden: {0}", "Could not build central analysis: {0}"), ex.Message), Severity.Error);
}
finally
{
_analyzingCentral = false;
}
}
private async Task AnalyzeFinanceSummary()
{
_analyzingFinance = true;
try
{
_financeResult = await CockpitPageService.AnalyzeFinanceSummaryAsync(
_selectedFinanceYear,
_selectedFinanceCountryKey,
_selectedFinanceCurrency);
_financeYearOptions = _financeResult.YearOptions;
_financeCountryOptions = _financeResult.CountryOptions;
_financeCurrencyOptions = _financeResult.CurrencyOptions;
_selectedFinanceYear = _financeResult.Filter.Year;
}
catch (Exception ex)
{
Snackbar.Add(string.Format(T("Finance Summary konnte nicht erzeugt werden: {0}", "Could not build finance summary: {0}"), ex.Message), Severity.Error);
}
finally
{
_analyzingFinance = false;
}
}
private void ClearCentralScope()
{
_centralLandFilter = null;
_centralTscFilter = null;
}
private static Severity MapSeverity(string severity) => severity switch
{
"Warning" => Severity.Warning,
"Error" => Severity.Error,
_ => Severity.Info
};
private static string BuildPeriodLabel(ManagementCockpitCentralResult result)
{
if (result.Summary.PeriodStart is null || result.Summary.PeriodEnd is null)
return "-";
return $"{result.Summary.PeriodStart.Value:dd.MM.yyyy} - {result.Summary.PeriodEnd.Value:dd.MM.yyyy}";
}
private static string FormatValue(decimal value, string currency)
=> string.IsNullOrWhiteSpace(currency) || currency == "-"
? value.ToString("N2")
: $"{value:N2} {currency}";
private void SetSelectedCentralAdditionalValueFields(IEnumerable<string> values)
{
_selectedCentralAdditionalValueFields = values
.Where(value => !string.IsNullOrWhiteSpace(value))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static string FormatAdditionalValue(ManagementCockpitTimeValueRow row, string fieldKey)
{
if (!row.AdditionalValues.TryGetValue(fieldKey, out var value))
return "-";
var formattedValue = FormatValue(value.Value, value.Currency);
return value.MissingExchangeRateCount == 0
? formattedValue
: $"{formattedValue} / {value.MissingExchangeRateCount} ohne Kurs";
}
private sealed record CurrencySelectOption(string Key, string Label);
}
@code {
private string T(string german, string english) => UiText.Text(german, english);
}
@@ -0,0 +1,367 @@
@page "/manual-imports"
@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,603 @@
@page "/settings"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject ISettingsPageService SettingsPageActions
@inject IJSRuntime JS
@inject ISnackbar Snackbar
<PageTitle>Settings</PageTitle>
<MudText Typo="Typo.h4" Class="mb-4">Settings</MudText>
<MudText Typo="Typo.h5" Class="mb-2">Konfiguration Import/Export</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudCheckBox @bind-Value="_includeSecretsInExport" Label="Mit Secrets exportieren" />
<MudText Typo="Typo.caption">
Wenn deaktiviert, bleiben Passwörter und Secrets beim Export leer. Beim Import ohne Secrets werden bestehende Secrets auf dem Zielsystem beibehalten.
</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 ? "Exportiere..." : "Konfiguration exportieren")
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Warning" HtmlTag="label"
StartIcon="@Icons.Material.Filled.UploadFile" Disabled="_importingConfig">
@(_importingConfig ? "Importiere..." : "Konfiguration importieren")
<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>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_spConfig.SiteUrl" Label="Site URL" />
</MudItem>
<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="Optional. Wenn leer, wird weiterhin Export Folder/Alle verwendet." />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.TenantId" Label="Tenant ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientId" Label="Client ID" />
</MudItem>
<MudItem xs="12" md="4">
<MudTextField @bind-Value="_spConfig.ClientSecret" Label="Client Secret" InputType="InputType.Password" />
</MudItem>
<MudItem xs="12">
<MudStack Row Spacing="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSharePoint"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</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...")
}
else
{
@("SharePoint Verbindung testen")
}
</MudButton>
</MudStack>
</MudItem>
@if (!string.IsNullOrWhiteSpace(_sharePointTestPreview))
{
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Class="mt-3">
<div><b>Test Preview</b></div>
<div style="white-space: pre-wrap">@_sharePointTestPreview</div>
</MudAlert>
</MudItem>
}
</MudGrid>
</MudPaper>
<MudText Typo="Typo.h5" Class="mb-2">Quellsysteme</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudGrid>
<MudItem xs="12">
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
Diese Zugangsdaten werden pro Quellsystem als Standard verwendet. Ein Standort kann sie bei Bedarf mit eigenen Overrides überschreiben.
</MudAlert>
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddSourceSystem"
StartIcon="@Icons.Material.Filled.Add" Class="mb-3">
Quellsystem hinzufuegen
</MudButton>
<MudTable Items="_sourceSystems" Dense Hover Striped Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Code</MudTh>
<MudTh>Name</MudTh>
<MudTh>Anschlussart</MudTh>
<MudTh>Zentrale URL</MudTh>
<MudTh>User</MudTh>
<MudTh>Aktiv</MudTh>
<MudTh>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) ? "Teste..." : "Testen")
</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">
Quellsysteme speichern
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
<MudDialog @bind-Visible="_sourceSystemDialogVisible" Options="_sourceSystemDialogOptions">
<TitleContent>
<MudText Typo="Typo.h6">@(_editingSourceSystem.Id == 0 ? "Quellsystem hinzufuegen" : "Quellsystem bearbeiten")</MudText>
</TitleContent>
<DialogContent>
<MudTextField @bind-Value="_editingSourceSystem.Code" Label="Code" Required />
<MudTextField @bind-Value="_editingSourceSystem.DisplayName" Label="Name" Required />
<MudSelect T="string" @bind-Value="_editingSourceSystem.ConnectionKind" Label="Anschlussart" 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="Zentrale Standard-URL fuer SAP Gateway. Ein Standort darf sie nur bei Bedarf ueberschreiben." />
}
<MudTextField @bind-Value="_editingSourceSystem.CentralUsername" Label="Zentraler Username" />
<MudTextField @bind-Value="_editingSourceSystem.CentralPassword" Label="Zentrales Passwort" InputType="InputType.Password" />
<MudCheckBox @bind-Value="_editingSourceSystem.IsActive" Label="Aktiv" />
</DialogContent>
<DialogActions>
<MudButton OnClick="CloseSourceSystemDialog">Abbrechen</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveSourceSystemEdit">Uebernehmen</MudButton>
</DialogActions>
</MudDialog>
<MudText Typo="Typo.h5" Class="mb-2">Wechselkurse</MudText>
<MudPaper Class="pa-4 mb-6" Elevation="1">
<MudText Typo="Typo.body2" Class="mb-3">
Diese Kurstabelle wird von der Transformation <b>ConvertCurrency</b> verwendet. Gleiche Waehrung rechnet automatisch mit Faktor 1.
</MudText>
<MudStack Row Spacing="2" Class="mb-3">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" OnClick="AddExchangeRate"
StartIcon="@Icons.Material.Filled.Add">
Kurs hinzufuegen
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Info" OnClick="RefreshEcbRates"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_refreshingExchangeRates">
@(_refreshingExchangeRates ? "Aktualisiere ECB-Kurse..." : "Refresh Kurse")
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExchangeRates"
StartIcon="@Icons.Material.Filled.Save">
Kurse speichern
</MudButton>
</MudStack>
<MudTable Items="_exchangeRates" Hover="true" Breakpoint="Breakpoint.Md">
<HeaderContent>
<MudTh>Von</MudTh>
<MudTh>Nach</MudTh>
<MudTh>Kurs</MudTh>
<MudTh>Gueltig ab</MudTh>
<MudTh>Gueltig bis</MudTh>
<MudTh>Notiz</MudTh>
<MudTh>Aktiv</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>
<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" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerHour" Label="Timer Stunde" Min="0" Max="23" />
</MudItem>
<MudItem xs="12" md="2">
<MudNumericField @bind-Value="_exportSettings.TimerMinute" Label="Timer Minute" Min="0" Max="59" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.TimerEnabled" Label="Timer aktiviert" Color="Color.Primary" />
</MudItem>
<MudItem xs="12" md="4">
<MudSwitch @bind-Value="_exportSettings.DebugLoggingEnabled" Label="Debug Live-Logging" Color="Color.Warning" />
<MudText Typo="Typo.caption">
Schreibt zusätzliche technische Fortschrittsmeldungen für HANA- und SAP-Lesevorgänge ins Dashboard und in die Logs.
</MudText>
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_exportSettings.LocalSiteExportFolder" Label="Lokaler Standardpfad Standort-Dateien"
HelperText="Wenn leer, wird ./output unter dem Programmverzeichnis verwendet." />
</MudItem>
<MudItem xs="12" md="6">
<MudTextField @bind-Value="_exportSettings.LocalConsolidatedExportFolder" Label="Lokaler Pfad Zentrale Datei"
HelperText="Optional. Wenn leer, wird der Standardpfad der Standort-Dateien verwendet." />
</MudItem>
<MudItem xs="12">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveExportSettings"
StartIcon="@Icons.Material.Filled.Save">
Speichern
</MudButton>
</MudItem>
</MudGrid>
</MudPaper>
@* Filename Preview *@
<MudText Typo="Typo.h5" Class="mb-2">Dateiname Vorschau</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
</MudText>
</MudPaper>
@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()
{
var state = await SettingsPageActions.LoadAsync();
_spConfig = state.SharePointConfig;
_exportSettings = state.ExportSettings;
_sourceSystems = state.SourceSystems;
_exchangeRates = state.ExchangeRates;
}
private async Task SaveSharePoint()
{
await SettingsPageActions.SaveSharePointAsync(_spConfig);
Snackbar.Add("SharePoint Konfiguration gespeichert", Severity.Success);
}
private async Task TestSharePoint()
{
_testingSp = true;
try
{
_sharePointTestPreview = await SettingsPageActions.BuildSharePointTestPreviewAsync(_spConfig);
Snackbar.Add("SharePoint Verbindung erfolgreich!", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Verbindung fehlgeschlagen: {ex.Message}", Severity.Error);
}
finally
{
_testingSp = false;
}
}
private async Task SaveExportSettings()
{
await SettingsPageActions.SaveExportSettingsAsync(_exportSettings);
Snackbar.Add("Export Einstellungen gespeichert", Severity.Success);
}
private void AddSourceSystem()
{
_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("Code und Name fuer das Quellsystem sind Pflicht.", Severity.Warning);
return;
}
if (_sourceSystems.Any(x => x.Id != _editingSourceSystem.Id && x.Code == _editingSourceSystem.Code))
{
Snackbar.Add($"Quellsystem-Code doppelt vorhanden: {_editingSourceSystem.Code}", Severity.Warning);
return;
}
if (_editingSourceSystem.Id == 0)
{
_sourceSystems.Add(_editingSourceSystem);
}
else
{
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;
}
}
_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("Quellsysteme gespeichert", 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("Wechselkurse gespeichert", Severity.Success);
}
private async Task RefreshEcbRates()
{
if (_refreshingExchangeRates)
return;
_refreshingExchangeRates = true;
try
{
var result = await SettingsPageActions.RefreshEcbRatesAsync();
_exchangeRates = result.ExchangeRates;
Snackbar.Add($"ECB-Kurse aktualisiert: {result.ImportedCount} Kurse vom {result.RateDate:yyyy-MM-dd}.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"ECB-Kursimport fehlgeschlagen: {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("Konfiguration exportiert", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Export fehlgeschlagen: {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("Konfiguration importiert", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Import fehlgeschlagen: {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($"Quellsystem '{sourceSystem}' nicht gefunden.", 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);
}
@@ -0,0 +1,134 @@
@page "/source-viewer"
@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
@@ -0,0 +1,312 @@
@page "/transformations"
@attribute [Authorize(Policy = TrafagSalesExporter.Security.SecurityPolicies.AdminOnly)]
@using System.Reflection
@using TrafagSalesExporter.Models
@using TrafagSalesExporter.Services
@inject ITransformationsPageService TransformationsPageActions
@inject ITransformationCatalog TransformationCatalog
@inject ISnackbar Snackbar
@inject IUiTextService UiText
<PageTitle>@T("Transformationen", "Transformations")</PageTitle>
<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">
@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>
</MudStack>
<MudTable Items="_rules" Dense Hover Striped>
<HeaderContent>
<MudTh>Aktiv</MudTh>
<MudTh>System</MudTh>
<MudTh>Scope</MudTh>
<MudTh>Source</MudTh>
<MudTh>Target</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 _sourceSystems.Where(x => x.IsActive))
{
<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>
}
</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>
}
</MudSelect>
</MudTd>
<MudTd>
@{
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.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 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)" />
</MudTd>
</RowTemplate>
</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[] _ruleScopes = ["Value", "Record"];
private readonly string[] _recordFields = typeof(SalesRecord)
.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Select(p => p.Name)
.OrderBy(n => n)
.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()
{
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()
{
var nextSort = _rules.Count == 0 ? 10 : _rules.Max(r => r.SortOrder) + 10;
_rules.Add(new FieldTransformationRule
{
SourceSystem = _sourceSystems.FirstOrDefault(x => x.IsActive)?.Code ?? "SAP",
RuleScope = "Value",
SourceField = nameof(SalesRecord.Material),
TargetField = nameof(SalesRecord.Material),
TransformationType = "Copy",
SortOrder = nextSort,
IsActive = true
});
}
private void RemoveRule(FieldTransformationRule rule)
{
_rules.Remove(rule);
}
private async Task SaveAllAsync()
{
_rules = await TransformationsPageActions.SaveAllAsync(_rules);
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);
}
@@ -0,0 +1,54 @@
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation
@inject TrafagSalesExporter.Services.IFinanceCockpitAccessService FinanceAccess
<CascadingAuthenticationState>
<Router AppAssembly="typeof(Program).Assembly">
<Found Context="routeData">
@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>
</CascadingAuthenticationState>
@code {
private bool RequiresFinanceUnlock()
{
var path = Navigation.ToBaseRelativePath(Navigation.Uri)
.Split('?', '#')[0]
.Trim('/')
.ToLowerInvariant();
return path is "" or
"management-cockpit" or
"finance-cockpit/vergleich" or
"standorte" or
"transformations" or
"finance-rules" or
"settings" or
"logs" or
"source-viewer";
}
}
@@ -0,0 +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.
+27
View File
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Data;
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>();
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>();
}
File diff suppressed because it is too large Load Diff
+733
View File
@@ -0,0 +1,733 @@
# TrafagSalesExporter LLM System Guide
Stand: 2026-05-05
## Aktueller Projektstand 2026-05-05
Fuer den aktuellen Finance-/Laenderabgleich zuerst diese Dateien lesen:
- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md)
- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md)
- [lastchange.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/lastchange.md)
- [SAGE_SPAIN_EXPORT_2026-05-05.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/SAGE_SPAIN_EXPORT_2026-05-05.md)
Lokaler FinanceProbe:
```text
http://localhost:55417/finance
```
## Aktueller Zusatzstand 2026-05-07 SAP OData / ZSCHWEIZ
Schweiz/Oesterreich werden ueber eine neue SAP-Tabelle `ZSCHWEIZ` bereitgestellt.
Wichtige Punkte:
- ABAP-Report: `report.abap`
- SAP-Tabelle: `ZSCHWEIZ`
- OData EntitySet: `ZSCHWEIZSet`
- App-Standort: `ZSCHWEIZ` / `Schweiz/Oesterreich`
- Geplanter App-Pfad: `SAP` = `SAP OData`, nicht direkter HANA-Spezialcode
Quellsystem-Codes:
- `SAP`: SAP OData/Gateway, DisplayName `SAP OData`
- `SAP_HANA`: direkte HANA-Tabellen/Views, DisplayName `SAP HANA Tables/Views`
- `BI1`: HANA
- `SAGE`: HANA
- `MANUAL_EXCEL`: Excel/CSV
Mapper:
- SAP OData nutzt `SapSourceDefinition`, `SapJoinDefinition`, `SapFieldMapping`.
- Direkte HANA-Tabellen/Views koennen dieselben Mapping-Tabellen ebenfalls nutzen.
- Gemeinsame Mapping-Engine ist `MappedSalesRecordComposer`.
- `SapCompositionService` und `HanaQueryService.GetMappedSalesRecordsAsync` unterscheiden sich nur noch in der Quellenbeschaffung; Join und `SalesRecord`-Mapping sind zentral.
- Bei HANA mit gepflegten Quellen/Mappings nutzt `HanaDataSourceAdapter` den generischen Mapping-Pfad.
- Ohne HANA-Mapping bleibt der alte B1-HANA-Standardpfad fuer `OINV/INV1/ORIN/RIN1` aktiv.
Finance-Konfiguration:
- `FinanceReferences` enthaelt Soll-/check.xlsx-Referenzen.
- `FinanceIntercompanyRules` enthaelt 2nd-party/IC-Regeln.
- Budgetkurse werden als `CurrencyExchangeRates` mit `Notes = Budget 2025` gepflegt.
- Config-Export/-Import umfasst Finance-Referenzen und IC-Regeln.
ZSCHWEIZ-Seed:
- Quelle Alias `Z`
- EntitySet `ZSCHWEIZSet`
- Mapping auf `SalesRecord` ist vorbefuellt und grafisch editierbar.
- Beim App-Start wird die ZSCHWEIZ-Quelle samt Feldmapping per Upsert angelegt oder repariert.
- Wenn Gateway `$metadata` liefert, koennen Felder in der UI per `Felder aus Quellen laden` gelesen werden.
ABAP-Fachlogik:
- `BUKRS 1100` = Schweiz, `TSC TRCH`, `LAND1 CH`
- `BUKRS 1200` = Oesterreich, `TSC TRAT`, `LAND1 AT`
- `CUSTOMER_LAND` = Kundenland aus `KNA1-LAND1`
- Netto-/Steuerwerte werden in Belegwaehrung und Hauswaehrung geschrieben.
Aktuelle FinanceProbe-Funktionen:
- `Meeting Ampel 2025` fuer alle Laender aus `check.xlsx`
- `Detail alle Laender`
- `Spain CSV direct check`
- `Germany Excel sample check`
Spanien:
- Datei: `sagespain/v2/Spain_Sales_2025.csv`
- Ist: `3'082'320.18` EUR
- Soll: `3'102'333.61`
- Differenz: `-20'013.43`
- Status: Gelb / Pruefen
- Technisch lesbar, fachliche Differenz noch offen
Deutschland:
- Datei: `DE_Beispiel_Export_Daten.xlsx`
- Sample-Summe `NettoPreisGesamtX`: `8'290.70` EUR
- Nur Beispielfile, keine finale Jahreszahl
- Mapping technisch verstanden, finaler DE-Jahresfile fehlt
Letzte Verifikation:
- `dotnet build .\Tools\FinanceProbe\FinanceProbe.csproj --verbosity minimal --no-restore`
- `dotnet test .\TrafagSalesExporter.Tests\TrafagSalesExporter.Tests.csproj --verbosity minimal --no-restore`
- Ergebnis: Build OK, Tests `50/50`, FinanceProbe `HTTP 200`
Diese Datei ist fuer andere LLMs gedacht, die das Projekt schnell verstehen und daraus Architekturtexte, Visualisierungen, Ablaufdiagramme oder UI-/Datenflussgrafiken erzeugen sollen.
## Zweck des Systems
`TrafagSalesExporter` ist eine Blazor Server App auf `.NET 8`, die Verkaufsdaten aus mehreren Quellsystemen in ein gemeinsames Zielschema ueberfuehrt.
Quellsysteme:
- `HANA`-basierte Systeme wie `BI1` und `SAGE`
- `SAP_GATEWAY` ueber OData
- `MANUAL_EXCEL` aus hochgeladenen oder referenzierten Excel-Dateien
Zielbild:
- jede Quelle wird in `SalesRecord` normalisiert
- Standortdaten koennen lokal als Excel exportiert werden
- alle Datensaetze werden in `CentralSalesRecords` gespeichert
- eine zentrale konsolidierte Datei wird aus dem zentralen Datenbestand erzeugt
- ein `Management Cockpit` analysiert sowohl exportierte Dateien als auch zentrale Rohdaten
## Technologie-Stack
- UI: Blazor Server + MudBlazor
- Authentifizierung: ASP.NET Core Authentication/Authorization, produktiv Windows Authentication / Active Directory
- Datenbank: SQLite (`trafag_exporter.db`)
- Excel lesen/schreiben: ClosedXML
- SAP HANA Zugriff: `Sap.Data.Hana.Core.v2.1.dll`
- SAP Gateway / OData: eigener Service ueber HTTP
- SharePoint Upload/Download: Microsoft Graph + Azure Identity
- Tests: xUnit
## Einstiegspunkte
Wichtige Dateien:
- [Program.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Program.cs)
- [Data/AppDbContext.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Data/AppDbContext.cs)
- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor)
`Program.cs` registriert fast die komplette Architektur ueber DI und fuehrt beim Start `DatabaseInitializationService.InitializeAsync()` aus.
Zusaetzlich registriert `Program.cs` den Zugriffsschutz:
- `AddCascadingAuthenticationState`
- Windows Authentication fuer produktive Umgebungen
- Development-Authentication-Handler nur bei `ASPNETCORE_ENVIRONMENT=Development` und `Security:DevelopmentBypass=true`
- globale Fallback-Policy fuer authentifizierte/berechtigte User
- Policy `AdminOnly` fuer administrative Seiten
## Hauptseiten
Navigation:
- `/` Dashboard
- `/standorte`
- `/transformations`
- `/management-cockpit`
- `/settings`
- `/logs`
Dateien:
- [Components/Pages/Dashboard.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Dashboard.razor)
- [Components/Pages/Standorte.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Standorte.razor)
- [Components/Pages/Transformations.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Transformations.razor)
- [Components/Pages/ManagementCockpit.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/ManagementCockpit.razor)
- [Components/Pages/Settings.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Settings.razor)
- [Components/Pages/Logs.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Pages/Logs.razor)
Kurzrollen:
- `Dashboard`: Einzel-Export, Alle exportieren, zentrale Datei neu erzeugen, Live-Status
- `Standorte`: Standortpflege, zentrale HANA-Technik, SAP-Konfiguration pro Standort, manueller Excel-Import
- `Transformations`: feldweise und record-basierte Regeln
- `Management Cockpit`: Dateianalyse und Rohanalyse aus `CentralSalesRecords`
- `Settings`: SharePoint, Exportpfade, Quellsysteme, Wechselkurse, Config Import/Export
- `Logs`: technische Ereignisprotokolle
Security:
- alle Routen erfordern Authentifizierung
- `Settings`, `Standorte` und `Transformations` sind `AdminOnly`
- Admin-Navigation wird nur fuer Admins angezeigt
- eingeloggter Benutzer wird im App-Bar angezeigt
## Kernmodelle
Wichtige Entity-Klassen:
- [Models/Site.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/Site.cs)
- [Models/SourceSystemDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SourceSystemDefinition.cs)
- [Models/HanaServer.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/HanaServer.cs)
- [Models/SalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SalesRecord.cs)
- [Models/CentralSalesRecord.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CentralSalesRecord.cs)
- [Models/FieldTransformationRule.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/FieldTransformationRule.cs)
- [Models/SapSourceDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapSourceDefinition.cs)
- [Models/SapJoinDefinition.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapJoinDefinition.cs)
- [Models/SapFieldMapping.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SapFieldMapping.cs)
- [Models/SharePointConfig.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/SharePointConfig.cs)
- [Models/ExportSettings.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportSettings.cs)
- [Models/ExportLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ExportLog.cs)
- [Models/AppEventLog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/AppEventLog.cs)
- [Models/CurrencyExchangeRate.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/CurrencyExchangeRate.cs)
`SalesRecord` / `CentralSalesRecord` enthalten neben den positionsnahen Feldern auch B1-Belegwaehrungsfelder:
- `DocumentCurrency` aus `DocCur`
- `DocumentTotalForeignCurrency` aus `DocTotalFC`
- `DocumentTotalLocalCurrency` aus `DocTotal`
- `VatSumForeignCurrency` aus `VatSumFC`
- `VatSumLocalCurrency` aus `VatSum`
- `DocumentRate` aus `DocRate`
- `CompanyCurrency` aus `OADM.MainCurncy`
Wichtig: diese Dokumentwerte sind Belegkopfwerte und werden in der positionsbasierten Excel pro Position wiederholt. Fuer Belegkopfsummen muessen Auswertungen nach Beleg deduplizieren.
Wichtige Relationen:
- `Site -> HanaServer` optional
- `Site -> SapSourceDefinitions`
- `Site -> SapJoinDefinitions`
- `Site -> SapFieldMappings`
- `Site -> CentralSalesRecords`
- `SourceSystemDefinition` ist zentrale Stammdatenquelle fuer Quellsysteme
## Datenbanktabellen
`AppDbContext` enthaelt:
- `HanaServers`
- `SourceSystemDefinitions`
- `Sites`
- `SharePointConfigs`
- `ExportSettings`
- `ExportLogs`
- `AppEventLogs`
- `FieldTransformationRules`
- `CurrencyExchangeRates`
- `SapSourceDefinitions`
- `SapJoinDefinitions`
- `SapFieldMappings`
- `CentralSalesRecords`
## Architekturrollen der Services
### Export / Orchestrierung
- [Services/ExportOrchestrationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportOrchestrationService.cs)
- [Services/SiteExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SiteExportService.cs)
- [Services/ConsolidatedExportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConsolidatedExportService.cs)
- [Services/CentralSalesRecordService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CentralSalesRecordService.cs)
- [Services/ExportLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExportLogService.cs)
Rollen:
- `ExportOrchestrationService` steuert UI-nahe Exportlaeufe und Live-Status
- `SiteExportService` entscheidet anhand des Quellsystems, wie ein Standort gelesen wird
- `CentralSalesRecordService` ersetzt zentrale Saetze pro Standort
- `ConsolidatedExportService` erzeugt die zentrale Datei
### Datenquellen
- [Services/HanaQueryService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/HanaQueryService.cs)
- [Services/SapGatewayService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapGatewayService.cs)
- [Services/SapCompositionService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SapCompositionService.cs)
- [Services/ManualExcelImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManualExcelImportService.cs)
- [Services/SharePointUploadService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/SharePointUploadService.cs)
Rollen:
- `HanaQueryService`: SQL gegen SAP B1/HANA-nahe Schemata
- `SapGatewayService`: OData-Metadaten und Reads
- `SapCompositionService`: Mehrquellen-/Join-/Mapping-Aufbau fuer SAP
- `ManualExcelImportService`: Import im Exportformat aus `.xlsx`
- `SharePointUploadService`: Upload fuer Exportdateien und Download fuer manuelle Excel-Dateien
### Transformation / Mapping
- [Services/TransformationCatalog.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationCatalog.cs)
- [Services/TransformationStrategies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TransformationStrategies.cs)
- [Services/RecordTransformationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/RecordTransformationService.cs)
- [Services/CurrencyExchangeRateService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/CurrencyExchangeRateService.cs)
- [Services/ExchangeRateImportService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ExchangeRateImportService.cs)
Rollen:
- `Value`-Transformationen fuer einzelne Felder
- `Record`-Transformationen fuer zeilenweite Regeln
- Wechselkursimport und -umrechnung
### Reporting / Monitoring / Infrastruktur
- [Services/ManagementCockpitService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ManagementCockpitService.cs)
- [Services/AppEventLogService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/AppEventLogService.cs)
- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs)
- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs)
- [Services/TimerBackgroundService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/TimerBackgroundService.cs)
## Der wichtigste technische Ablauf
### 1. Standort-Export
Pfad:
`Dashboard/Standorte -> ExportOrchestrationService -> SiteExportService`
`SiteExportService` unterscheidet drei Modi:
1. `SAP_GATEWAY`
- SAP-Quellen lesen
- SAP-Joins anwenden
- SAP-Feldmappings auf `SalesRecord`
- Transformationen anwenden
- Standort-Excel erzeugen
- `CentralSalesRecords` ersetzen
- optional SharePoint-Upload
2. `HANA`
- effektive zentrale HANA-Konfiguration laden
- optionale Standort-Credential-Overrides anwenden
- SQL in HANA ausfuehren
- `SalesRecord` erzeugen
- Transformationen anwenden
- Standort-Excel erzeugen
- `CentralSalesRecords` ersetzen
- optional SharePoint-Upload
3. `MANUAL_EXCEL`
- `ManualImportFilePath` auswerten
- wenn lokal/UNC vorhanden: lokal lesen
- wenn SharePoint-Referenz: via Graph temp herunterladen
- Excel in `SalesRecord` lesen
- Transformationen anwenden
- keine neue Standortdatei erzeugen, bestehende Excel dient als Eingabe
- `CentralSalesRecords` ersetzen
### 2. Konsolidierter Export
Pfad:
`Dashboard -> ExportOrchestrationService -> ConsolidatedExportService`
Semantik aktuell:
- die zentrale Datei basiert fachlich auf `CentralSalesRecords`
- `ExportAllAsync()` sammelt zwar auch `consolidatedRecords`, aber die zentrale Exportsemantik ist historisch noch nicht vollkommen bereinigt
### 3. Management Cockpit
Zwei Betriebsarten:
1. Dateibasiert
- vorhandene `.xlsx` waehlen
- Datei mit ClosedXML lesen
- Summenfeld waehlen
- Anzeige-Waehrung waehlen
- Kennzahlen, Top-Listen, Datenqualitaet, Findings erzeugen
2. Zentraldatenbasiert
- direkt aus `CentralSalesRecords`
- Jahr/Monat Filter
- Summenfeld waehlen
- optionale weitere Summenfelder fuer Zeitreihen waehlen
- Anzeige-Waehrung waehlen
- Rohsicht ohne Intercompany-, Budget- oder Spartelogik
Aktuelle Summenfelder:
- `Sales Price/Value`
- `Quantity`
- `Standard cost`
- `Quantity * Standard cost`
Aktuelle Anzeige-Waehrungen:
- `EUR`
- `USD`
- `Original`
Die Waehrungsumrechnung nutzt `CurrencyExchangeRateService`. Bei `Original` bleiben Werte in Quellwaehrungen gruppiert. Nicht-betragliche Summenfelder wie `Quantity` haben keine Waehrung. Fehlende Wechselkurse werden gezaehlt und in Hinweisen bzw. Findings sichtbar; betroffene Werte werden in der Zielwaehrung mit `0` einbezogen.
## Quellsystemlogik
### SourceSystemDefinition
`SourceSystemDefinition` ist die fuehrende Wahrheit fuer:
- `Code`
- `DisplayName`
- `ConnectionKind`
- `IsActive`
- `CentralUsername`
- `CentralPassword`
- `CentralServiceUrl` fuer SAP
Anschlussarten:
- `HANA`
- `SAP_GATEWAY`
- `MANUAL_EXCEL`
### HANA
Fachliche Logik:
- zentrale technische HANA-Konfiguration pro Quellsystem
- keine separaten Vollverbindungen pro Standort
- Standort speichert nur Fachdaten plus optionale Username-/Password-Overrides
Schema-Lookup:
- in `Standorte` gibt es jetzt `Schemas laden`
- Lookup fragt `sys.tables` in HANA ab
- eingeschraenkt auf typische B1-Schemas mit Tabellen wie `OINV`, `INV1`, `ORIN`, `RIN1`, `OCRD`, `OITM`
### SAP
Fachliche Logik:
- zentrale SAP Service URL in `SourceSystemDefinition.CentralServiceUrl`
- Standort kann `SapServiceUrl` als Override pflegen
- pro Standort gibt es SAP-Quellen, Joins und Feldmappings
### Manual Excel
Fachliche Logik:
- `Site.ManualImportFilePath` kann sein:
- lokaler Windows-Pfad
- UNC-Pfad
- SharePoint-URL
- SharePoint-Pfad unterhalb der konfigurierten Site
- Standortdaten werden aus der Excel eingelesen und in `CentralSalesRecords` uebernommen
- SharePoint dient hier als Eingangsquelle, nicht nur als Exportziel
## Transformationen
Das System unterscheidet:
- `Value`-Transformationen
- `Record`-Transformationen
Beispiele:
- `Copy`
- `Uppercase`
- `Lowercase`
- `Prefix`
- `Suffix`
- `Replace`
- `Constant`
- `NormalizeCurrencyCode`
- `FirstNonEmpty`
- `ConvertCurrency`
Technischer Ablauf:
- Regeln liegen in `FieldTransformationRules`
- `TransformationCatalog` meldet verfuegbare Strategien an die UI
- `RecordTransformationService` wendet record-basierte Strategien an
## Wechselkurse
Vorhanden:
- `CurrencyExchangeRates`
- `ExchangeRateImportService` fuer ECB-Tageskurse
- `NormalizeCurrencyCode`
- `ConvertCurrency`
- `ManagementCockpitService` kann betragliche Cockpit-Kennzahlen in `EUR` oder `USD` umrechnen
Wichtig:
- die Rohsicht im `Management Cockpit` kann jetzt Anzeige-Waehrungen nutzen
- `CHF` ist im Cockpit aktuell nicht als direkte Anzeige-Waehrung in der UI angeboten
- CHF bleibt weiterhin Teil des allgemeinen Transformationssystems
- fachlich ist noch zu klaeren, ob CHF als Standard- oder zusaetzliche Cockpit-Anzeige-Waehrung gebraucht wird
## SharePoint-Rolle im Gesamtsystem
`SharePointConfig` enthaelt:
- `SiteUrl`
- `ExportFolder`
- `CentralExportFolder`
- `TenantId`
- `ClientId`
- `ClientSecret`
Verwendung:
- Upload von Standort-Exporten
- Upload der zentralen Datei
- Download von manuellen Excel-Dateien fuer `MANUAL_EXCEL`
Wichtig:
- die App arbeitet gegen dieselbe SharePoint-Site, die in `Settings` konfiguriert ist
- fuer `MANUAL_EXCEL` muessen Referenzen auf derselben Site aufloesbar sein
## Startinitialisierung / Migrationen
Kritische Datei:
- [Services/DatabaseInitializationService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/DatabaseInitializationService.cs)
Aktuelle Rolle:
- `EnsureCreated`
- Schema-Ergaenzungen per `ALTER TABLE`
- Tabellen-Rebuilds bei Legacy-Schemas
- FK-Reparaturen
- Stammdaten-Seeding
- empfohlene Transformationsregeln
Bekannte Architekturrealitaet:
- das ist funktional hilfreich, aber kein sauberes Migrationssystem
- die Startlogik traegt produktive Schema-Reparaturverantwortung
- das ist einer der wichtigsten technischen Risikobloecke
Bereits gehaertete Fehlerbilder:
- kaputte FK-Referenzen auf `Sites_old`
- kaputte FK-Referenzen auf `HanaServers_repair_old`
- Legacy-Credential-Spalten in `ExportSettings`
- Legacy-Credential-Spalten in `HanaServers`
- verschobene Spalten im `Sites_old -> Sites`-Kopierpfad
## Authentifizierung / Autorisierung
Dateien:
- [Security/SecurityOptions.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityOptions.cs)
- [Security/SecurityPolicies.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/SecurityPolicies.cs)
- [Security/DevelopmentAuthenticationHandler.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Security/DevelopmentAuthenticationHandler.cs)
- [Components/Routes.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Routes.razor)
- [Components/Layout/NavMenu.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/NavMenu.razor)
- [Components/Layout/MainLayout.razor](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Components/Layout/MainLayout.razor)
Produktives Ziel:
- Windows Authentication / Active Directory
- keine eigene Benutzerverwaltung
- Zugriff ueber AD-Gruppen
- Adminrechte ueber separate AD-Gruppe
Konfiguration in `appsettings.json`:
- `Security:AccessGroups`
- `Security:AdminGroups`
- `Security:DevelopmentBypass`
- `Security:DevelopmentUserIsAdmin`
- `Security:DevelopmentUserName`
Default-Gruppen:
- `TRAFAG\\TrafagSalesExporter-Users`
- `TRAFAG\\TrafagSalesExporter-Admins`
Development:
- `appsettings.Development.json` aktiviert einen lokalen Development-Auth-Handler
- dieser ist nur fuer lokale Entwicklung gedacht
- produktiv darf `ASPNETCORE_ENVIRONMENT` nicht `Development` sein
IIS-Betrieb:
- Windows Authentication aktivieren
- Anonymous Authentication deaktivieren
- AD-Gruppennamen in produktiver Konfiguration setzen
## Config Import / Export
Dateien:
- [Services/ConfigTransferService.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Services/ConfigTransferService.cs)
- [Models/ConfigTransferPackage.cs](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/Models/ConfigTransferPackage.cs)
Aktueller Stand:
- JSON Export/Import fuer Konfiguration
- Secrets optional
- `SourceSystemDefinitions` im aktuellen Modell enthalten
- HANA-Technik ohne HANA-Credentials
- Standort-Overrides bleiben erhalten
Wichtige Punkte:
- Import laeuft jetzt transaktional
- alte `ConnectionKind`-lose Formate bekommen Fallbacks
- `CentralSalesRecords` werden nicht mehr blind geloescht
- bestehende zentrale Laufzeitdaten werden fuer weiterhin vorhandene Standorte remappt
## Logging
Es gibt zwei Log-Ebenen:
- `ExportLogs` fuer fachliche Exporthistorie
- `AppEventLogs` fuer technische und UI-nahe Ereignisse
Die `Logs`-Seite liest vor allem `AppEventLogs`.
## Tests
Testprojekt:
- [TrafagSalesExporter.Tests](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/TrafagSalesExporter.Tests)
Aktuell vorhandene Schwerpunkte:
- Transformationen
- Record-Transformationen
- TransformationCatalog
- CurrencyExchangeRateService
- ExchangeRateImportService
- ManualExcelImportService
- ManagementCockpitService
- ConfigTransferService
- DatabaseInitializationService
`ManagementCockpitServiceTests` decken inzwischen auch ab:
- zentrale Analyse nach Jahr/Monat
- Tages-, Monats-, Jahres-, Quellen- und Laenderwerte
- waehlbare Summenfelder
- Waehrungsumrechnung in EUR
- Wechselkurs-Caching
- Mengen-Auswertung ohne Waehrungsumrechnung
- Zusatz-Summenfelder in Zeitreihen
`SecurityPolicyFactoryTests` decken inzwischen ab:
- App-Zugriff fuer User in `AccessGroups`
- Ablehnung fuer User ausserhalb der Access-Gruppen
- Development-Auth-Zugriff im lokalen Modus
- Admin-Zugriff fuer User in `AdminGroups`
- Ablehnung normaler User fuer `AdminOnly`
- Development-Admin-Claim
`CentralSalesRecordServiceTests` decken inzwischen ab:
- Persistenz und Ruecklesen der B1-Belegwaehrungsfelder in `CentralSalesRecords`
Wichtig:
- es gibt aktuell keine echten UI-Komponententests mit `bUnit`
- es gibt keine Browser-E2E-Tests mit `Playwright`
- viele Button-Aktionen sind nur indirekt ueber Services und Persistenz getestet
## Bekannte offene Architekturfragen
Fuer andere LLMs wichtig, damit Visualisierungen nicht zu glatt oder zu idealisiert werden:
1. `DatabaseInitializationService` ist ein produktiver Reparatur-/Migrationslayer, nicht nur Bootstrap.
2. `Settings.razor` und `Standorte.razor` enthalten weiterhin relativ viel Anwendungslogik.
3. Die Semantik der konsolidierten Datei ist historisch teilweise doppelt angelegt.
4. Das `Management Cockpit` ist noch kein voll generalisierter Reporting-Layer.
5. SharePoint ist sowohl Exportziel als auch bei `MANUAL_EXCEL` mittlerweile moegliche Eingangsquelle.
## Empfohlene Diagramme fuer andere LLMs
### 1. Kontextdiagramm
Zeige:
- Benutzer
- Blazor App
- SQLite
- SAP HANA
- SAP Gateway
- lokale Dateisystempfade
- SharePoint
### 2. Komponenten-/Service-Diagramm
Gruppiere:
- UI
- Orchestrierung
- Quelladapter
- Transformation
- Persistenz
- Reporting
### 3. Datenflussdiagramm pro Quelltyp
Je ein separater Flow fuer:
- HANA
- SAP Gateway
- Manual Excel lokal
- Manual Excel SharePoint
### 4. ER-Diagramm
Fokussiere auf:
- `SourceSystemDefinition`
- `HanaServer`
- `Site`
- `SapSourceDefinition`
- `SapJoinDefinition`
- `SapFieldMapping`
- `CentralSalesRecord`
- `FieldTransformationRule`
### 5. Sequenzdiagramm fuer Export
Wichtige Stationen:
- Dashboard
- ExportOrchestrationService
- SiteExportService
- spezifischer Quellservice
- Transformation
- CentralSalesRecordService
- Excel/SharePoint
- ExportLog/AppEventLog
## Prompt-Vorlage fuer ein anderes LLM
Wenn ein anderes LLM daraus Visualisierungen erzeugen soll, funktioniert diese Anweisung gut:
> Lies `LLM_SYSTEM_GUIDE.md` als primaeren Systemkontext. Erzeuge daraus ein Architekturdiagramm, ein Datenflussdiagramm fuer HANA/SAP/MANUAL_EXCEL, ein ER-Diagramm der wichtigsten Tabellen und ein Sequenzdiagramm fuer `ExportAsync`. Achte darauf, dass `DatabaseInitializationService` produktive Reparaturlogik enthaelt und dass `MANUAL_EXCEL` sowohl lokal als auch ueber SharePoint gelesen werden kann.
## Weitere Kontextdateien
Zusatzkontext fuer Verlauf und Risiken:
- [HANDOFF_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/HANDOFF_2026-04-15.md)
- [NEXT_STEPS_2026-04-15.md](C:/Users/koi/source/repos/Ai/TrafagSalesExporter/NEXT_STEPS_2026-04-15.md)
Diese beiden Dateien sind wichtig, wenn ein anderes LLM nicht nur Struktur, sondern auch historische Umbauten, Risiken und Prioritaeten verstehen soll.
+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,50 @@
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 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,160 @@
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 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;
}
+22
View File
@@ -0,0 +1,22 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class ExportLog
{
public int Id { get; set; }
public DateTime Timestamp { get; set; }
public int SiteId { get; set; }
[ForeignKey(nameof(SiteId))]
public Site? Site { get; set; }
public string Land { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
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; }
}
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Models;
public class ExportSettings
{
public int Id { get; set; }
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;
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
namespace TrafagSalesExporter.Models;
public class FieldTransformationRule
{
public int Id { get; set; }
[Required]
public string SourceSystem { get; set; } = string.Empty;
[Required]
public string SourceField { get; set; } = nameof(SalesRecord.Material);
[Required]
public string TargetField { get; set; } = nameof(SalesRecord.Material);
[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; }
public bool IsActive { get; set; } = true;
}
@@ -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
];
}
+154
View File
@@ -0,0 +1,154 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Data.Common;
namespace TrafagSalesExporter.Models;
public class HanaServer
{
public int Id { get; set; }
[Required]
public string SourceSystem { get; set; } = string.Empty;
[Required]
public string Name { get; set; } = string.Empty;
[Required]
public string Host { get; set; } = string.Empty;
public int Port { get; set; } = 30015;
[NotMapped]
public string Username { get; set; } = string.Empty;
[NotMapped]
public string Password { get; set; } = string.Empty;
/// <summary>
/// Name der Tenant-Datenbank bei Multi-Tenant Database Container (MDC) Setups.
/// Leer lassen, wenn direkt auf einen Tenant-Port verbunden wird.
/// </summary>
public string DatabaseName { get; set; } = string.Empty;
/// <summary>
/// SSL/TLS Verschlüsselung aktivieren (encrypt=true).
/// </summary>
public bool UseSsl { get; set; }
/// <summary>
/// SSL-Zertifikat validieren. Bei self-signed Zertifikaten auf false setzen.
/// </summary>
public bool ValidateCertificate { get; set; }
/// <summary>
/// Zusätzliche Verbindungsparameter (Semikolon-getrennt), z.B. "sslCryptoProvider=openssl".
/// </summary>
public string AdditionalParams { get; set; } = string.Empty;
public string BuildConnectionString()
{
var builder = new DbConnectionStringBuilder();
builder["ServerNode"] = BuildServerNode();
builder["UserName"] = Username.Trim();
builder["Password"] = Password;
if (!string.IsNullOrWhiteSpace(DatabaseName))
builder["DatabaseName"] = DatabaseName.Trim();
if (UseSsl)
{
builder["encrypt"] = true;
builder["sslValidateCertificate"] = ValidateCertificate;
}
AppendAdditionalParams(builder);
return builder.ConnectionString;
}
public string GetConnectionStringPreview()
{
var pwdMasked = string.IsNullOrEmpty(Password) ? "" : "***";
var copy = new HanaServer
{
SourceSystem = SourceSystem,
Host = Host,
Port = Port,
Username = Username,
Password = pwdMasked,
DatabaseName = DatabaseName,
UseSsl = UseSsl,
ValidateCertificate = ValidateCertificate,
AdditionalParams = AdditionalParams
};
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;
}
}
}
+217
View File
@@ -0,0 +1,217 @@
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 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,189 @@
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 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 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 decimal NetSalesActual { get; set; }
}
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 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;
}
@@ -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; }
}
+40
View File
@@ -0,0 +1,40 @@
namespace TrafagSalesExporter.Models;
public class SalesRecord
{
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 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,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; }
}
@@ -0,0 +1,12 @@
namespace TrafagSalesExporter.Models;
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;
}
+43
View File
@@ -0,0 +1,43 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace TrafagSalesExporter.Models;
public class Site
{
public int Id { get; set; }
public int? HanaServerId { get; set; }
[ForeignKey(nameof(HanaServerId))]
public HanaServer? HanaServer { get; set; }
[Required]
public string Schema { get; set; } = string.Empty;
[Required]
public string TSC { get; set; } = string.Empty;
[Required]
public string Land { get; set; } = string.Empty;
[Required]
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];
}
File diff suppressed because it is too large Load Diff
+139
View File
@@ -0,0 +1,139 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Server.IISIntegration;
using Microsoft.EntityFrameworkCore;
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();
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.AddDbContextFactory<AppDbContext>(options =>
options.UseSqlite("Data Source=trafag_exporter.db;Default Timeout=60"));
// 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<IConsolidatedExportService, ConsolidatedExportService>();
builder.Services.AddSingleton<IExportLogService, ExportLogService>();
builder.Services.AddSingleton<ICentralSalesRecordService, CentralSalesRecordService>();
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>();
// 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<IHrKpiAccessService, HrKpiAccessService>();
builder.Services.AddScoped<IFinanceCockpitAccessService, FinanceCockpitAccessService>();
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 databaseInitialization = scope.ServiceProvider.GetRequiredService<IDatabaseInitializationService>();
await databaseInitialization.InitializeAsync();
}
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseStaticFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
app.MapRazorComponents<TrafagSalesExporter.Components.App>()
.AddInteractiveServerRenderMode();
app.Run();
@@ -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"
}
}
}
+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,234 @@
# Sage Spain Export
Stand: 2026-05-05
## 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
- Soll aus `check.xlsx`: `3'102'333.61`
- Differenz: `-20'013.43`
- Status FinanceProbe: Gelb / Pruefen
- Finale Aussage: technisch importierbar, aber fachlich noch nicht abgestimmt.
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.
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,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,221 @@
param(
[string]$ServerInstance = "localhost",
[string]$Database = "Sage",
[datetime]$FromDate = "2025-01-01",
[datetime]$ToDate = "2026-01-01",
[string]$OutputDirectory = (Join-Path $env:USERPROFILE "Desktop")
)
$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
}
}
$timestamp = Get-Date -Format "yyyyMMdd_HHmmss"
$runDirectory = Join-Path $OutputDirectory "Sage_Spain_Sales_Export_$timestamp"
New-Item -ItemType Directory -Path $runDirectory -Force | Out-Null
$csvPath = Join-Path $runDirectory "Spain_Sales_2025.csv"
$summaryPath = Join-Path $runDirectory "Spain_Sales_2025_summary.txt"
$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 c.FechaFactura >= @FromDate
AND c.FechaFactura < @ToDate
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
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:
CabeceraAlbaranCliente.FechaFactura >= FromDate
CabeceraAlbaranCliente.FechaFactura < ToDate
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.
"@ | 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,32 @@
Sage Spain final sales export candidate
======================================
Run on the Spain Sage SQL Server machine.
PowerShell commands:
Set-ExecutionPolicy -Scope Process Bypass
.\Export-SageSpainSalesCsv.ps1
Output folder on Desktop:
Sage_Spain_Sales_Export_YYYYMMDD_HHMMSS
Files created:
- Spain_Sales_2025.csv
- Spain_Sales_2025_summary.txt
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
- Date filter: CabeceraAlbaranCliente.FechaFactura from 2025-01-01 to 2026-01-01
- Sales value: LineasAlbaranCliente.ImporteNeto
If the SQL instance or database name differs:
.\Export-SageSpainSalesCsv.ps1 -ServerInstance "localhost" -Database "Sage"
@@ -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,43 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
namespace TrafagSalesExporter.Security;
public sealed class DevelopmentAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
public const string SchemeName = "Development";
public const string AdminClaimType = "TrafagSalesExporter.Admin";
private readonly IConfiguration _configuration;
public DevelopmentAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IConfiguration configuration)
: base(options, logger, encoder)
{
_configuration = configuration;
}
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
var settings = _configuration.GetSection(SecurityOptions.SectionName).Get<SecurityOptions>() ?? new SecurityOptions();
var claims = new List<Claim>
{
new(ClaimTypes.Name, settings.DevelopmentUserName),
new(ClaimTypes.NameIdentifier, settings.DevelopmentUserName)
};
if (settings.DevelopmentUserIsAdmin)
claims.Add(new Claim(AdminClaimType, "true"));
var identity = new ClaimsIdentity(claims, SchemeName);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, SchemeName);
return Task.FromResult(AuthenticateResult.Success(ticket));
}
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Security;
public sealed class FinanceCockpitAccessOptions
{
public const string SectionName = "FinanceCockpitAccess";
public bool Enabled { get; set; } = true;
public string Username { get; set; } = "finance";
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -0,0 +1,11 @@
namespace TrafagSalesExporter.Security;
public sealed class HrKpiAccessOptions
{
public const string SectionName = "HrKpiAccess";
public bool Enabled { get; set; } = true;
public string Username { get; set; } = "hr";
public string PasswordHash { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty;
}
@@ -0,0 +1,13 @@
namespace TrafagSalesExporter.Security;
public sealed class SecurityOptions
{
public const string SectionName = "Security";
public bool Enabled { get; set; } = true;
public bool DevelopmentBypass { get; set; }
public bool DevelopmentUserIsAdmin { get; set; }
public string DevelopmentUserName { get; set; } = "DEV\\TrafagDeveloper";
public List<string> AccessGroups { get; set; } = [];
public List<string> AdminGroups { get; set; } = [];
}
@@ -0,0 +1,6 @@
namespace TrafagSalesExporter.Security;
public static class SecurityPolicies
{
public const string AdminOnly = nameof(AdminOnly);
}
@@ -0,0 +1,47 @@
using Microsoft.AspNetCore.Authorization;
namespace TrafagSalesExporter.Security;
public static class SecurityPolicyFactory
{
public static AuthorizationPolicy BuildAccessPolicy(SecurityOptions settings, bool useDevelopmentAuthentication)
{
if (!settings.Enabled)
{
return new AuthorizationPolicyBuilder()
.RequireAssertion(_ => true)
.Build();
}
var builder = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser();
if (!useDevelopmentAuthentication &&
settings.AccessGroups.Count > 0)
{
builder.RequireAssertion(context =>
settings.AccessGroups.Any(group => context.User.IsInRole(group)));
}
return builder.Build();
}
public static AuthorizationPolicy BuildAdminPolicy(SecurityOptions settings, bool useDevelopmentAuthentication)
{
if (!settings.Enabled)
{
return new AuthorizationPolicyBuilder()
.RequireAssertion(_ => true)
.Build();
}
var builder = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser();
builder.RequireAssertion(context =>
useDevelopmentAuthentication && context.User.HasClaim(DevelopmentAuthenticationHandler.AdminClaimType, "true") ||
settings.AdminGroups.Any(group => context.User.IsInRole(group)));
return builder.Build();
}
}
@@ -0,0 +1,67 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class AppEventLogService : IAppEventLogService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ILogger<AppEventLogService> _logger;
public AppEventLogService(IDbContextFactory<AppDbContext> dbFactory, ILogger<AppEventLogService> logger)
{
_dbFactory = dbFactory;
_logger = logger;
}
public async Task WriteAsync(string category, string message, string level = "Info", int? siteId = null, string? land = null, string? details = null)
{
try
{
using var db = await _dbFactory.CreateDbContextAsync();
db.AppEventLogs.Add(new AppEventLog
{
Timestamp = DateTime.Now,
Level = string.IsNullOrWhiteSpace(level) ? "Info" : level.Trim(),
Category = category?.Trim() ?? string.Empty,
SiteId = siteId,
Land = land?.Trim() ?? string.Empty,
Message = message?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty
});
await db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "AppEventLog konnte nicht gespeichert werden: {Category} - {Message}", category, message);
}
}
public async Task WriteDebugAsync(string category, string message, int? siteId = null, string? land = null, string? details = null)
{
try
{
using var db = await _dbFactory.CreateDbContextAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync();
if (settings is null || !settings.DebugLoggingEnabled)
return;
db.AppEventLogs.Add(new AppEventLog
{
Timestamp = DateTime.Now,
Level = "Debug",
Category = category?.Trim() ?? string.Empty,
SiteId = siteId,
Land = land?.Trim() ?? string.Empty,
Message = message?.Trim() ?? string.Empty,
Details = details?.Trim() ?? string.Empty
});
await db.SaveChangesAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Debug-AppEventLog konnte nicht gespeichert werden: {Category} - {Message}", category, message);
}
}
}
@@ -0,0 +1,266 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class CentralSalesRecordService : ICentralSalesRecordService
{
private const int BatchSize = 25;
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly IAppEventLogService _appEventLogService;
public CentralSalesRecordService(IDbContextFactory<AppDbContext> dbFactory, IAppEventLogService appEventLogService)
{
_dbFactory = dbFactory;
_appEventLogService = appEventLogService;
}
public async Task ReplaceForSiteAsync(Site site, IEnumerable<SalesRecord> records, Action<string>? updateStatus = null)
{
using var db = await _dbFactory.CreateDbContextAsync();
var recordList = records.ToList();
await db.Database.OpenConnectionAsync();
var connection = (SqliteConnection)db.Database.GetDbConnection();
try
{
updateStatus?.Invoke("Zentrale Tabelle: bestehende Saetze zaehlen...");
var existingCount = await CountExistingAsync(connection, site.Id);
if (existingCount > 0)
{
updateStatus?.Invoke("Zentrale Tabelle: alte Saetze loeschen...");
await DeleteExistingAsync(connection, site.Id);
}
updateStatus?.Invoke("Zentrale Tabelle: neue Saetze vorbereiten...");
await InsertRecordsInCommittedBatchesAsync(connection, site, recordList, updateStatus);
updateStatus?.Invoke("Zentrale Tabelle aktualisiert");
await _appEventLogService.WriteAsync(
"Export",
"Zentrale Tabelle aktualisiert",
siteId: site.Id,
land: site.Land,
details: $"Geloescht={existingCount} | Neu={recordList.Count}");
}
finally
{
await db.Database.CloseConnectionAsync();
}
}
public async Task<List<SalesRecord>> GetAllAsync()
{
using var db = await _dbFactory.CreateDbContextAsync();
return await db.CentralSalesRecords
.OrderBy(r => r.Land)
.ThenBy(r => r.Tsc)
.Select(r => new SalesRecord
{
ExtractionDate = r.ExtractionDate,
Tsc = r.Tsc,
DocumentEntry = r.DocumentEntry,
InvoiceNumber = r.InvoiceNumber,
PositionOnInvoice = r.PositionOnInvoice,
Material = r.Material,
Name = r.Name,
ProductGroup = r.ProductGroup,
Quantity = r.Quantity,
SupplierNumber = r.SupplierNumber,
SupplierName = r.SupplierName,
SupplierCountry = r.SupplierCountry,
CustomerNumber = r.CustomerNumber,
CustomerName = r.CustomerName,
CustomerCountry = r.CustomerCountry,
CustomerIndustry = r.CustomerIndustry,
StandardCost = r.StandardCost,
StandardCostCurrency = r.StandardCostCurrency,
PurchaseOrderNumber = r.PurchaseOrderNumber,
SalesPriceValue = r.SalesPriceValue,
SalesCurrency = r.SalesCurrency,
DocumentCurrency = r.DocumentCurrency,
DocumentTotalForeignCurrency = r.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = r.DocumentTotalLocalCurrency,
VatSumForeignCurrency = r.VatSumForeignCurrency,
VatSumLocalCurrency = r.VatSumLocalCurrency,
DocumentRate = r.DocumentRate,
CompanyCurrency = r.CompanyCurrency,
Incoterms2020 = r.Incoterms2020,
SalesResponsibleEmployee = r.SalesResponsibleEmployee,
PostingDate = r.PostingDate,
InvoiceDate = r.InvoiceDate,
OrderDate = r.OrderDate,
Land = r.Land,
DocumentType = r.DocumentType
})
.ToListAsync();
}
private static async Task<int> CountExistingAsync(SqliteConnection connection, int siteId)
{
await using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(1) FROM CentralSalesRecords WHERE SiteId = $siteId;";
command.Parameters.AddWithValue("$siteId", siteId);
var scalar = await command.ExecuteScalarAsync();
return scalar is null or DBNull ? 0 : Convert.ToInt32(scalar);
}
private static async Task DeleteExistingAsync(SqliteConnection connection, int siteId)
{
await using var transaction = connection.BeginTransaction();
await using var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = "DELETE FROM CentralSalesRecords WHERE SiteId = $siteId;";
command.Parameters.AddWithValue("$siteId", siteId);
await command.ExecuteNonQueryAsync();
await transaction.CommitAsync();
}
private static async Task InsertRecordsInCommittedBatchesAsync(
SqliteConnection connection,
Site site,
IReadOnlyList<SalesRecord> records,
Action<string>? updateStatus)
{
var sourceSystem = string.IsNullOrWhiteSpace(site.SourceSystem) ? "SAP" : site.SourceSystem;
var total = records.Count;
var totalBatches = Math.Max(1, (int)Math.Ceiling(total / (double)BatchSize));
var processed = 0;
for (var batchIndex = 0; batchIndex < totalBatches; batchIndex++)
{
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} speichern...");
await using var transaction = connection.BeginTransaction();
await using var command = CreateInsertCommand(connection, transaction);
var batchRecords = records
.Skip(batchIndex * BatchSize)
.Take(BatchSize);
foreach (var record in batchRecords)
{
SetInsertParameters(command, site, sourceSystem, record);
await command.ExecuteNonQueryAsync();
processed++;
}
updateStatus?.Invoke($"Zentrale Tabelle: Batch {batchIndex + 1}/{totalBatches} abschliessen...");
await transaction.CommitAsync();
}
updateStatus?.Invoke($"Zentrale Tabelle: {processed} Datensaetze gespeichert.");
}
private static SqliteCommand CreateInsertCommand(SqliteConnection connection, SqliteTransaction transaction)
{
var command = connection.CreateCommand();
command.Transaction = transaction;
command.CommandText = """
INSERT INTO CentralSalesRecords (
StoredAtUtc, SiteId, SourceSystem, ExtractionDate, Tsc, DocumentEntry, InvoiceNumber, PositionOnInvoice,
Material, Name, ProductGroup, Quantity, SupplierNumber, SupplierName, SupplierCountry,
CustomerNumber, CustomerName, CustomerCountry, CustomerIndustry, StandardCost,
StandardCostCurrency, PurchaseOrderNumber, SalesPriceValue, SalesCurrency, Incoterms2020,
DocumentCurrency, DocumentTotalForeignCurrency, DocumentTotalLocalCurrency, VatSumForeignCurrency,
VatSumLocalCurrency, DocumentRate, CompanyCurrency, SalesResponsibleEmployee, PostingDate, InvoiceDate, OrderDate, Land, DocumentType
)
VALUES (
$storedAtUtc, $siteId, $sourceSystem, $extractionDate, $tsc, $documentEntry, $invoiceNumber, $positionOnInvoice,
$material, $name, $productGroup, $quantity, $supplierNumber, $supplierName, $supplierCountry,
$customerNumber, $customerName, $customerCountry, $customerIndustry, $standardCost,
$standardCostCurrency, $purchaseOrderNumber, $salesPriceValue, $salesCurrency, $incoterms2020,
$documentCurrency, $documentTotalForeignCurrency, $documentTotalLocalCurrency, $vatSumForeignCurrency,
$vatSumLocalCurrency, $documentRate, $companyCurrency, $salesResponsibleEmployee, $postingDate, $invoiceDate, $orderDate, $land, $documentType
);
""";
command.Parameters.Add("$storedAtUtc", SqliteType.Text);
command.Parameters.Add("$siteId", SqliteType.Integer);
command.Parameters.Add("$sourceSystem", SqliteType.Text);
command.Parameters.Add("$extractionDate", SqliteType.Text);
command.Parameters.Add("$tsc", SqliteType.Text);
command.Parameters.Add("$documentEntry", SqliteType.Integer);
command.Parameters.Add("$invoiceNumber", SqliteType.Text);
command.Parameters.Add("$positionOnInvoice", SqliteType.Integer);
command.Parameters.Add("$material", SqliteType.Text);
command.Parameters.Add("$name", SqliteType.Text);
command.Parameters.Add("$productGroup", SqliteType.Text);
command.Parameters.Add("$quantity", SqliteType.Real);
command.Parameters.Add("$supplierNumber", SqliteType.Text);
command.Parameters.Add("$supplierName", SqliteType.Text);
command.Parameters.Add("$supplierCountry", SqliteType.Text);
command.Parameters.Add("$customerNumber", SqliteType.Text);
command.Parameters.Add("$customerName", SqliteType.Text);
command.Parameters.Add("$customerCountry", SqliteType.Text);
command.Parameters.Add("$customerIndustry", SqliteType.Text);
command.Parameters.Add("$standardCost", SqliteType.Real);
command.Parameters.Add("$standardCostCurrency", SqliteType.Text);
command.Parameters.Add("$purchaseOrderNumber", SqliteType.Text);
command.Parameters.Add("$salesPriceValue", SqliteType.Real);
command.Parameters.Add("$salesCurrency", SqliteType.Text);
command.Parameters.Add("$documentCurrency", SqliteType.Text);
command.Parameters.Add("$documentTotalForeignCurrency", SqliteType.Real);
command.Parameters.Add("$documentTotalLocalCurrency", SqliteType.Real);
command.Parameters.Add("$vatSumForeignCurrency", SqliteType.Real);
command.Parameters.Add("$vatSumLocalCurrency", SqliteType.Real);
command.Parameters.Add("$documentRate", SqliteType.Real);
command.Parameters.Add("$companyCurrency", SqliteType.Text);
command.Parameters.Add("$incoterms2020", SqliteType.Text);
command.Parameters.Add("$salesResponsibleEmployee", SqliteType.Text);
command.Parameters.Add("$postingDate", SqliteType.Text);
command.Parameters.Add("$invoiceDate", SqliteType.Text);
command.Parameters.Add("$orderDate", SqliteType.Text);
command.Parameters.Add("$land", SqliteType.Text);
command.Parameters.Add("$documentType", SqliteType.Text);
return command;
}
private static void SetInsertParameters(SqliteCommand command, Site site, string sourceSystem, SalesRecord record)
{
command.Parameters["$storedAtUtc"].Value = DateTime.UtcNow.ToString("O");
command.Parameters["$siteId"].Value = site.Id;
command.Parameters["$sourceSystem"].Value = sourceSystem;
command.Parameters["$extractionDate"].Value = record.ExtractionDate.ToString("O");
command.Parameters["$tsc"].Value = record.Tsc ?? string.Empty;
command.Parameters["$documentEntry"].Value = record.DocumentEntry;
command.Parameters["$invoiceNumber"].Value = record.InvoiceNumber ?? string.Empty;
command.Parameters["$positionOnInvoice"].Value = record.PositionOnInvoice;
command.Parameters["$material"].Value = record.Material ?? string.Empty;
command.Parameters["$name"].Value = record.Name ?? string.Empty;
command.Parameters["$productGroup"].Value = record.ProductGroup ?? string.Empty;
command.Parameters["$quantity"].Value = record.Quantity;
command.Parameters["$supplierNumber"].Value = record.SupplierNumber ?? string.Empty;
command.Parameters["$supplierName"].Value = record.SupplierName ?? string.Empty;
command.Parameters["$supplierCountry"].Value = record.SupplierCountry ?? string.Empty;
command.Parameters["$customerNumber"].Value = record.CustomerNumber ?? string.Empty;
command.Parameters["$customerName"].Value = record.CustomerName ?? string.Empty;
command.Parameters["$customerCountry"].Value = record.CustomerCountry ?? string.Empty;
command.Parameters["$customerIndustry"].Value = record.CustomerIndustry ?? string.Empty;
command.Parameters["$standardCost"].Value = record.StandardCost;
command.Parameters["$standardCostCurrency"].Value = record.StandardCostCurrency ?? string.Empty;
command.Parameters["$purchaseOrderNumber"].Value = record.PurchaseOrderNumber ?? string.Empty;
command.Parameters["$salesPriceValue"].Value = record.SalesPriceValue;
command.Parameters["$salesCurrency"].Value = record.SalesCurrency ?? string.Empty;
command.Parameters["$documentCurrency"].Value = record.DocumentCurrency ?? string.Empty;
command.Parameters["$documentTotalForeignCurrency"].Value = record.DocumentTotalForeignCurrency;
command.Parameters["$documentTotalLocalCurrency"].Value = record.DocumentTotalLocalCurrency;
command.Parameters["$vatSumForeignCurrency"].Value = record.VatSumForeignCurrency;
command.Parameters["$vatSumLocalCurrency"].Value = record.VatSumLocalCurrency;
command.Parameters["$documentRate"].Value = record.DocumentRate;
command.Parameters["$companyCurrency"].Value = record.CompanyCurrency ?? string.Empty;
command.Parameters["$incoterms2020"].Value = record.Incoterms2020 ?? string.Empty;
command.Parameters["$salesResponsibleEmployee"].Value = record.SalesResponsibleEmployee ?? string.Empty;
command.Parameters["$postingDate"].Value = record.PostingDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$invoiceDate"].Value = record.InvoiceDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$orderDate"].Value = record.OrderDate?.ToString("O") ?? (object)DBNull.Value;
command.Parameters["$land"].Value = record.Land ?? string.Empty;
command.Parameters["$documentType"].Value = record.DocumentType ?? string.Empty;
}
}
@@ -0,0 +1,625 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ConfigTransferService : IConfigTransferService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
public ConfigTransferService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<string> ExportJsonAsync(bool includeSecrets)
{
using var db = await _dbFactory.CreateDbContextAsync();
var sharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var exportSettings = await db.ExportSettings.FirstOrDefaultAsync();
var sourceSystems = await db.SourceSystemDefinitions.OrderBy(x => x.Code).ToListAsync();
var exchangeRates = await db.CurrencyExchangeRates
.OrderBy(x => x.FromCurrency)
.ThenBy(x => x.ToCurrency)
.ThenByDescending(x => x.ValidFrom)
.ToListAsync();
var financeReferences = await db.FinanceReferences
.OrderBy(x => x.Year)
.ThenBy(x => x.Key)
.ToListAsync();
var financeIntercompanyRules = await db.FinanceIntercompanyRules
.OrderBy(x => x.ScopeKey)
.ThenBy(x => x.CustomerNumber)
.ThenBy(x => x.CustomerNameContains)
.ToListAsync();
var financeRules = await db.FinanceRules
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Id)
.ToListAsync();
var hanaServers = await db.HanaServers.OrderBy(x => x.Name).ToListAsync();
var sites = await db.Sites.OrderBy(x => x.Land).ToListAsync();
var rules = await db.FieldTransformationRules.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapSources = await db.SapSourceDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapJoins = await db.SapJoinDefinitions.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var sapMappings = await db.SapFieldMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var manualExcelMappings = await db.ManualExcelColumnMappings.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToListAsync();
var serverKeyMap = hanaServers.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
var siteKeyMap = sites.ToDictionary(x => x.Id, _ => Guid.NewGuid().ToString("N"));
var package = new ConfigTransferPackage
{
IncludesSecrets = includeSecrets,
SharePointConfig = sharePoint is null ? null : new ConfigTransferSharePoint
{
SiteUrl = sharePoint.SiteUrl,
ExportFolder = sharePoint.ExportFolder,
CentralExportFolder = sharePoint.CentralExportFolder,
TenantId = sharePoint.TenantId,
ClientId = sharePoint.ClientId,
ClientSecret = includeSecrets ? sharePoint.ClientSecret : null
},
ExportSettings = exportSettings is null ? null : new ConfigTransferExportSettings
{
DateFilter = exportSettings.DateFilter,
TimerHour = exportSettings.TimerHour,
TimerMinute = exportSettings.TimerMinute,
TimerEnabled = exportSettings.TimerEnabled,
DebugLoggingEnabled = exportSettings.DebugLoggingEnabled,
LocalSiteExportFolder = exportSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = exportSettings.LocalConsolidatedExportFolder
},
SourceSystemDefinitions = sourceSystems.Select(system => new ConfigTransferSourceSystemDefinition
{
Code = system.Code,
DisplayName = system.DisplayName,
ConnectionKind = system.ConnectionKind,
IsActive = system.IsActive,
CentralServiceUrl = system.CentralServiceUrl,
CentralUsername = includeSecrets ? system.CentralUsername : null,
CentralPassword = includeSecrets ? system.CentralPassword : null
}).ToList(),
CurrencyExchangeRates = exchangeRates.Select(rate => new ConfigTransferCurrencyExchangeRate
{
FromCurrency = rate.FromCurrency,
ToCurrency = rate.ToCurrency,
Rate = rate.Rate,
ValidFrom = rate.ValidFrom,
ValidTo = rate.ValidTo,
Notes = rate.Notes,
IsActive = rate.IsActive
}).ToList(),
FinanceReferences = financeReferences.Select(reference => new ConfigTransferFinanceReference
{
Key = reference.Key,
Label = reference.Label,
Year = reference.Year,
LocalCurrencyValue = reference.LocalCurrencyValue,
CheckValue = reference.CheckValue,
Notes = reference.Notes,
IsActive = reference.IsActive
}).ToList(),
FinanceIntercompanyRules = financeIntercompanyRules.Select(rule => new ConfigTransferFinanceIntercompanyRule
{
ScopeKey = rule.ScopeKey,
CustomerNumber = rule.CustomerNumber,
CustomerNameContains = rule.CustomerNameContains,
Notes = rule.Notes,
IsActive = rule.IsActive
}).ToList(),
FinanceRules = financeRules.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
NumericValue = rule.NumericValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
}).ToList(),
HanaServers = hanaServers.Select(server => new ConfigTransferHanaServer
{
Key = serverKeyMap[server.Id],
SourceSystem = server.SourceSystem,
Name = server.Name,
Host = server.Host,
Port = server.Port,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
}).ToList(),
Sites = sites.Select(site => new ConfigTransferSite
{
Key = siteKeyMap[site.Id],
HanaServerKey = site.HanaServerId.HasValue && serverKeyMap.TryGetValue(site.HanaServerId.Value, out var serverKey) ? serverKey : null,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = site.SourceSystem,
UsernameOverride = includeSecrets ? site.UsernameOverride : null,
PasswordOverride = includeSecrets ? site.PasswordOverride : null,
LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
}).ToList(),
FieldTransformationRules = rules.Select(r => new FieldTransformationRule
{
SourceSystem = r.SourceSystem,
SourceField = r.SourceField,
TargetField = r.TargetField,
TransformationType = r.TransformationType,
RuleScope = r.RuleScope,
Argument = r.Argument,
SortOrder = r.SortOrder,
IsActive = r.IsActive
}).ToList(),
SapSourceDefinitions = sapSources.Select(s => new ConfigTransferSapSourceDefinition
{
SiteKey = siteKeyMap[s.SiteId],
Alias = s.Alias,
EntitySet = s.EntitySet,
IsPrimary = s.IsPrimary,
IsActive = s.IsActive,
SortOrder = s.SortOrder
}).ToList(),
SapJoinDefinitions = sapJoins.Select(j => new ConfigTransferSapJoinDefinition
{
SiteKey = siteKeyMap[j.SiteId],
LeftAlias = j.LeftAlias,
RightAlias = j.RightAlias,
LeftKeys = j.LeftKeys,
RightKeys = j.RightKeys,
JoinType = j.JoinType,
IsActive = j.IsActive,
SortOrder = j.SortOrder
}).ToList(),
SapFieldMappings = sapMappings.Select(m => new ConfigTransferSapFieldMapping
{
SiteKey = siteKeyMap[m.SiteId],
TargetField = m.TargetField,
SourceExpression = m.SourceExpression,
IsRequired = m.IsRequired,
IsActive = m.IsActive,
SortOrder = m.SortOrder
}).ToList(),
ManualExcelColumnMappings = manualExcelMappings.Select(m => new ConfigTransferManualExcelColumnMapping
{
SiteKey = siteKeyMap[m.SiteId],
TargetField = m.TargetField,
SourceHeader = m.SourceHeader,
IsRequired = m.IsRequired,
IsActive = m.IsActive,
SortOrder = m.SortOrder
}).ToList()
};
return JsonSerializer.Serialize(package, JsonOptions);
}
public async Task ImportJsonAsync(string json)
{
var package = JsonSerializer.Deserialize<ConfigTransferPackage>(json, JsonOptions)
?? throw new InvalidOperationException("Konfigurationsdatei konnte nicht gelesen werden.");
var importedSourceSystems = ResolveImportedSourceSystems(json, package);
using var db = await _dbFactory.CreateDbContextAsync();
await using var transaction = await db.Database.BeginTransactionAsync();
var existingSharePoint = await db.SharePointConfigs.FirstOrDefaultAsync();
var existingSettings = await db.ExportSettings.FirstOrDefaultAsync();
var existingSourceSystems = await db.SourceSystemDefinitions.ToListAsync();
var existingServers = await db.HanaServers.ToListAsync();
var existingExchangeRates = await db.CurrencyExchangeRates.ToListAsync();
var existingFinanceReferences = await db.FinanceReferences.ToListAsync();
var existingFinanceIntercompanyRules = await db.FinanceIntercompanyRules.ToListAsync();
var existingFinanceRules = await db.FinanceRules.ToListAsync();
var existingSites = await db.Sites.ToListAsync();
var existingCentralRecords = await db.CentralSalesRecords.AsNoTracking().ToListAsync();
var existingRules = await db.FieldTransformationRules.ToListAsync();
var existingSapSources = await db.SapSourceDefinitions.ToListAsync();
var existingSapJoins = await db.SapJoinDefinitions.ToListAsync();
var existingSapMappings = await db.SapFieldMappings.ToListAsync();
var existingManualExcelMappings = await db.ManualExcelColumnMappings.ToListAsync();
var preservedSharePointSecret = existingSharePoint?.ClientSecret ?? string.Empty;
var preservedSourceSystemSecrets = existingSourceSystems.ToDictionary(
x => x.Code,
x => (CentralUsername: x.CentralUsername, CentralPassword: x.CentralPassword),
StringComparer.OrdinalIgnoreCase);
var preservedSiteSecrets = existingSites.ToDictionary(
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem),
x => (x.UsernameOverride, x.PasswordOverride));
var existingSiteSignaturesById = existingSites.ToDictionary(
x => x.Id,
x => BuildSiteSignature(x.Land, x.TSC, x.Schema, x.SourceSystem));
if (existingSapMappings.Count > 0) db.SapFieldMappings.RemoveRange(existingSapMappings);
if (existingManualExcelMappings.Count > 0) db.ManualExcelColumnMappings.RemoveRange(existingManualExcelMappings);
if (existingSapJoins.Count > 0) db.SapJoinDefinitions.RemoveRange(existingSapJoins);
if (existingSapSources.Count > 0) db.SapSourceDefinitions.RemoveRange(existingSapSources);
if (existingRules.Count > 0) db.FieldTransformationRules.RemoveRange(existingRules);
if (package.FinanceReferences.Count > 0 && existingFinanceReferences.Count > 0)
db.FinanceReferences.RemoveRange(existingFinanceReferences);
if (package.FinanceIntercompanyRules.Count > 0 && existingFinanceIntercompanyRules.Count > 0)
db.FinanceIntercompanyRules.RemoveRange(existingFinanceIntercompanyRules);
if (package.FinanceRules.Count > 0 && existingFinanceRules.Count > 0)
db.FinanceRules.RemoveRange(existingFinanceRules);
if (existingExchangeRates.Count > 0) db.CurrencyExchangeRates.RemoveRange(existingExchangeRates);
if (existingSites.Count > 0) db.Sites.RemoveRange(existingSites);
if (existingServers.Count > 0) db.HanaServers.RemoveRange(existingServers);
if (existingSourceSystems.Count > 0) db.SourceSystemDefinitions.RemoveRange(existingSourceSystems);
if (existingSharePoint is not null) db.SharePointConfigs.Remove(existingSharePoint);
if (existingSettings is not null) db.ExportSettings.Remove(existingSettings);
await db.SaveChangesAsync();
var newSharePoint = package.SharePointConfig is null ? new SharePointConfig() : new SharePointConfig
{
SiteUrl = package.SharePointConfig.SiteUrl,
ExportFolder = package.SharePointConfig.ExportFolder,
CentralExportFolder = package.SharePointConfig.CentralExportFolder,
TenantId = package.SharePointConfig.TenantId,
ClientId = package.SharePointConfig.ClientId,
ClientSecret = package.IncludesSecrets ? package.SharePointConfig.ClientSecret ?? string.Empty : preservedSharePointSecret
};
db.SharePointConfigs.Add(newSharePoint);
var importedSettings = package.ExportSettings ?? new ConfigTransferExportSettings();
db.ExportSettings.Add(new ExportSettings
{
DateFilter = importedSettings.DateFilter,
TimerHour = importedSettings.TimerHour,
TimerMinute = importedSettings.TimerMinute,
TimerEnabled = importedSettings.TimerEnabled,
DebugLoggingEnabled = importedSettings.DebugLoggingEnabled,
LocalSiteExportFolder = importedSettings.LocalSiteExportFolder,
LocalConsolidatedExportFolder = importedSettings.LocalConsolidatedExportFolder
});
foreach (var sourceSystem in importedSourceSystems)
{
preservedSourceSystemSecrets.TryGetValue(sourceSystem.Code, out var preserved);
db.SourceSystemDefinitions.Add(new SourceSystemDefinition
{
Code = sourceSystem.Code,
DisplayName = sourceSystem.DisplayName,
ConnectionKind = sourceSystem.ConnectionKind,
IsActive = sourceSystem.IsActive,
CentralServiceUrl = sourceSystem.CentralServiceUrl,
CentralUsername = package.IncludesSecrets ? sourceSystem.CentralUsername ?? string.Empty : preserved.CentralUsername ?? string.Empty,
CentralPassword = package.IncludesSecrets ? sourceSystem.CentralPassword ?? string.Empty : preserved.CentralPassword ?? string.Empty
});
}
if (package.CurrencyExchangeRates.Count > 0)
{
db.CurrencyExchangeRates.AddRange(package.CurrencyExchangeRates.Select(rate => new CurrencyExchangeRate
{
FromCurrency = rate.FromCurrency,
ToCurrency = rate.ToCurrency,
Rate = rate.Rate,
ValidFrom = rate.ValidFrom,
ValidTo = rate.ValidTo,
Notes = rate.Notes,
IsActive = rate.IsActive
}));
}
if (package.FinanceReferences.Count > 0)
{
db.FinanceReferences.AddRange(package.FinanceReferences.Select(reference => new FinanceReference
{
Key = reference.Key,
Label = reference.Label,
Year = reference.Year,
LocalCurrencyValue = reference.LocalCurrencyValue,
CheckValue = reference.CheckValue,
Notes = reference.Notes,
IsActive = reference.IsActive
}));
}
if (package.FinanceIntercompanyRules.Count > 0)
{
db.FinanceIntercompanyRules.AddRange(package.FinanceIntercompanyRules.Select(rule => new FinanceIntercompanyRule
{
ScopeKey = rule.ScopeKey,
CustomerNumber = rule.CustomerNumber,
CustomerNameContains = rule.CustomerNameContains,
Notes = rule.Notes,
IsActive = rule.IsActive
}));
}
if (package.FinanceRules.Count > 0)
{
db.FinanceRules.AddRange(package.FinanceRules.Select(rule => new FinanceRule
{
ScopeKey = rule.ScopeKey,
Year = rule.Year,
RuleType = rule.RuleType,
FieldName = rule.FieldName,
MatchType = rule.MatchType,
MatchValue = rule.MatchValue,
NumericValue = rule.NumericValue,
Notes = rule.Notes,
SortOrder = rule.SortOrder,
IsActive = rule.IsActive
}));
}
var serverIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var server in package.HanaServers)
{
var entity = new HanaServer
{
SourceSystem = server.SourceSystem,
Name = server.Name,
Host = server.Host,
Port = server.Port,
Username = string.Empty,
Password = string.Empty,
DatabaseName = server.DatabaseName,
UseSsl = server.UseSsl,
ValidateCertificate = server.ValidateCertificate,
AdditionalParams = server.AdditionalParams
};
db.HanaServers.Add(entity);
await db.SaveChangesAsync();
serverIdMap[server.Key] = entity.Id;
}
var siteIdMap = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var importedSiteIdBySignature = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var site in package.Sites)
{
preservedSiteSecrets.TryGetValue(BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem), out var preserved);
var entity = new Site
{
HanaServerId = !string.IsNullOrWhiteSpace(site.HanaServerKey) && serverIdMap.TryGetValue(site.HanaServerKey, out var mappedServerId)
? mappedServerId
: null,
Schema = site.Schema,
TSC = site.TSC,
Land = site.Land,
SourceSystem = site.SourceSystem,
UsernameOverride = package.IncludesSecrets ? site.UsernameOverride ?? string.Empty : preserved.UsernameOverride ?? string.Empty,
PasswordOverride = package.IncludesSecrets ? site.PasswordOverride ?? string.Empty : preserved.PasswordOverride ?? string.Empty,
LocalExportFolderOverride = site.LocalExportFolderOverride,
ManualImportFilePath = site.ManualImportFilePath,
ManualImportLastUploadedAtUtc = site.ManualImportLastUploadedAtUtc,
SapServiceUrl = site.SapServiceUrl,
SapEntitySet = site.SapEntitySet,
SapEntitySetsCache = site.SapEntitySetsCache,
SapEntitySetsRefreshedAtUtc = site.SapEntitySetsRefreshedAtUtc,
IsActive = site.IsActive
};
db.Sites.Add(entity);
await db.SaveChangesAsync();
siteIdMap[site.Key] = entity.Id;
importedSiteIdBySignature[BuildSiteSignature(site.Land, site.TSC, site.Schema, site.SourceSystem)] = entity.Id;
}
var centralRecordsToPreserve = existingCentralRecords
.Where(record => existingSiteSignaturesById.TryGetValue(record.SiteId, out var signature) && importedSiteIdBySignature.ContainsKey(signature))
.Select(record =>
{
var signature = existingSiteSignaturesById[record.SiteId];
return new CentralSalesRecord
{
StoredAtUtc = record.StoredAtUtc,
SiteId = importedSiteIdBySignature[signature],
SourceSystem = record.SourceSystem,
ExtractionDate = record.ExtractionDate,
Tsc = record.Tsc,
DocumentEntry = record.DocumentEntry,
InvoiceNumber = record.InvoiceNumber,
PositionOnInvoice = record.PositionOnInvoice,
Material = record.Material,
Name = record.Name,
ProductGroup = record.ProductGroup,
Quantity = record.Quantity,
SupplierNumber = record.SupplierNumber,
SupplierName = record.SupplierName,
SupplierCountry = record.SupplierCountry,
CustomerNumber = record.CustomerNumber,
CustomerName = record.CustomerName,
CustomerCountry = record.CustomerCountry,
CustomerIndustry = record.CustomerIndustry,
StandardCost = record.StandardCost,
StandardCostCurrency = record.StandardCostCurrency,
PurchaseOrderNumber = record.PurchaseOrderNumber,
SalesPriceValue = record.SalesPriceValue,
SalesCurrency = record.SalesCurrency,
DocumentCurrency = record.DocumentCurrency,
DocumentTotalForeignCurrency = record.DocumentTotalForeignCurrency,
DocumentTotalLocalCurrency = record.DocumentTotalLocalCurrency,
VatSumForeignCurrency = record.VatSumForeignCurrency,
VatSumLocalCurrency = record.VatSumLocalCurrency,
DocumentRate = record.DocumentRate,
CompanyCurrency = record.CompanyCurrency,
Incoterms2020 = record.Incoterms2020,
SalesResponsibleEmployee = record.SalesResponsibleEmployee,
PostingDate = record.PostingDate,
InvoiceDate = record.InvoiceDate,
OrderDate = record.OrderDate,
Land = record.Land,
DocumentType = record.DocumentType
};
})
.ToList();
if (centralRecordsToPreserve.Count > 0)
db.CentralSalesRecords.AddRange(centralRecordsToPreserve);
if (package.FieldTransformationRules.Count > 0)
{
db.FieldTransformationRules.AddRange(package.FieldTransformationRules.Select(r => new FieldTransformationRule
{
SourceSystem = r.SourceSystem,
SourceField = r.SourceField,
TargetField = r.TargetField,
TransformationType = r.TransformationType,
RuleScope = r.RuleScope,
Argument = r.Argument,
SortOrder = r.SortOrder,
IsActive = r.IsActive
}));
}
if (package.SapSourceDefinitions.Count > 0)
{
db.SapSourceDefinitions.AddRange(package.SapSourceDefinitions
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new SapSourceDefinition
{
SiteId = siteIdMap[x.SiteKey],
Alias = x.Alias,
EntitySet = x.EntitySet,
IsPrimary = x.IsPrimary,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
if (package.SapJoinDefinitions.Count > 0)
{
db.SapJoinDefinitions.AddRange(package.SapJoinDefinitions
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new SapJoinDefinition
{
SiteId = siteIdMap[x.SiteKey],
LeftAlias = x.LeftAlias,
RightAlias = x.RightAlias,
LeftKeys = x.LeftKeys,
RightKeys = x.RightKeys,
JoinType = x.JoinType,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
if (package.SapFieldMappings.Count > 0)
{
db.SapFieldMappings.AddRange(package.SapFieldMappings
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new SapFieldMapping
{
SiteId = siteIdMap[x.SiteKey],
TargetField = x.TargetField,
SourceExpression = x.SourceExpression,
IsRequired = x.IsRequired,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
if (package.ManualExcelColumnMappings.Count > 0)
{
db.ManualExcelColumnMappings.AddRange(package.ManualExcelColumnMappings
.Where(x => siteIdMap.ContainsKey(x.SiteKey))
.Select(x => new ManualExcelColumnMapping
{
SiteId = siteIdMap[x.SiteKey],
TargetField = x.TargetField,
SourceHeader = x.SourceHeader,
IsRequired = x.IsRequired,
IsActive = x.IsActive,
SortOrder = x.SortOrder
}));
}
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
private static string BuildSiteSignature(string land, string tsc, string schema, string sourceSystem)
=> $"{land}|{tsc}|{schema}|{sourceSystem}".ToUpperInvariant();
private static List<ConfigTransferSourceSystemDefinition> ResolveImportedSourceSystems(string json, ConfigTransferPackage package)
{
if (package.SourceSystemDefinitions.Count == 0)
return BuildDefaultSourceSystems();
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty(nameof(ConfigTransferPackage.SourceSystemDefinitions), out var sourceSystemsElement) ||
sourceSystemsElement.ValueKind != JsonValueKind.Array)
{
return package.SourceSystemDefinitions;
}
var imported = package.SourceSystemDefinitions
.Select((sourceSystem, index) =>
{
var hasExplicitConnectionKind =
index < sourceSystemsElement.GetArrayLength() &&
sourceSystemsElement[index].TryGetProperty(nameof(ConfigTransferSourceSystemDefinition.ConnectionKind), out _);
if (hasExplicitConnectionKind)
return sourceSystem;
sourceSystem.ConnectionKind = InferLegacyConnectionKind(sourceSystem.Code);
return sourceSystem;
})
.ToList();
return imported;
}
private static string InferLegacyConnectionKind(string code)
{
if (string.Equals(code, "SAP", StringComparison.OrdinalIgnoreCase))
return SourceSystemConnectionKinds.SapGateway;
if (string.Equals(code, "MANUAL_EXCEL", StringComparison.OrdinalIgnoreCase))
return SourceSystemConnectionKinds.ManualExcel;
return SourceSystemConnectionKinds.Hana;
}
private static List<ConfigTransferSourceSystemDefinition> BuildDefaultSourceSystems()
{
return
[
new ConfigTransferSourceSystemDefinition
{
Code = "SAP",
DisplayName = "SAP",
ConnectionKind = SourceSystemConnectionKinds.SapGateway,
IsActive = true,
CentralServiceUrl = string.Empty
},
new ConfigTransferSourceSystemDefinition
{
Code = "BI1",
DisplayName = "BI1",
ConnectionKind = SourceSystemConnectionKinds.Hana,
IsActive = true
},
new ConfigTransferSourceSystemDefinition
{
Code = "SAGE",
DisplayName = "SAGE",
ConnectionKind = SourceSystemConnectionKinds.Hana,
IsActive = true
},
new ConfigTransferSourceSystemDefinition
{
Code = "MANUAL_EXCEL",
DisplayName = "Manual Excel",
ConnectionKind = SourceSystemConnectionKinds.ManualExcel,
IsActive = true
}
];
}
}
@@ -0,0 +1,76 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public class ConsolidatedExportService : IConsolidatedExportService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
private readonly ICentralSalesRecordService _centralSalesRecordService;
private readonly IExcelExportService _excelService;
private readonly ISharePointUploadService _sharePointService;
public ConsolidatedExportService(
IDbContextFactory<AppDbContext> dbFactory,
ICentralSalesRecordService centralSalesRecordService,
IExcelExportService excelService,
ISharePointUploadService sharePointService)
{
_dbFactory = dbFactory;
_centralSalesRecordService = centralSalesRecordService;
_excelService = excelService;
_sharePointService = sharePointService;
}
public async Task<string?> ExportAsync()
{
var consolidatedRecords = await _centralSalesRecordService.GetAllAsync();
if (consolidatedRecords.Count == 0)
return null;
using var db = await _dbFactory.CreateDbContextAsync();
var spConfig = await db.SharePointConfigs.FirstOrDefaultAsync();
var settings = await db.ExportSettings.FirstOrDefaultAsync() ?? new ExportSettings();
var outputDir = ResolveConsolidatedOutputDirectory(settings);
var consolidatedPath = _excelService.CreateConsolidatedExcelFile(
outputDir,
DateTime.UtcNow.Date,
consolidatedRecords
.OrderBy(r => r.Land)
.ThenBy(r => r.Tsc)
.ThenByDescending(r => r.InvoiceDate ?? DateTime.MinValue)
.ThenBy(r => r.InvoiceNumber)
.ThenBy(r => r.PositionOnInvoice)
.ToList());
if (spConfig is not null &&
!string.IsNullOrWhiteSpace(spConfig.TenantId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientId) &&
!string.IsNullOrWhiteSpace(spConfig.ClientSecret))
{
var centralFolderConfigured = !string.IsNullOrWhiteSpace(spConfig.CentralExportFolder);
var sharePointFolder = centralFolderConfigured
? spConfig.CentralExportFolder
: spConfig.ExportFolder;
var landSubfolder = centralFolderConfigured ? string.Empty : "Alle";
await _sharePointService.UploadAsync(
spConfig.TenantId, spConfig.ClientId, spConfig.ClientSecret,
spConfig.SiteUrl, sharePointFolder, landSubfolder, consolidatedPath);
}
return consolidatedPath;
}
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
return settings.LocalConsolidatedExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
return settings.LocalSiteExportFolder.Trim();
return Path.Combine(AppContext.BaseDirectory, "output");
}
}
@@ -0,0 +1,121 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
namespace TrafagSalesExporter.Services;
public class CurrencyExchangeRateService : ICurrencyExchangeRateService
{
private static readonly Dictionary<string, string> BuiltInCurrencyAliases = new(StringComparer.OrdinalIgnoreCase)
{
["$"] = "USD",
["US$"] = "USD",
["USD"] = "USD",
["€"] = "EUR",
["EUR"] = "EUR",
["CHF"] = "CHF",
["SFR"] = "CHF",
["INR"] = "INR",
["RS"] = "INR",
["GBP"] = "GBP",
["CAD"] = "CAD"
};
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public CurrencyExchangeRateService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public decimal? ResolveRate(string fromCurrency, string toCurrency, DateTime? effectiveDate)
{
var normalizedFrom = NormalizeCurrencyCode(fromCurrency);
var normalizedTo = NormalizeCurrencyCode(toCurrency);
if (string.IsNullOrWhiteSpace(normalizedFrom) || string.IsNullOrWhiteSpace(normalizedTo))
return null;
if (string.Equals(normalizedFrom, normalizedTo, StringComparison.OrdinalIgnoreCase))
return 1m;
var date = (effectiveDate ?? DateTime.UtcNow).Date;
using var db = _dbFactory.CreateDbContext();
var directRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == normalizedFrom
&& x.ToCurrency.ToUpper() == normalizedTo
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (directRate is not null)
return directRate.Rate;
var inverseRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == normalizedTo
&& x.ToCurrency.ToUpper() == normalizedFrom
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (inverseRate is not null && inverseRate.Rate != 0m)
return 1m / inverseRate.Rate;
var fromToEur = ResolveDirectOrInverseRate(db, normalizedFrom, "EUR", date);
var eurToTarget = ResolveDirectOrInverseRate(db, "EUR", normalizedTo, date);
if (fromToEur.HasValue && eurToTarget.HasValue)
return fromToEur.Value * eurToTarget.Value;
return null;
}
public string NormalizeCurrencyCode(string? currencyCode)
{
var normalized = currencyCode?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(normalized))
return string.Empty;
return BuiltInCurrencyAliases.TryGetValue(normalized, out var mapped)
? mapped
: normalized.ToUpperInvariant();
}
private static decimal? ResolveDirectOrInverseRate(AppDbContext db, string fromCurrency, string toCurrency, DateTime date)
{
if (string.Equals(fromCurrency, toCurrency, StringComparison.OrdinalIgnoreCase))
return 1m;
var directRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == fromCurrency
&& x.ToCurrency.ToUpper() == toCurrency
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (directRate is not null)
return directRate.Rate;
var inverseRate = db.CurrencyExchangeRates
.AsNoTracking()
.Where(x => x.IsActive
&& x.FromCurrency.ToUpper() == toCurrency
&& x.ToCurrency.ToUpper() == fromCurrency
&& x.ValidFrom.Date <= date
&& (!x.ValidTo.HasValue || x.ValidTo.Value.Date >= date))
.OrderByDescending(x => x.ValidFrom)
.FirstOrDefault();
if (inverseRate is not null && inverseRate.Rate != 0m)
return 1m / inverseRate.Rate;
return null;
}
}
@@ -0,0 +1,208 @@
using Microsoft.EntityFrameworkCore;
using TrafagSalesExporter.Data;
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services;
public interface IDashboardPageService
{
Task<DashboardPageState> LoadAsync();
}
public sealed class DashboardPageService : IDashboardPageService
{
private readonly IDbContextFactory<AppDbContext> _dbFactory;
public DashboardPageService(IDbContextFactory<AppDbContext> dbFactory)
{
_dbFactory = dbFactory;
}
public async Task<DashboardPageState> LoadAsync()
{
await using var db = await _dbFactory.CreateDbContextAsync();
var sites = await db.Sites.Include(s => s.HanaServer).Where(s => s.IsActive).ToListAsync();
var sourceSystems = await db.SourceSystemDefinitions.AsNoTracking().ToListAsync();
var logs = await db.ExportLogs
.GroupBy(l => l.SiteId)
.Select(g => g.OrderByDescending(l => l.Timestamp).First())
.ToListAsync();
var appLogs = await db.AppEventLogs
.Where(l => l.SiteId != null)
.OrderByDescending(l => l.Timestamp)
.Take(1000)
.ToListAsync();
var latestAppLogsBySite = appLogs
.GroupBy(l => l.SiteId!.Value)
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.Timestamp).First());
var rows = sites.Select(s =>
{
var log = logs.FirstOrDefault(l => l.SiteId == s.Id);
latestAppLogsBySite.TryGetValue(s.Id, out var appLog);
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, s.SourceSystem, StringComparison.OrdinalIgnoreCase));
return new DashboardRow
{
SiteId = s.Id,
Land = s.Land,
DataBasis = ResolveDataBasis(s, sourceSystem),
TSC = s.TSC,
Schema = s.Schema,
ServerName = string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase)
? ResolveDashboardSapServiceUrl(s, sourceSystems)
: s.HanaServer?.Name ?? string.Empty,
LastStatus = log?.Status ?? string.Empty,
RowCount = log?.RowCount ?? 0,
LastRun = log?.Timestamp,
DurationSeconds = log?.DurationSeconds ?? 0,
ErrorMessage = log?.ErrorMessage ?? string.Empty,
FilePath = log?.FilePath ?? string.Empty,
LiveMessage = appLog is null ? string.Empty : $"{appLog.Category}: {appLog.Message}",
LiveDetails = appLog?.Details ?? string.Empty
};
}).ToList();
var consolidatedRows = BuildConsolidatedRows(await db.ExportSettings.FirstOrDefaultAsync() ?? new());
var latestSuccessfulSiteRun = logs
.Where(log => log.Status == "OK")
.Select(log => (DateTime?)log.Timestamp)
.OrderByDescending(timestamp => timestamp)
.FirstOrDefault();
var latestConsolidatedRun = consolidatedRows
.Select(row => row.LastModified)
.OrderByDescending(timestamp => timestamp)
.FirstOrDefault();
return new DashboardPageState
{
DashboardRows = rows,
ConsolidatedRows = consolidatedRows,
ReadinessWarnings = BuildReadinessWarnings(sites, sourceSystems),
IsConsolidatedStale = latestSuccessfulSiteRun.HasValue &&
(!latestConsolidatedRun.HasValue || latestSuccessfulSiteRun.Value > latestConsolidatedRun.Value),
LatestSuccessfulSiteRun = latestSuccessfulSiteRun,
LatestConsolidatedRun = latestConsolidatedRun
};
}
private static List<string> BuildReadinessWarnings(List<Site> activeSites, List<SourceSystemDefinition> sourceSystems)
{
var warnings = new List<string>();
foreach (var site in activeSites.OrderBy(x => x.Land).ThenBy(x => x.TSC))
{
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase));
if (!string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
continue;
if (string.IsNullOrWhiteSpace(site.ManualImportFilePath))
warnings.Add($"{site.Land} / {site.TSC}: manuelle Excel-/CSV-Datei fehlt.");
}
return warnings;
}
private static string ResolveDashboardSapServiceUrl(Site site, List<SourceSystemDefinition> sourceSystems)
{
if (!string.IsNullOrWhiteSpace(site.SapServiceUrl))
return site.SapServiceUrl;
var sourceSystem = sourceSystems.FirstOrDefault(x => string.Equals(x.Code, site.SourceSystem, StringComparison.OrdinalIgnoreCase));
return string.IsNullOrWhiteSpace(sourceSystem?.CentralServiceUrl) ? "SAP Gateway" : sourceSystem.CentralServiceUrl;
}
private static string ResolveDataBasis(Site site, SourceSystemDefinition? sourceSystem)
{
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.ManualExcel, StringComparison.OrdinalIgnoreCase))
{
var path = site.ManualImportFilePath ?? string.Empty;
var extension = Path.GetExtension(path).TrimStart('.').ToUpperInvariant();
if (extension is "CSV")
return "CSV-Datei";
if (extension is "XLS" or "XLSX" or "XLSM")
return "Excel-Datei";
if (!string.IsNullOrWhiteSpace(path))
return "Excel/CSV-Datei";
return "Manuelle Datei";
}
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.SapGateway, StringComparison.OrdinalIgnoreCase))
return "SAP Service";
if (string.Equals(sourceSystem?.ConnectionKind, SourceSystemConnectionKinds.Hana, StringComparison.OrdinalIgnoreCase))
return "Server";
return string.IsNullOrWhiteSpace(site.SourceSystem) ? "-" : site.SourceSystem;
}
private static List<ConsolidatedDashboardRow> BuildConsolidatedRows(ExportSettings settings)
{
var outputDirectory = ResolveConsolidatedOutputDirectory(settings);
if (!Directory.Exists(outputDirectory))
return [];
return Directory.GetFiles(outputDirectory, "Sales_All_*.xlsx")
.Select(path => new FileInfo(path))
.OrderByDescending(file => file.LastWriteTime)
.Take(1)
.Select(file => new ConsolidatedDashboardRow
{
Label = "Konsolidierter Export",
FilePath = file.FullName,
DisplayPath = file.FullName,
LastModified = file.LastWriteTime
})
.ToList();
}
private static string ResolveConsolidatedOutputDirectory(ExportSettings settings)
{
if (!string.IsNullOrWhiteSpace(settings.LocalConsolidatedExportFolder))
return settings.LocalConsolidatedExportFolder.Trim();
if (!string.IsNullOrWhiteSpace(settings.LocalSiteExportFolder))
return settings.LocalSiteExportFolder.Trim();
return Path.Combine(AppContext.BaseDirectory, "output");
}
}
public sealed class DashboardPageState
{
public List<DashboardRow> DashboardRows { get; set; } = [];
public List<ConsolidatedDashboardRow> ConsolidatedRows { get; set; } = [];
public List<string> ReadinessWarnings { get; set; } = [];
public bool IsConsolidatedStale { get; set; }
public DateTime? LatestSuccessfulSiteRun { get; set; }
public DateTime? LatestConsolidatedRun { get; set; }
}
public sealed class DashboardRow
{
public int SiteId { get; set; }
public string Land { get; set; } = string.Empty;
public string DataBasis { get; set; } = string.Empty;
public string TSC { get; set; } = string.Empty;
public string Schema { get; set; } = string.Empty;
public string ServerName { get; set; } = string.Empty;
public string LastStatus { get; set; } = string.Empty;
public int RowCount { get; set; }
public DateTime? LastRun { get; set; }
public double DurationSeconds { get; set; }
public string ErrorMessage { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string LiveMessage { get; set; } = string.Empty;
public string LiveDetails { get; set; } = string.Empty;
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
}
public sealed class ConsolidatedDashboardRow
{
public string Label { get; set; } = string.Empty;
public string FilePath { get; set; } = string.Empty;
public string DisplayPath { get; set; } = string.Empty;
public DateTime? LastModified { get; set; }
public bool HasOpenableFile => !string.IsNullOrWhiteSpace(FilePath) && File.Exists(FilePath);
}
@@ -0,0 +1,27 @@
using TrafagSalesExporter.Models;
namespace TrafagSalesExporter.Services.DataSources;
public sealed class DataSourceAdapterResolver : IDataSourceAdapterResolver
{
private readonly Dictionary<string, IDataSourceAdapter> _adapters;
public DataSourceAdapterResolver(IEnumerable<IDataSourceAdapter> adapters)
{
_adapters = adapters.ToDictionary(
a => a.ConnectionKind,
StringComparer.OrdinalIgnoreCase);
}
public IDataSourceAdapter Resolve(string connectionKind)
{
if (string.IsNullOrWhiteSpace(connectionKind))
connectionKind = SourceSystemConnectionKinds.Hana;
if (_adapters.TryGetValue(connectionKind, out var adapter))
return adapter;
throw new InvalidOperationException(
$"Kein DataSourceAdapter fuer ConnectionKind '{connectionKind}' registriert.");
}
}

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