Initial commit: JDE Scoping Tool migration project

Set up repository with legacy .NET Framework 4.8 source (OLD/),
new .NET 10 Blazor solution (NEW/), OpenSpec specifications,
documentation, and project configuration.
This commit is contained in:
Joseph Doherty
2026-01-02 07:43:29 -05:00
commit 26ff8d9b4f
1761 changed files with 596509 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
---
name: OpenSpec: Apply
description: Implement an approved OpenSpec change and keep tasks in sync.
category: OpenSpec
tags: [openspec, apply]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
Track these steps as TODOs and complete them one by one.
1. Read `changes/<id>/proposal.md`, `design.md` (if present), and `tasks.md` to confirm scope and acceptance criteria.
2. Work through tasks sequentially, keeping edits minimal and focused on the requested change.
3. Confirm completion before updating statuses—make sure every item in `tasks.md` is finished.
4. Update the checklist after all work is done so each task is marked `- [x]` and reflects reality.
5. Reference `openspec list` or `openspec show <item>` when additional context is required.
**Reference**
- Use `openspec show <id> --json --deltas-only` if you need additional context from the proposal while implementing.
<!-- OPENSPEC:END -->
+27
View File
@@ -0,0 +1,27 @@
---
name: OpenSpec: Archive
description: Archive a deployed OpenSpec change and update specs.
category: OpenSpec
tags: [openspec, archive]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
**Steps**
1. Determine the change ID to archive:
- If this prompt already includes a specific change ID (for example inside a `<ChangeId>` block populated by slash-command arguments), use that value after trimming whitespace.
- If the conversation references a change loosely (for example by title or summary), run `openspec list` to surface likely IDs, share the relevant candidates, and confirm which one the user intends.
- Otherwise, review the conversation, run `openspec list`, and ask the user which change to archive; wait for a confirmed change ID before proceeding.
- If you still cannot identify a single change ID, stop and tell the user you cannot archive anything yet.
2. Validate the change ID by running `openspec list` (or `openspec show <id>`) and stop if the change is missing, already archived, or otherwise not ready to archive.
3. Run `openspec archive <id> --yes` so the CLI moves the change and applies spec updates without prompts (use `--skip-specs` only for tooling-only work).
4. Review the command output to confirm the target specs were updated and the change landed in `changes/archive/`.
5. Validate with `openspec validate --strict` and inspect with `openspec show <id>` if anything looks off.
**Reference**
- Use `openspec list` to confirm change IDs before archiving.
- Inspect refreshed specs with `openspec list --specs` and address any validation issues before handing off.
<!-- OPENSPEC:END -->
+28
View File
@@ -0,0 +1,28 @@
---
name: OpenSpec: Proposal
description: Scaffold a new OpenSpec change and validate strictly.
category: OpenSpec
tags: [openspec, change]
---
<!-- OPENSPEC:START -->
**Guardrails**
- Favor straightforward, minimal implementations first and add complexity only when it is requested or clearly required.
- Keep changes tightly scoped to the requested outcome.
- Refer to `openspec/AGENTS.md` (located inside the `openspec/` directory—run `ls openspec` or `openspec update` if you don't see it) if you need additional OpenSpec conventions or clarifications.
- Identify any vague or ambiguous details and ask the necessary follow-up questions before editing files.
- Do not write any code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, and spec deltas). Implementation happens in the apply stage after approval.
**Steps**
1. Review `openspec/project.md`, run `openspec list` and `openspec list --specs`, and inspect related code or docs (e.g., via `rg`/`ls`) to ground the proposal in current behaviour; note any gaps that require clarification.
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, and `design.md` (when needed) under `openspec/changes/<id>/`.
3. Map the change into concrete capabilities or requirements, breaking multi-scope efforts into distinct spec deltas with clear relationships and sequencing.
4. Capture architectural reasoning in `design.md` when the solution spans multiple systems, introduces new patterns, or demands trade-off discussion before committing to specs.
5. Draft spec deltas in `changes/<id>/specs/<capability>/spec.md` (one folder per capability) using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement and cross-reference related capabilities when relevant.
6. Draft `tasks.md` as an ordered list of small, verifiable work items that deliver user-visible progress, include validation (tests, tooling), and highlight dependencies or parallelizable work.
7. Validate with `openspec validate <id> --strict` and resolve every issue before sharing the proposal.
**Reference**
- Use `openspec show <id> --json --deltas-only` or `openspec show <spec> --type spec` to inspect details when validation fails.
- Search existing requirements with `rg -n "Requirement:|Scenario:" openspec/specs` before writing new ones.
- Explore the codebase with `rg <keyword>`, `ls`, or direct file reads so proposals align with current implementation realities.
<!-- OPENSPEC:END -->
+426
View File
@@ -0,0 +1,426 @@
# .NET / C# / Visual Studio .gitignore
# https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical dependencies
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Coverage directory
CoverageResults/
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU Debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (Experimental) extension
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - autoass embly weaving
FodyWeavers.xsd
# VS Code files for those who prefer it
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
.idea/
*.sln.iml
# macOS
.DS_Store
.AppleDouble
.LSOverride
._*
# Windows
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Project-specific ignores
# Local development database credentials
db_info.md
# Sensitive configuration files
appsettings.*.json
!appsettings.json
!appsettings.Development.json.template
# Environment files
.env
.env.*
!.env.example
# Secrets
secrets.json
*.secrets.json
+153
View File
@@ -0,0 +1,153 @@
# AGENTS.md
This file provides guidance to coding agents working in this repository.
## Project Overview
This is a **migration project** to convert a legacy .NET Framework 4.8 application
("LotFinder" / JDE Scoping Tool) to .NET 10.
### Folder Structure
```
JdeScopingTool/
├── OLD/ # Legacy .NET Framework 4.8 source code (read-only reference)
├── NEW/ # New .NET 10 solution (build here)
├── SPECS/ # OpenSpec specifications and design documents
├── PLANS/ # Design plans, implementation plans, and task lists
└── DOCUMENTATION/ # Project documentation
```
### Legacy System Purpose
- Caches data from JDE (JD Edwards - Oracle) and CMS (Sybase) enterprise systems into SQL Server
- Allows users to create complex searches across work orders, lots, items, operators, and work centers
- Processes searches asynchronously and exports results to Excel
- Provides real-time status updates via SignalR
### Migration Target
- **Single .NET 10 service** (combines the legacy web app + worker service)
- **Blazor WebAssembly UI** (replaces ASP.NET MVC + Razor views)
## Legacy Architecture (OLD/)
### Solution Structure
```
OLD/LotFinder.sln
├── WebInterface/ # ASP.NET MVC 5 web application
├── WorkerService/ # Windows Service (Topshelf) - search processor + data refresher
├── DataModel/ # Shared library (Commons.csproj) - models, DB access, helpers
├── Database/ # SQL Server database project (.sqlproj)
└── TestApp/ # Console test application
```
### Key Components
**WebInterface** - ASP.NET MVC with SignalR
- Controllers: `SearchController` (main), `LookupController` (autocomplete APIs), `AccountController` (LDAP auth)
- SignalR Hub: `StatusHub` for real-time search status updates
- Uses Forms auth with LDAP backend
**WorkerService** - Background processor (Topshelf service)
- `WorkProcessor`: Main work loop - checks for pending data updates, then processes queued searches
- `UpdateProcessor`: Syncs data from JDE/CMS to local SQL Server cache (mass/daily/hourly schedules)
- `ExcelWriter`: Generates Excel output using EPPlus
- Data source configs in `dsconfig/*.json` files
**DataModel (Commons)** - Shared library
- `Process/LotFinderDB*.cs`: SQL Server cache database access (Dapper)
- `Process/JDE*.cs`: Oracle JDE queries
- `Process/CMS*.cs`: Sybase CMS queries
- `Models/`: Domain models (WorkOrder, Lot, Item, Search, etc.)
- `ViewModels/`: DTOs for API responses
- `Config.cs`: Connection strings (encrypted passwords)
### Data Flow
1. User submits search via web UI -> stored in `Search` table with status=Queued
2. WorkerService polls for queued searches
3. Service executes search against local cache, generates Excel
4. Results stored in `Search.Results` (VARBINARY), status updated
5. SignalR pushes status updates to connected clients
### Key Database Tables
- `Search`: User search requests and results
- `DataUpdate`: Tracks cache refresh timestamps per table
- `WorkOrder_Curr/Hist`, `LotUsage_Curr/Hist`, etc.: Cached JDE data (current + historical)
- `Lot`, `Item`, `ProfitCenter`, `WorkCenter`, `JdeUser`: Reference data
### External Dependencies
- **DDTek.Oracle**: Progress DataDirect Oracle driver
- **Sybase.AdoNet4.AseClient**: Sybase database driver
- **Dapper**: Micro-ORM for SQL queries
- **EPPlus**: Excel generation
- **Topshelf**: Windows service hosting
- **SignalR**: Real-time web communication
## Migration Considerations
### What to Preserve
- Search criteria model (`SearchCriteria`, `SearchCriteriaViewModel`)
- JDE/CMS query logic (files in `DataModel/Process/JDE*.cs`, `CMS*.cs`)
- Excel generation logic (`WorkerService/Process/ExcelWriter.cs`)
- Data sync scheduling patterns (mass/daily/hourly from dsconfig)
- Query templates (`WorkerService/Templates/QueryTemplate.cs`)
### What Changes
- Topshelf -> .NET hosted service with BackgroundService
- SignalR (legacy) -> modern ASP.NET Core SignalR
- ASP.NET MVC -> Blazor WebAssembly
- Forms auth -> ASP.NET Core Identity with LDAP
- `System.Data.SqlClient` -> `Microsoft.Data.SqlClient`
### Authentication Pattern
Legacy uses LDAP authentication with group membership check. Config values:
- `LDAPUrl`: Directory server URL(s)
- `LDAPGroup`: Required group membership
## OpenSpec Workflow
This project uses OpenSpec for change management. See `.claude/commands/openspec/`
for proposal, apply, and archive workflows. Key principles:
- Favor minimal implementations
- Keep changes tightly scoped
- Create design docs before implementation
## Documentation Guidelines
When writing or updating documentation, consult the `DOCUMENTATION/Instructions/` folder:
- `DOCUMENTATION/Instructions/GeneratingDocs.md` - How to create new documentation
- `DOCUMENTATION/Instructions/UpdatingDocs.md` - When and how to update existing docs
- `DOCUMENTATION/Instructions/StyleGuide.md` - Writing conventions and formatting rules
- `DOCUMENTATION/Instructions/ComponentMap.md` - Maps source code to documentation folders
Key principles:
- Read source code before documenting
- Use code snippets from actual codebase (never invent examples)
- Follow the component mapping for file organization
- Update documentation when code changes
## Plans and Task Lists
Store design plans, implementation plans, and task list files in the `PLANS/` folder:
- **Design plans** - Architecture decisions, component designs
- **Implementation plans** - Step-by-step implementation guides
- **Task lists** - Tracked work items and checklists
This keeps planning artifacts organized and separate from specifications (SPECS/)
and documentation (DOCUMENTATION/).
## Local Development Database
The `db_info.md` file contains connection information for the local SQL Server
development database:
- Docker container details (`scopingtool-sqlserver` on port 1434)
- SA credentials (admin access)
- Application credentials (`scopingapp` user with read/write/execute/truncate permissions)
- Connection strings for .NET configuration
Note: This file contains plain-text credentials for local development only. Do
not commit to source control or use in production.
## Sync Checklist
When `CLAUDE.md` changes, review `AGENTS.md` and update any modified sections so
both files stay aligned.
+193
View File
@@ -0,0 +1,193 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
This is a **migration project** to convert a legacy .NET Framework 4.8 application ("LotFinder" / JDE Scoping Tool) to .NET 10.
### Folder Structure
```
JdeScopingTool/
├── OLD/ # Legacy .NET Framework 4.8 source code (read-only reference)
├── NEW/ # New .NET 10 solution (build here)
├── openspec/ # OpenSpec specifications and design documents
├── PLANS/ # Design plans, implementation plans, and task lists
└── DOCUMENTATION/ # Project documentation
```
### Legacy System Purpose
A manufacturing/ERP search tool that:
- Caches data from JDE (JD Edwards - Oracle) and CMS (Sybase) enterprise systems into SQL Server
- Allows users to create complex searches across work orders, lots, items, operators, and work centers
- Processes searches asynchronously and exports results to Excel
- Provides real-time status updates via SignalR
### Migration Target
- **Single .NET 10 service** (combines the legacy web app + worker service)
- **Blazor WebAssembly UI** (replaces ASP.NET MVC + Razor views)
## Legacy Architecture (OLD/)
### Solution Structure
```
OLD/LotFinder.sln
├── WebInterface/ # ASP.NET MVC 5 web application
├── WorkerService/ # Windows Service (Topshelf) - search processor + data refresher
├── DataModel/ # Shared library (Commons.csproj) - models, DB access, helpers
├── Database/ # SQL Server database project (.sqlproj)
└── TestApp/ # Console test application
```
### Key Components
**WebInterface** - ASP.NET MVC with SignalR
- Controllers: `SearchController` (main), `LookupController` (autocomplete APIs), `AccountController` (LDAP auth)
- SignalR Hub: `StatusHub` for real-time search status updates
- Uses Forms auth with LDAP backend
**WorkerService** - Background processor (Topshelf service)
- `WorkProcessor`: Main work loop - checks for pending data updates, then processes queued searches
- `UpdateProcessor`: Syncs data from JDE/CMS to local SQL Server cache (mass/daily/hourly schedules)
- `ExcelWriter`: Generates Excel output using EPPlus
- Data source configs in `dsconfig/*.json` files
**DataModel (Commons)** - Shared library
- `Process/LotFinderDB*.cs`: SQL Server cache database access (Dapper)
- `Process/JDE*.cs`: Oracle JDE queries
- `Process/CMS*.cs`: Sybase CMS queries
- `Models/`: Domain models (WorkOrder, Lot, Item, Search, etc.)
- `ViewModels/`: DTOs for API responses
- `Config.cs`: Connection strings (encrypted passwords)
### Data Flow
1. User submits search via web UI -> stored in `Search` table with status=Queued
2. WorkerService polls for queued searches
3. Service executes search against local cache, generates Excel
4. Results stored in `Search.Results` (VARBINARY), status updated
5. SignalR pushes status updates to connected clients
### Key Database Tables
- `Search`: User search requests and results
- `DataUpdate`: Tracks cache refresh timestamps per table
- `WorkOrder_Curr/Hist`, `LotUsage_Curr/Hist`, etc.: Cached JDE data (current + historical)
- `Lot`, `Item`, `ProfitCenter`, `WorkCenter`, `JdeUser`: Reference data
### External Dependencies
- **DDTek.Oracle**: Progress DataDirect Oracle driver
- **Sybase.AdoNet4.AseClient**: Sybase database driver
- **Dapper**: Micro-ORM for SQL queries
- **EPPlus**: Excel generation
- **Topshelf**: Windows service hosting
- **SignalR**: Real-time web communication
## Migration Considerations
### What to Preserve
- Search criteria model (`SearchCriteria`, `SearchCriteriaViewModel`)
- JDE/CMS query logic (files in `DataModel/Process/JDE*.cs`, `CMS*.cs`)
- Excel generation logic (`WorkerService/Process/ExcelWriter.cs`)
- Data sync scheduling patterns (mass/daily/hourly from dsconfig)
- Query templates (`WorkerService/Templates/QueryTemplate.cs`)
### What Changes
- Topshelf -> .NET hosted service with BackgroundService
- SignalR (legacy) -> modern ASP.NET Core SignalR
- ASP.NET MVC -> Blazor WebAssembly
- Forms auth -> ASP.NET Core Identity with LDAP
- `System.Data.SqlClient` -> `Microsoft.Data.SqlClient`
### Authentication Pattern
Legacy uses LDAP authentication with group membership check. Config values:
- `LDAPUrl`: Directory server URL(s)
- `LDAPGroup`: Required group membership
## OpenSpec Workflow
This project uses OpenSpec for change management. Specifications are stored in the `openspec/` directory using the standard OpenSpec layout.
### Directory Structure
```
openspec/
├── project.md # Project overview and spec organization
├── AGENTS.md # Agent guidelines and templates
├── specs/ # Specification files by functional area
│ ├── domain-models/
│ ├── database-schema/
│ ├── data-access/
│ ├── data-sync/
│ ├── search-processing/
│ ├── excel-export/
│ └── web-api-auth/
└── changes/ # Change proposals
```
### Spec Formatting Rules
Specs MUST follow OpenSpec format for validation to pass:
1. **Purpose section**: Use `## Purpose` (not `## Overview`)
2. **Requirements**: Use SHALL/MUST language
```markdown
### Requirement: Search entity
The system SHALL store user search requests containing filter criteria.
```
3. **Scenarios**: Use WHEN/THEN format (not Given/When/Then)
```markdown
#### Scenario: Submit new search
- **WHEN** a user creates a search with name and criteria
- **THEN** a new Search record is created with Status = New
```
### Validation
Validate specs with the OpenSpec CLI:
```bash
openspec validate --specs # Validate all specs
openspec list --specs # List specs with requirement counts
```
### Change Workflows
See `.claude/commands/openspec/` for proposal, apply, and archive workflows. Key principles:
- Favor minimal implementations
- Keep changes tightly scoped
- Create design docs before implementation
## Documentation Guidelines
When writing or updating documentation, consult the `DOCUMENTATION/Instructions/` folder:
- **[GeneratingDocs.md](DOCUMENTATION/Instructions/GeneratingDocs.md)** - How to create new documentation
- **[UpdatingDocs.md](DOCUMENTATION/Instructions/UpdatingDocs.md)** - When and how to update existing docs
- **[StyleGuide.md](DOCUMENTATION/Instructions/StyleGuide.md)** - Writing conventions and formatting rules
- **[ComponentMap.md](DOCUMENTATION/Instructions/ComponentMap.md)** - Maps source code to documentation folders
Key principles:
- Read source code before documenting
- Use code snippets from actual codebase (never invent examples)
- Follow the component mapping for file organization
- Update documentation when code changes
## Plans and Task Lists
Store design plans, implementation plans, and task list files in the `PLANS/` folder:
- **Design plans** - Architecture decisions, component designs
- **Implementation plans** - Step-by-step implementation guides
- **Task lists** - Tracked work items and checklists
This keeps planning artifacts organized and separate from specifications (SPECS/) and documentation (DOCUMENTATION/).
## Local Development Database
The **[db_info.md](db_info.md)** file contains connection information for the local SQL Server development database:
- Docker container details (`scopingtool-sqlserver` on port 1434)
- SA credentials (admin access)
- Application credentials (`scopingapp` user with read/write/execute/truncate permissions)
- Connection strings for .NET configuration
**Note:** This file contains plain-text credentials for local development only. Do not commit to source control or use in production.
@@ -0,0 +1,94 @@
# Blazor Client
The `JdeScoping.Client` project is a Blazor WebAssembly application using Radzen Blazor components.
## Project Structure
```
JdeScoping.Client/
├── Program.cs # WASM entry, configure HttpClient + SignalR
├── Pages/
│ ├── Login.razor # LDAP login form
│ ├── Search.razor # Main search criteria page
│ ├── Results.razor # Search history + download
│ └── Admin.razor # Data sync status (optional)
├── Components/
│ ├── SearchCriteriaForm.razor # Complex search form
│ ├── SearchStatusCard.razor # Real-time status display
│ └── LookupDropdown.razor # Autocomplete wrapper
├── Services/
│ ├── SearchApiClient.cs # HTTP calls to SearchController
│ ├── LookupApiClient.cs # HTTP calls to LookupController
│ ├── AuthApiClient.cs # Login/logout
│ └── StatusHubClient.cs # SignalR connection
└── wwwroot/
└── css/ # Custom styles if needed
```
## Radzen Components
Radzen Blazor replaces the legacy Kendo UI JS components. The core library is free (MIT license).
| Component | Usage |
|-----------|-------|
| `RadzenDataGrid` | Search results and history tables |
| `RadzenDropDown` | Work center, operator selection |
| `RadzenAutoComplete` | Item number lookup with search |
| `RadzenDatePicker` | Date range selection |
| `RadzenButton` | Form actions |
| `RadzenCard` | Layout containers |
| `RadzenNotification` | Toast messages |
| `RadzenProgressBar` | Search progress indication |
## Program.cs Configuration
```csharp
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
// HTTP client for API calls
builder.Services.AddScoped(sp =>
new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
// API clients
builder.Services.AddScoped<SearchApiClient>();
builder.Services.AddScoped<LookupApiClient>();
builder.Services.AddScoped<AuthApiClient>();
builder.Services.AddScoped<StatusHubClient>();
await builder.Build().RunAsync();
```
## SignalR Client
The `StatusHubClient` connects on login and subscribes to status updates:
```csharp
public class StatusHubClient : IAsyncDisposable
{
private HubConnection _connection;
public event Action<SearchStatusUpdate> OnStatusChanged;
public async Task ConnectAsync(string baseUrl)
{
_connection = new HubConnectionBuilder()
.WithUrl($"{baseUrl}/hubs/status")
.WithAutomaticReconnect()
.Build();
_connection.On<SearchStatusUpdate>("StatusChanged", update =>
{
OnStatusChanged?.Invoke(update);
});
await _connection.StartAsync();
}
}
```
## Related Documentation
- [Solution Structure](./SolutionStructure.md)
- [Data Flow](./DataFlow.md)
- [Dependencies](./Dependencies.md)
+225
View File
@@ -0,0 +1,225 @@
# Configuration
The application uses standard ASP.NET Core configuration with `appsettings.json` and environment variables for sensitive values.
## appsettings.json Structure
```json
{
"ConnectionStrings": {
"SqlServer": "Server=localhost;Database=LotFinder;Integrated Security=true;TrustServerCertificate=true",
"JdeOracle": "Data Source=jde-server:1521/JDEPROD;User Id=${JDE_USER};Password=${JDE_PASSWORD}",
"CmsOracle": "Data Source=cms-server:1521/CMSPROD;User Id=${CMS_USER};Password=${CMS_PASSWORD}"
},
"DataSource": {
"UseFileDataSource": false,
"FileDirectory": "DevData"
},
"Auth": {
"UseFakeAuth": false
},
"Ldap": {
"Url": "LDAP://your-domain.com",
"BaseDn": "DC=your-domain,DC=com",
"RequiredGroup": "CN=LotFinderUsers,OU=Groups,DC=your-domain,DC=com"
},
"DataSync": {
"MassSchedule": "0 2 * * 0",
"DailySchedule": "0 3 * * *",
"HourlySchedule": "0 * * * *",
"BatchSize": 10000
},
"Search": {
"MaxResultRows": 100000,
"TimeoutSeconds": 300,
"MaxConcurrentSearches": 5
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
```
## Environment Variables
Sensitive values are provided via environment variables at runtime:
| Variable | Purpose |
|----------|---------|
| `JDE_USER` | JDE Oracle username |
| `JDE_PASSWORD` | JDE Oracle password |
| `CMS_USER` | CMS Oracle username |
| `CMS_PASSWORD` | CMS Oracle password |
For local development, use User Secrets or a `.env` file (not committed to source control).
## Strongly-Typed Options
Configuration sections are bound to strongly-typed options classes:
```csharp
public class LdapOptions
{
public string Url { get; set; }
public string BaseDn { get; set; }
public string RequiredGroup { get; set; }
}
public class DataSyncOptions
{
public string MassSchedule { get; set; }
public string DailySchedule { get; set; }
public string HourlySchedule { get; set; }
public int BatchSize { get; set; } = 10000;
}
public class SearchOptions
{
public int MaxResultRows { get; set; } = 100000;
public int TimeoutSeconds { get; set; } = 300;
public int MaxConcurrentSearches { get; set; } = 5;
}
public class DataSourceOptions
{
public bool UseFileDataSource { get; set; } = false;
public string FileDirectory { get; set; } = "DevData";
}
public class AuthOptions
{
public bool UseFakeAuth { get; set; } = false;
}
```
Registered in `Program.cs`:
```csharp
builder.Services.Configure<LdapOptions>(builder.Configuration.GetSection("Ldap"));
builder.Services.Configure<DataSyncOptions>(builder.Configuration.GetSection("DataSync"));
builder.Services.Configure<SearchOptions>(builder.Configuration.GetSection("Search"));
builder.Services.Configure<DataSourceOptions>(builder.Configuration.GetSection("DataSource"));
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
```
## Data Source Configuration
The JDE and CMS data sources support two implementations:
| Implementation | Use Case |
|----------------|----------|
| Oracle (`JdeOracleDataSource`, `CmsOracleDataSource`) | Production - connects to Oracle databases |
| File (`JdeFileDataSource`, `CmsFileDataSource`) | Development - reads from exported JSON/CSV files |
### Development Setup
For development without Oracle access, set `UseFileDataSource: true` in `appsettings.Development.json`:
```json
{
"DataSource": {
"UseFileDataSource": true,
"FileDirectory": "DevData"
}
}
```
Place data export files in the `DevData` directory:
```
DevData/
├── workorders.json
├── lots.json
├── items.json
└── lotusage.json
```
### Registration Logic
```csharp
var dataSourceOptions = builder.Configuration
.GetSection("DataSource").Get<DataSourceOptions>();
if (dataSourceOptions?.UseFileDataSource == true || builder.Environment.IsDevelopment())
{
builder.Services.AddScoped<IJdeDataSource, JdeFileDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsFileDataSource>();
}
else
{
builder.Services.AddScoped<IJdeDataSource, JdeOracleDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsOracleDataSource>();
}
```
## Authentication Configuration
Authentication supports two implementations:
| Implementation | Use Case |
|----------------|----------|
| LDAP (`LdapAuthService`) | Production - authenticates against real LDAP server |
| Fake (`FakeAuthService`) | Development - accepts any non-empty credentials |
### Development Setup
For development without LDAP access, set `UseFakeAuth: true` in `appsettings.Development.json`:
```json
{
"Auth": {
"UseFakeAuth": true
}
}
```
The fake auth service:
- Accepts any non-empty username/password combination
- Returns the username as the display name
- Always returns `true` for group membership checks
### Registration Logic
```csharp
var authOptions = builder.Configuration
.GetSection("Auth").Get<AuthOptions>();
if (authOptions?.UseFakeAuth == true)
{
builder.Services.AddScoped<IAuthService, FakeAuthService>();
}
else
{
builder.Services.AddScoped<IAuthService, LdapAuthService>();
}
```
## Cron Expressions
Data sync schedules use cron expressions, parsed by the Cronos library:
| Expression | Meaning |
|------------|---------|
| `0 2 * * 0` | Sunday at 2:00 AM |
| `0 3 * * *` | Daily at 3:00 AM |
| `0 * * * *` | Every hour on the hour |
## Windows Service Installation
When installing as a Windows Service, environment variables can be set:
```powershell
# Create service
sc.exe create JdeScopingTool binPath= "C:\Services\JdeScoping\JdeScoping.Host.exe"
# Set environment variables for the service
$envVars = "JDE_USER=myuser`0JDE_PASSWORD=mypass`0CMS_USER=cmsuser`0CMS_PASSWORD=cmspass"
Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\JdeScopingTool" -Name "Environment" -Value $envVars
```
## Related Documentation
- [Host Project](./HostProject.md)
- [Data Flow](./DataFlow.md)
+277
View File
@@ -0,0 +1,277 @@
# Core Project
The `JdeScoping.Core` project contains business logic and data access. It has no ASP.NET dependencies and is fully testable.
## Project Structure
```
JdeScoping.Core/
├── Models/
│ ├── Search.cs # Search request + status
│ ├── SearchCriteria.cs # Search parameters
│ ├── SearchStatus.cs # Status enum
│ ├── WorkOrder.cs # Work order entity
│ ├── Lot.cs # Lot entity
│ ├── Item.cs # Item entity
│ └── DataUpdate.cs # Sync tracking
├── Interfaces/
│ ├── ISearchRepository.cs # Local SQL Server cache
│ ├── IJdeDataSource.cs # JDE data access (interface)
│ ├── ICmsDataSource.cs # CMS data access (interface)
│ ├── ISearchService.cs # Search execution
│ └── IExcelExportService.cs # Excel generation
├── Repositories/
│ ├── SearchRepository.cs # Dapper against SQL Server
│ ├── Jde/
│ │ ├── JdeOracleDataSource.cs # Production: Oracle connection
│ │ └── JdeFileDataSource.cs # Development: File-based data
│ └── Cms/
│ ├── CmsOracleDataSource.cs # Production: Oracle connection
│ └── CmsFileDataSource.cs # Development: File-based data
├── Services/
│ ├── SearchService.cs # Search execution logic
│ ├── ExcelExportService.cs # ClosedXML generation
│ └── DataSyncOrchestrator.cs # Sync orchestration logic
└── Auth/
├── IAuthService.cs # Authentication interface
├── LdapAuthService.cs # Production: Real LDAP server
└── FakeAuthService.cs # Development: Accepts any credentials
```
## Repository Pattern
Repositories use Dapper for data access. Connections are created per-query and disposed after use.
```csharp
public class SearchRepository : ISearchRepository
{
private readonly string _connectionString;
public SearchRepository(IConfiguration config)
{
_connectionString = config.GetConnectionString("SqlServer");
}
public async Task<Search> GetByIdAsync(int id)
{
using var connection = new SqlConnection(_connectionString);
return await connection.QuerySingleOrDefaultAsync<Search>(
"SELECT * FROM Search WHERE Id = @Id", new { Id = id });
}
public async Task<int> CreateAsync(Search search)
{
using var connection = new SqlConnection(_connectionString);
return await connection.ExecuteScalarAsync<int>(
@"INSERT INTO Search (UserId, Criteria, Status, CreatedAt)
VALUES (@UserId, @Criteria, @Status, @CreatedAt);
SELECT SCOPE_IDENTITY();", search);
}
}
```
## Data Source Pattern (JDE/CMS)
JDE and CMS data access uses an interface with two implementations: production (Oracle) and development (file-based). This allows development without Oracle connectivity.
### Interface Definition
```csharp
public interface IJdeDataSource
{
Task<IEnumerable<WorkOrder>> GetWorkOrdersAsync(DateTime since);
Task<IEnumerable<Lot>> GetLotsAsync(DateTime since);
Task<IEnumerable<Item>> GetItemsAsync();
// ... other data retrieval methods
}
public interface ICmsDataSource
{
Task<IEnumerable<LotUsage>> GetLotUsageAsync(DateTime since);
// ... other CMS data methods
}
```
### Production Implementation (Oracle)
```csharp
public class JdeOracleDataSource : IJdeDataSource
{
private readonly string _connectionString;
public JdeOracleDataSource(IConfiguration config)
{
_connectionString = config.GetConnectionString("JdeOracle");
}
public async Task<IEnumerable<WorkOrder>> GetWorkOrdersAsync(DateTime since)
{
using var connection = new OracleConnection(_connectionString);
return await connection.QueryAsync<WorkOrder>(
"SELECT * FROM F4801 WHERE UPMJ >= :Since", new { Since = since });
}
}
```
### Development Implementation (File-based)
```csharp
public class JdeFileDataSource : IJdeDataSource
{
private readonly string _dataDirectory;
public JdeFileDataSource(IConfiguration config)
{
_dataDirectory = config["DataSource:FileDirectory"] ?? "DevData";
}
public async Task<IEnumerable<WorkOrder>> GetWorkOrdersAsync(DateTime since)
{
var filePath = Path.Combine(_dataDirectory, "workorders.json");
var json = await File.ReadAllTextAsync(filePath);
var allOrders = JsonSerializer.Deserialize<List<WorkOrder>>(json);
return allOrders.Where(wo => wo.UpdateDate >= since);
}
}
```
### Registration by Environment
```csharp
// In Program.cs
if (builder.Environment.IsDevelopment())
{
builder.Services.AddScoped<IJdeDataSource, JdeFileDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsFileDataSource>();
}
else
{
builder.Services.AddScoped<IJdeDataSource, JdeOracleDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsOracleDataSource>();
}
```
This pattern enables:
- Development without Oracle database access
- Testing with predictable data sets
- Easy switching between implementations via configuration
## Authentication Pattern
Authentication uses the same interface pattern with production and development implementations.
### Interface Definition
```csharp
public interface IAuthService
{
Task<AuthResult> AuthenticateAsync(string username, string password);
Task<bool> IsInGroupAsync(string username, string groupName);
}
public class AuthResult
{
public bool Success { get; set; }
public string DisplayName { get; set; }
public string Email { get; set; }
public string ErrorMessage { get; set; }
}
```
### Production Implementation (LDAP)
```csharp
public class LdapAuthService : IAuthService
{
private readonly LdapOptions _options;
public LdapAuthService(IOptions<LdapOptions> options)
{
_options = options.Value;
}
public async Task<AuthResult> AuthenticateAsync(string username, string password)
{
using var connection = new LdapConnection(_options.Url);
try
{
connection.Bind(new NetworkCredential(username, password));
// Retrieve user details from directory
return new AuthResult { Success = true, DisplayName = "..." };
}
catch (LdapException)
{
return new AuthResult { Success = false, ErrorMessage = "Invalid credentials" };
}
}
}
```
### Development Implementation (Fake)
```csharp
public class FakeAuthService : IAuthService
{
private readonly AuthOptions _options;
public FakeAuthService(IOptions<AuthOptions> options)
{
_options = options.Value;
}
public Task<AuthResult> AuthenticateAsync(string username, string password)
{
// Accept any non-empty credentials in development
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
return Task.FromResult(new AuthResult
{
Success = false,
ErrorMessage = "Username and password required"
});
}
return Task.FromResult(new AuthResult
{
Success = true,
DisplayName = username,
Email = $"{username}@dev.local"
});
}
public Task<bool> IsInGroupAsync(string username, string groupName)
{
// Always return true in development
return Task.FromResult(true);
}
}
```
### Registration by Configuration
```csharp
// In Program.cs
var authOptions = builder.Configuration.GetSection("Auth").Get<AuthOptions>();
if (authOptions?.UseFakeAuth == true)
{
builder.Services.AddScoped<IAuthService, FakeAuthService>();
}
else
{
builder.Services.AddScoped<IAuthService, LdapAuthService>();
}
```
## Porting Strategy
The legacy Dapper queries in `LotFinderDB*.cs`, `JDE*.cs`, and `CMS*.cs` port with minimal changes:
- Update namespaces (`System.Data.SqlClient` to `Microsoft.Data.SqlClient`)
- Adapt CMS queries from Sybase to Oracle syntax
- Use async/await consistently
## Related Documentation
- [Solution Structure](./SolutionStructure.md)
- [Data Flow](./DataFlow.md)
- [Testing](./Testing.md)
+84
View File
@@ -0,0 +1,84 @@
# Data Flow
The system has two primary data flows: search execution and data synchronization.
## Search Flow
The search flow mirrors the legacy pattern, modernized for ASP.NET Core:
```
1. User submits search via Blazor UI
└─> POST /api/search (SearchCriteria JSON)
2. SearchController validates and stores in SQL Server
└─> Search record created with Status = "Queued"
└─> Returns SearchId to client
3. Client connects to SignalR StatusHub
└─> Subscribes to updates for their SearchId
4. SearchProcessorService (BackgroundService) polls
└─> Finds queued searches
└─> Executes query against local cache
└─> Generates Excel via ClosedXML
└─> Stores result in Search.Results (VARBINARY)
└─> Updates Status = "Complete"
5. StatusHub pushes update to client
└─> Client shows "Complete" status
6. User clicks download
└─> GET /api/search/{id}/download
└─> Returns Excel file stream
```
## Search Status States
| Status | Description |
|--------|-------------|
| `Queued` | Search submitted, waiting for processing |
| `Processing` | Background service is executing the search |
| `Generating` | Query complete, generating Excel file |
| `Complete` | Excel ready for download |
| `Failed` | Error occurred during processing |
## Data Sync Flow
The `DataSyncService` runs on a schedule to keep the local SQL Server cache current:
```
DataSyncService runs on schedule:
├── Mass refresh: Full reload (weekly or manual trigger)
├── Daily refresh: Last 24-48 hours of changes
└── Hourly refresh: Incremental updates
Each sync:
1. Determine tables to sync based on schedule
2. Query JDE/CMS Oracle for changes since last sync
3. Bulk insert/update to SQL Server cache
4. Update DataUpdate table with timestamp
```
## Sync Schedules
| Schedule | Frequency | Scope |
|----------|-----------|-------|
| Mass | Weekly (Sunday 2 AM) or manual | Full reload of all cached tables |
| Daily | Daily (3 AM) | Changes from last 48 hours |
| Hourly | Every hour | Incremental changes since last sync |
The schedules are configured via cron expressions in `appsettings.json` and parsed using the Cronos library.
## Database Connections
| Database | Purpose | Driver |
|----------|---------|--------|
| SQL Server | Local cache, search storage | Microsoft.Data.SqlClient |
| JDE Oracle | Enterprise work order data | Oracle.ManagedDataAccess.Core |
| CMS Oracle | Enterprise CMS data (migrated from Sybase) | Oracle.ManagedDataAccess.Core |
## Related Documentation
- [Host Project](./HostProject.md)
- [Core Project](./CoreProject.md)
- [Configuration](./Configuration.md)
+202
View File
@@ -0,0 +1,202 @@
# Database
The application uses SQL Server for the local cache database. Schema is managed using DbUp, with versioned SQL scripts embedded in the application.
## DbUp Overview
DbUp is a .NET library for deploying changes to SQL Server databases. It tracks which scripts have been executed in a `SchemaVersions` table and runs new scripts in alphabetical order.
Key benefits:
- Schema defined as code (versioned SQL scripts)
- Automatic migration on startup
- Idempotent - safe to run multiple times
- Simple, well-tested library
## Project Structure
```
JdeScoping.Database/
├── JdeScoping.Database.csproj
├── DatabaseMigrator.cs # Entry point for migrations
└── Scripts/
├── 001_CreateSearchTable.sql
├── 002_CreateDataUpdateTable.sql
├── 003_CreateWorkOrderTables.sql
├── 004_CreateLotTables.sql
├── 005_CreateReferenceTables.sql
└── ...
```
## Script Naming Convention
Scripts are named with a numeric prefix for ordering:
```
NNN_DescriptiveName.sql
```
- `NNN`: Zero-padded number (001, 002, etc.)
- `DescriptiveName`: Brief description of what the script does
- Scripts run in alphabetical order (numeric prefix ensures correct order)
## DatabaseMigrator Implementation
```csharp
using DbUp;
using Microsoft.Extensions.Configuration;
namespace JdeScoping.Database;
public class DatabaseMigrator
{
private readonly string _connectionString;
public DatabaseMigrator(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("SqlServer")
?? throw new InvalidOperationException("SqlServer connection string not configured");
}
public DatabaseUpgradeResult Migrate()
{
EnsureDatabase.For.SqlDatabase(_connectionString);
var upgrader = DeployChanges.To
.SqlDatabase(_connectionString)
.WithScriptsEmbeddedInAssembly(typeof(DatabaseMigrator).Assembly)
.WithTransaction()
.LogToConsole()
.Build();
return upgrader.PerformUpgrade();
}
}
```
## Embedding Scripts as Resources
Scripts are embedded in the assembly by configuring the project file:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Scripts\*.sql" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="dbup-sqlserver" Version="5.*" />
</ItemGroup>
</Project>
```
## Running Migrations on Startup
Migrations run early in application startup, before other services are configured:
```csharp
// In Program.cs
var builder = WebApplication.CreateBuilder(args);
// Run database migrations first
var migrator = new DatabaseMigrator(builder.Configuration);
var result = migrator.Migrate();
if (!result.Successful)
{
Console.WriteLine($"Database migration failed: {result.Error}");
return 1;
}
// Continue with normal startup...
builder.Host.UseWindowsService();
```
## Core Tables
The scoping tool cache database includes these primary tables:
| Table | Purpose |
|-------|---------|
| `Search` | User search requests, status, and results (Excel as VARBINARY) |
| `DataUpdate` | Tracks last sync timestamp per data type |
| `WorkOrder_Curr` | Current work orders from JDE |
| `WorkOrder_Hist` | Historical work orders from JDE |
| `LotUsage_Curr` | Current lot usage from CMS |
| `LotUsage_Hist` | Historical lot usage from CMS |
| `Lot` | Lot reference data |
| `Item` | Item master reference data |
| `WorkCenter` | Work center reference data |
| `JdeUser` | Operator reference data |
| `ProfitCenter` | Profit center reference data |
| `SchemaVersions` | DbUp tracking table (auto-created) |
## Example Migration Scripts
### 001_CreateSearchTable.sql
```sql
CREATE TABLE [dbo].[Search] (
[Id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
[UserId] NVARCHAR(50) NOT NULL,
[UserDisplayName] NVARCHAR(100) NULL,
[Criteria] NVARCHAR(MAX) NOT NULL,
[Status] INT NOT NULL DEFAULT 0,
[CreatedAt] DATETIME2 NOT NULL DEFAULT GETUTCDATE(),
[StartedAt] DATETIME2 NULL,
[CompletedAt] DATETIME2 NULL,
[ResultCount] INT NULL,
[Results] VARBINARY(MAX) NULL,
[ErrorMessage] NVARCHAR(MAX) NULL
);
CREATE INDEX [IX_Search_Status] ON [dbo].[Search] ([Status]);
CREATE INDEX [IX_Search_UserId] ON [dbo].[Search] ([UserId]);
```
### 002_CreateDataUpdateTable.sql
```sql
CREATE TABLE [dbo].[DataUpdate] (
[Id] INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
[TableName] NVARCHAR(100) NOT NULL,
[UpdateType] NVARCHAR(20) NOT NULL,
[LastUpdated] DATETIME2 NOT NULL,
[RecordCount] INT NULL,
[Status] NVARCHAR(20) NOT NULL DEFAULT 'Completed'
);
CREATE UNIQUE INDEX [IX_DataUpdate_TableName_Type]
ON [dbo].[DataUpdate] ([TableName], [UpdateType]);
```
## Development vs Production
The same migration scripts run in all environments. For development with file-based data sources, the cache tables are still created but populated from JSON/CSV files instead of Oracle.
## Adding New Migrations
1. Create a new SQL file with the next number prefix
2. Write idempotent SQL (use `IF NOT EXISTS` where appropriate)
3. Build and run - DbUp picks up new embedded scripts automatically
```sql
-- Example: 006_AddNewColumn.sql
IF NOT EXISTS (
SELECT 1 FROM sys.columns
WHERE object_id = OBJECT_ID('dbo.Search') AND name = 'Priority'
)
BEGIN
ALTER TABLE [dbo].[Search] ADD [Priority] INT NOT NULL DEFAULT 0;
END
```
## Related Documentation
- [Overview](./Overview.md)
- [Solution Structure](./SolutionStructure.md)
- [Configuration](./Configuration.md)
- [Data Flow](./DataFlow.md)
@@ -0,0 +1,95 @@
# Package Dependencies
All packages are free with MIT, Apache, or similar permissive licenses.
## JdeScoping.Host
| Package | Purpose | License |
|---------|---------|---------|
| `Microsoft.Extensions.Hosting.WindowsServices` | Windows Service support | MIT |
| `Microsoft.AspNetCore.SignalR` | Real-time updates | MIT |
## JdeScoping.Client
| Package | Purpose | License |
|---------|---------|---------|
| `Microsoft.AspNetCore.Components.WebAssembly` | Blazor WASM runtime | MIT |
| `Microsoft.AspNetCore.Components.WebAssembly.DevServer` | Dev server (dev only) | MIT |
| `Microsoft.AspNetCore.SignalR.Client` | SignalR client | MIT |
| `Radzen.Blazor` | UI components | MIT (free tier) |
## JdeScoping.Core
| Package | Purpose | License |
|---------|---------|---------|
| `Dapper` | Micro-ORM | Apache 2.0 |
| `Microsoft.Data.SqlClient` | SQL Server driver | MIT |
| `Oracle.ManagedDataAccess.Core` | Oracle driver | Oracle Free Use |
| `ClosedXML` | Excel generation | MIT |
| `System.DirectoryServices.Protocols` | LDAP authentication | MIT |
| `Cronos` | Cron expression parsing | MIT |
| `Microsoft.Extensions.Options` | Options pattern | MIT |
| `Microsoft.Extensions.Configuration.Abstractions` | Configuration abstractions | MIT |
## JdeScoping.Database
| Package | Purpose | License |
|---------|---------|---------|
| `dbup-sqlserver` | SQL Server database migrations | MIT |
## JdeScoping.Tests
| Package | Purpose | License |
|---------|---------|---------|
| `xunit` | Test framework | Apache 2.0 |
| `xunit.runner.visualstudio` | VS test runner | Apache 2.0 |
| `Microsoft.NET.Test.Sdk` | Test SDK | MIT |
| `Shouldly` | Assertions | BSD |
| `NSubstitute` | Mocking | BSD |
| `Microsoft.AspNetCore.Mvc.Testing` | Integration tests | MIT |
## Packages Explicitly Avoided
| Package | Reason |
|---------|--------|
| `FluentAssertions` | Commercial license since v6 |
| `EPPlus` (v5+) | Commercial license since v5 |
| `Kendo UI` | Commercial license, replaced by Radzen |
## Version Considerations
- Target **.NET 10** (LTS when released, currently .NET 9 is latest)
- Use latest stable versions of all packages
- `Oracle.ManagedDataAccess.Core` v3.x for .NET 6+ support
- `Radzen.Blazor` v5.x for .NET 8+ Blazor features
## Package Installation
```bash
# Host project
dotnet add src/JdeScoping.Host package Microsoft.Extensions.Hosting.WindowsServices
# Client project
dotnet add src/JdeScoping.Client package Radzen.Blazor
dotnet add src/JdeScoping.Client package Microsoft.AspNetCore.SignalR.Client
# Core project
dotnet add src/JdeScoping.Core package Dapper
dotnet add src/JdeScoping.Core package Microsoft.Data.SqlClient
dotnet add src/JdeScoping.Core package Oracle.ManagedDataAccess.Core
dotnet add src/JdeScoping.Core package ClosedXML
dotnet add src/JdeScoping.Core package Cronos
# Test project
dotnet add tests/JdeScoping.Tests package xunit
dotnet add tests/JdeScoping.Tests package Shouldly
dotnet add tests/JdeScoping.Tests package NSubstitute
# Database project
dotnet add src/JdeScoping.Database package dbup-sqlserver
```
## Related Documentation
- [Overview](./Overview.md)
- [Testing](./Testing.md)
+101
View File
@@ -0,0 +1,101 @@
# Host Project
The `JdeScoping.Host` project is the main entry point - an ASP.NET Core application that runs as a Windows Service.
## Program.cs Configuration
```csharp
var builder = WebApplication.CreateBuilder(args);
builder.Host.UseWindowsService(); // Run as Windows Service
// ASP.NET Core services
builder.Services.AddControllersWithViews();
builder.Services.AddRazorPages();
builder.Services.AddSignalR();
// Background services
builder.Services.AddHostedService<SearchProcessorService>();
builder.Services.AddHostedService<DataSyncService>();
// Core dependencies (from JdeScoping.Core)
builder.Services.AddScoped<ISearchRepository, SearchRepository>();
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<IExcelExportService, ExcelExportService>();
// Data source registration (file-based for dev, Oracle for prod)
var dataSourceOptions = builder.Configuration
.GetSection("DataSource").Get<DataSourceOptions>();
if (dataSourceOptions?.UseFileDataSource == true)
{
builder.Services.AddScoped<IJdeDataSource, JdeFileDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsFileDataSource>();
}
else
{
builder.Services.AddScoped<IJdeDataSource, JdeOracleDataSource>();
builder.Services.AddScoped<ICmsDataSource, CmsOracleDataSource>();
}
// Auth registration (fake for dev, LDAP for prod)
var authOptions = builder.Configuration
.GetSection("Auth").Get<AuthOptions>();
if (authOptions?.UseFakeAuth == true)
{
builder.Services.AddScoped<IAuthService, FakeAuthService>();
}
else
{
builder.Services.AddScoped<IAuthService, LdapAuthService>();
}
// Configuration
builder.Services.Configure<LdapOptions>(builder.Configuration.GetSection("Ldap"));
builder.Services.Configure<DataSyncOptions>(builder.Configuration.GetSection("DataSync"));
builder.Services.Configure<DataSourceOptions>(builder.Configuration.GetSection("DataSource"));
builder.Services.Configure<AuthOptions>(builder.Configuration.GetSection("Auth"));
var app = builder.Build();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.MapControllers();
app.MapHub<StatusHub>("/hubs/status");
app.MapFallbackToFile("index.html");
app.Run();
```
## Controllers
| Controller | Purpose |
|------------|---------|
| `SearchController` | Submit search, get results, download Excel |
| `LookupController` | Autocomplete APIs for items, work centers, operators |
| `AuthController` | Login/logout against LDAP |
## Hubs
| Hub | Purpose |
|-----|---------|
| `StatusHub` | Pushes search status updates to connected clients |
## Background Services
| Service | Purpose |
|---------|---------|
| `SearchProcessorService` | Polls for queued searches, executes them, generates Excel |
| `DataSyncService` | Runs on schedule, syncs JDE/CMS data to local cache |
Background services use `IServiceScopeFactory` to create scopes for database access, avoiding scoped-in-singleton issues.
## Related Documentation
- [Solution Structure](./SolutionStructure.md)
- [Data Flow](./DataFlow.md)
- [Configuration](./Configuration.md)
+57
View File
@@ -0,0 +1,57 @@
# Architecture Overview
The JDE Scoping Tool is a manufacturing/ERP search application that caches data from JDE (Oracle) and CMS (Oracle) enterprise systems into SQL Server, allowing users to create complex searches and export results to Excel.
## Key Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| Target framework | .NET 10 | Modern LTS, consolidation from .NET Framework 4.8 |
| Deployment | Self-hosted Kestrel as Windows Service | Simple, no IIS dependency |
| UI | Blazor WebAssembly + Radzen | Modern SPA, free component library |
| Database access | Dapper | Preserve existing queries, minimal changes |
| Oracle driver | Oracle.ManagedDataAccess.Core | Both JDE and CMS now on Oracle |
| Data sources | Interface + prod/dev implementations | Development uses file exports, production uses Oracle |
| Authentication | Interface + prod/dev implementations | Development uses fake auth, production uses LDAP |
| Real-time | ASP.NET Core SignalR | Push search status updates |
| Excel | ClosedXML | Free MIT license (replaces EPPlus) |
| Testing | xUnit + Shouldly + NSubstitute | Free, readable assertions |
| Config | appsettings.json + env vars | Standard, secrets via environment |
| Database migrations | DbUp | Schema defined in application, versioned SQL scripts |
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ Windows Service Host │
│ ┌───────────────┐ ┌───────────────┐ ┌─────────────────┐ │
│ │ Blazor WASM │ │ REST API │ │ SignalR Hub │ │
│ │ Client │ │ Controllers │ │ (StatusHub) │ │
│ └───────────────┘ └───────────────┘ └─────────────────┘ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ Background Services │ │
│ │ ┌─────────────────┐ ┌─────────────────────────┐ │ │
│ │ │ SearchProcessor │ │ DataSyncService │ │ │
│ │ └─────────────────┘ └─────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────┼─────────────────────┐
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ SQL Server │ │ JDE Oracle │ │ CMS Oracle │
│ (Local Cache)│ │ (Enterprise) │ │ (Enterprise) │
└───────────────┘ └───────────────┘ └───────────────┘
```
## Related Documentation
- [Solution Structure](./SolutionStructure.md)
- [Host Project](./HostProject.md)
- [Blazor Client](./BlazorClient.md)
- [Core Project](./CoreProject.md)
- [Database](./Database.md)
- [Data Flow](./DataFlow.md)
- [Configuration](./Configuration.md)
- [Testing](./Testing.md)
- [Dependencies](./Dependencies.md)
@@ -0,0 +1,71 @@
# Solution Structure
The solution uses a minimal project structure with four projects in a single deployable.
## Project Layout
```
NEW/
├── JdeScoping.sln
├── src/
│ ├── JdeScoping.Host/ # ASP.NET Core host + Blazor server
│ │ ├── Program.cs # Entry point, service configuration
│ │ ├── Controllers/ # API endpoints
│ │ ├── Hubs/ # SignalR hubs
│ │ ├── BackgroundServices/ # Search processor, data sync
│ │ └── wwwroot/ # Blazor WASM published output
│ │
│ ├── JdeScoping.Client/ # Blazor WebAssembly UI
│ │ ├── Pages/ # Razor pages
│ │ ├── Components/ # Reusable Radzen components
│ │ ├── Services/ # HTTP + SignalR clients
│ │ └── wwwroot/ # Static assets
│ │
│ ├── JdeScoping.Core/ # Shared business logic
│ │ ├── Models/ # Domain models (WorkOrder, Search, etc.)
│ │ ├── Interfaces/ # Repository/service contracts
│ │ ├── Repositories/ # Dapper data access
│ │ └── Services/ # Business logic
│ │
│ └── JdeScoping.Database/ # Database schema migrations
│ ├── Scripts/ # Versioned SQL scripts
│ │ ├── 001_CreateSearchTable.sql
│ │ ├── 002_CreateDataUpdateTable.sql
│ │ └── ...
│ └── DatabaseMigrator.cs # DbUp runner
└── tests/
└── JdeScoping.Tests/ # xUnit + Shouldly tests
├── Unit/
└── Integration/
```
## Project Responsibilities
### JdeScoping.Host
The deployable Windows Service. Hosts the Blazor WASM client, REST API, SignalR hub, and background services. References `JdeScoping.Core` for business logic.
### JdeScoping.Client
The Blazor WebAssembly UI. Compiled and published into Host's `wwwroot` folder. Uses Radzen Blazor for components. No direct database access - communicates via HTTP and SignalR.
### JdeScoping.Core
Business logic and data access. Contains domain models, repository interfaces and implementations, and services. No ASP.NET dependencies - fully testable in isolation.
### JdeScoping.Database
Database schema management using DbUp. Contains versioned SQL scripts that are embedded as resources and executed in order on application startup. Scripts are idempotent - DbUp tracks which scripts have run in a `SchemaVersions` table.
### JdeScoping.Tests
Unit and integration tests using xUnit, Shouldly for assertions, and NSubstitute for mocking.
## Related Documentation
- [Overview](./Overview.md)
- [Host Project](./HostProject.md)
- [Blazor Client](./BlazorClient.md)
- [Core Project](./CoreProject.md)
- [Database](./Database.md)
+181
View File
@@ -0,0 +1,181 @@
# Testing Strategy
The test project uses xUnit for the framework, Shouldly for assertions, and NSubstitute for mocking.
## Project Structure
```
JdeScoping.Tests/
├── Unit/
│ ├── Services/
│ │ ├── SearchServiceTests.cs
│ │ ├── ExcelExportServiceTests.cs
│ │ └── DataSyncOrchestratorTests.cs
│ ├── Repositories/
│ │ └── SearchRepositoryTests.cs
│ └── Models/
│ └── SearchCriteriaTests.cs
└── Integration/
├── ApiTests/
│ ├── SearchControllerTests.cs
│ └── LookupControllerTests.cs
└── RepositoryTests/
└── JdeRepositoryTests.cs
```
## Unit Tests
Unit tests mock dependencies and test business logic in isolation:
```csharp
public class SearchServiceTests
{
[Fact]
public async Task ExecuteSearch_WithValidCriteria_ReturnsResults()
{
// Arrange
var mockRepo = Substitute.For<ISearchRepository>();
mockRepo.GetWorkOrdersAsync(Arg.Any<SearchCriteria>())
.Returns(new List<WorkOrder> { new WorkOrder { Number = "WO123" } });
var service = new SearchService(mockRepo);
var criteria = new SearchCriteria { ItemNumber = "ABC123" };
// Act
var results = await service.ExecuteAsync(criteria);
// Assert
results.Count.ShouldBeGreaterThan(0);
results.First().Number.ShouldBe("WO123");
}
[Fact]
public async Task ExecuteSearch_WithInvalidCriteria_ThrowsValidationException()
{
// Arrange
var mockRepo = Substitute.For<ISearchRepository>();
var service = new SearchService(mockRepo);
var criteria = new SearchCriteria(); // Empty criteria
// Act & Assert
await Should.ThrowAsync<ValidationException>(
() => service.ExecuteAsync(criteria));
}
}
```
## Shouldly Assertions
Shouldly provides readable assertion syntax without FluentAssertions licensing:
```csharp
// Value assertions
result.ShouldBe(expected);
result.ShouldNotBeNull();
result.ShouldBeGreaterThan(0);
// Collection assertions
list.ShouldContain(item);
list.ShouldBeEmpty();
list.Count.ShouldBe(5);
// String assertions
text.ShouldStartWith("Error");
text.ShouldContain("expected");
// Exception assertions
Should.Throw<ArgumentException>(() => service.Process(null));
await Should.ThrowAsync<InvalidOperationException>(() => service.ProcessAsync());
```
## NSubstitute Mocking
NSubstitute provides a simple API for creating test doubles:
```csharp
// Create substitute
var mockRepo = Substitute.For<ISearchRepository>();
// Configure returns
mockRepo.GetByIdAsync(123).Returns(new Search { Id = 123 });
mockRepo.GetByIdAsync(Arg.Any<int>()).Returns(x => new Search { Id = (int)x[0] });
// Verify calls
await mockRepo.Received().CreateAsync(Arg.Is<Search>(s => s.Status == "Queued"));
await mockRepo.DidNotReceive().DeleteAsync(Arg.Any<int>());
```
## Integration Tests
Integration tests use `WebApplicationFactory<Program>` for API tests:
```csharp
public class SearchControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient _client;
public SearchControllerTests(WebApplicationFactory<Program> factory)
{
_client = factory.CreateClient();
}
[Fact]
public async Task SubmitSearch_ReturnsSearchId()
{
// Arrange
var criteria = new SearchCriteria { ItemNumber = "TEST123" };
var content = new StringContent(
JsonSerializer.Serialize(criteria),
Encoding.UTF8,
"application/json");
// Act
var response = await _client.PostAsync("/api/search", content);
// Assert
response.StatusCode.ShouldBe(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<SearchResult>();
result.SearchId.ShouldBeGreaterThan(0);
}
}
```
## Database Integration Tests
Repository integration tests run against a local SQL Server instance:
```csharp
public class SearchRepositoryIntegrationTests : IDisposable
{
private readonly string _connectionString;
public SearchRepositoryIntegrationTests()
{
_connectionString = "Server=localhost;Database=LotFinder_Test;...";
// Setup test database
}
[Fact]
public async Task CreateAndRetrieve_RoundTrips()
{
var repo = new SearchRepository(_connectionString);
var search = new Search { UserId = "testuser", Status = "Queued" };
var id = await repo.CreateAsync(search);
var retrieved = await repo.GetByIdAsync(id);
retrieved.ShouldNotBeNull();
retrieved.UserId.ShouldBe("testuser");
}
public void Dispose()
{
// Cleanup test data
}
}
```
## Related Documentation
- [Core Project](./CoreProject.md)
- [Dependencies](./Dependencies.md)
+249
View File
@@ -0,0 +1,249 @@
# Component Map
This document maps source code locations to their corresponding documentation folders. Use this to determine where documentation should live.
## Project Structure
This is a migration project with two main code areas:
```
JdeScopingTool/
├── OLD/ # Legacy .NET Framework 4.8 (read-only reference)
├── NEW/ # New .NET 10 solution (build target)
├── SPECS/ # OpenSpec specifications
└── DOCUMENTATION/ # Project documentation
```
## Source to Documentation Mapping
### Legacy Code (OLD/)
| Source Path | Documentation Folder |
|-------------|---------------------|
| `OLD/WebInterface/` | `LegacyReference/WebInterface/` |
| `OLD/WebInterface/Controllers/` | `LegacyReference/WebInterface/Controllers.md` |
| `OLD/WebInterface/Hubs/` | `LegacyReference/WebInterface/SignalR.md` |
| `OLD/WorkerService/` | `LegacyReference/WorkerService/` |
| `OLD/WorkerService/Process/` | `LegacyReference/WorkerService/Processing.md` |
| `OLD/DataModel/` (Commons) | `LegacyReference/DataModel/` |
| `OLD/DataModel/Process/JDE*.cs` | `DataSync/JDE.md` |
| `OLD/DataModel/Process/CMS*.cs` | `DataSync/CMS.md` |
| `OLD/DataModel/Process/LotFinderDB*.cs` | `Database/LocalCache.md` |
| `OLD/DataModel/Models/` | `Database/Entities.md` |
| `OLD/Database/` | `Database/Schema.md` |
### New Code (NEW/)
| Source Path | Documentation Folder |
|-------------|---------------------|
| `NEW/src/` | Component-specific folders |
| `NEW/src/Web/Controllers/` | `API/Endpoints.md` |
| `NEW/src/Web/Hubs/` | `API/SignalR.md` |
| `NEW/src/Web/Client/` (Blazor) | `WebClient/` |
| `NEW/src/Services/` | `BackgroundServices/` |
| `NEW/src/Core/Models/` | `Database/Entities.md` |
| `NEW/src/Core/Interfaces/` | Document in implementing component |
| `NEW/src/Infrastructure/` | Component-specific folders |
| `NEW/appsettings*.json` | `Configuration/` |
| `NEW/tests/` | Document in corresponding component |
## Documentation Folder Structure
```
DOCUMENTATION/
├── Instructions/ # This folder - documentation guidelines
├── GettingStarted/ # Onboarding, prerequisites, architecture overview
├── LegacyReference/ # Documentation of the OLD codebase for migration reference
│ ├── WebInterface/ # ASP.NET MVC web application
│ ├── WorkerService/ # Windows service (Topshelf)
│ └── DataModel/ # Shared library (Commons.csproj)
├── Search/ # Search functionality
├── DataSync/ # JDE/CMS data synchronization
├── Database/ # SQL Server cache, entities, schema
├── API/ # REST endpoints, SignalR
├── WebClient/ # Blazor WASM UI
├── Export/ # Excel generation
├── Authentication/ # LDAP authentication
├── BackgroundServices/ # Background processing
├── Configuration/ # appsettings, connection strings
└── Operations/ # Deployment, monitoring
```
## Component Details
### Search/
Documents the search functionality.
**Source paths (Legacy):**
- `OLD/DataModel/Models/Search*.cs` - Search models
- `OLD/DataModel/ViewModels/Search*.cs` - Search view models
- `OLD/WebInterface/Controllers/SearchController.cs` - Search API
- `OLD/WorkerService/Process/WorkProcessor.cs` - Search execution
**Source paths (New):**
- `NEW/src/Core/Models/Search*.cs` - Search models
- `NEW/src/Web/Controllers/SearchController.cs` - Search API
- `NEW/src/Services/SearchProcessor.cs` - Search execution
**Typical files:**
- `Overview.md` - Search system architecture
- `Criteria.md` - Search criteria model and options
- `Execution.md` - How searches are processed
- `Results.md` - Result storage and retrieval
### DataSync/
Documents data synchronization from enterprise systems.
**Source paths (Legacy):**
- `OLD/DataModel/Process/JDE*.cs` - JDE Oracle queries
- `OLD/DataModel/Process/CMS*.cs` - CMS Sybase queries
- `OLD/WorkerService/Process/UpdateProcessor.cs` - Sync orchestration
- `OLD/WorkerService/dsconfig/*.json` - Data source configs
**Source paths (New):**
- `NEW/src/Infrastructure/DataSync/` - Data sync services
- `NEW/src/Infrastructure/DataSync/JDE/` - JDE adapter
- `NEW/src/Infrastructure/DataSync/CMS/` - CMS adapter
**Typical files:**
- `Overview.md` - Data sync architecture
- `JDE.md` - JD Edwards (Oracle) integration
- `CMS.md` - CMS (Sybase) integration
- `Scheduling.md` - Mass/daily/hourly sync schedules
- `Configuration.md` - Data source configuration
### Database/
Documents the SQL Server cache database.
**Source paths (Legacy):**
- `OLD/DataModel/Process/LotFinderDB*.cs` - SQL Server access
- `OLD/DataModel/Models/` - Entity definitions
- `OLD/Database/` - SQL Server database project
**Source paths (New):**
- `NEW/src/Infrastructure/Persistence/` - EF Core implementation
- `NEW/src/Core/Models/` - Entity definitions
**Typical files:**
- `Overview.md` - Database architecture
- `Entities.md` - Entity documentation
- `LocalCache.md` - Cached data tables
- `Schema.md` - Database schema reference
### API/
Documents the REST API and real-time communication.
**Source paths (Legacy):**
- `OLD/WebInterface/Controllers/` - MVC controllers
- `OLD/WebInterface/Hubs/StatusHub.cs` - SignalR hub
**Source paths (New):**
- `NEW/src/Web/Controllers/` - API controllers
- `NEW/src/Web/Hubs/` - SignalR hubs
**Typical files:**
- `Overview.md` - API architecture
- `Endpoints.md` - Endpoint reference
- `SignalR.md` - Real-time status updates
- `Authentication.md` - JWT/LDAP auth
### WebClient/
Documents the Blazor WASM frontend.
**Source paths (New):**
- `NEW/src/Web/Client/Pages/` - Page components
- `NEW/src/Web/Client/Components/` - Reusable components
- `NEW/src/Web/Client/Services/` - Client services
**Typical files:**
- `Overview.md` - Frontend architecture
- `Components.md` - Component documentation
- `SearchUI.md` - Search interface
- `State.md` - State management
### Export/
Documents Excel export functionality.
**Source paths (Legacy):**
- `OLD/WorkerService/Process/ExcelWriter.cs` - EPPlus generation
- `OLD/WorkerService/Templates/QueryTemplate.cs` - Query templates
**Source paths (New):**
- `NEW/src/Services/ExcelExporter.cs` - Excel generation
**Typical files:**
- `Overview.md` - Export architecture
- `Excel.md` - Excel generation details
- `Templates.md` - Export templates
### Authentication/
Documents LDAP authentication.
**Source paths (Legacy):**
- `OLD/WebInterface/Controllers/AccountController.cs` - Login/logout
- `OLD/DataModel/Config.cs` - LDAP configuration
**Source paths (New):**
- `NEW/src/Infrastructure/Authentication/` - Auth services
**Typical files:**
- `Overview.md` - Authentication architecture
- `LDAP.md` - LDAP integration details
- `Configuration.md` - Auth configuration
### BackgroundServices/
Documents background processing.
**Source paths (Legacy):**
- `OLD/WorkerService/` - Topshelf Windows service
- `OLD/WorkerService/Process/WorkProcessor.cs` - Main work loop
**Source paths (New):**
- `NEW/src/Services/` - BackgroundService implementations
**Typical files:**
- `Overview.md` - Background service architecture
- `SearchProcessor.md` - Search queue processing
- `DataSyncService.md` - Data synchronization service
### LegacyReference/
Documents the legacy codebase for migration reference.
**Source paths:**
- All files under `OLD/`
**Typical files:**
- `WebInterface/Overview.md` - ASP.NET MVC structure
- `WorkerService/Overview.md` - Topshelf service structure
- `DataModel/Overview.md` - Commons library structure
- `MigrationNotes.md` - Patterns to preserve/change
## Ambiguous Cases
When code spans multiple components, use these guidelines:
| Code Type | Document In |
|-----------|-------------|
| SignalR hubs | `API/SignalR.md` |
| SignalR clients | `WebClient/SignalR.md` |
| Shared DTOs | Component that "owns" the concept |
| Cross-cutting services | Most relevant component |
| Legacy patterns | `LegacyReference/` with cross-references |
## Adding New Components
When adding a new system component:
1. Create a new folder under `DOCUMENTATION/`
2. Add at minimum `Overview.md`
3. Update this mapping table
4. Update [GeneratingDocs.md](./GeneratingDocs.md) if new patterns emerge
@@ -0,0 +1,145 @@
# Generating Documentation
This guide defines how to create new documentation for the JDE Scoping Tool migration project. Follow these instructions when documenting new features, components, or systems.
## Document Types
Each component folder should contain these standard files:
| File | Purpose |
|------|---------|
| `Overview.md` | What the component does, key concepts, architecture diagrams |
| `Development.md` | How to add/modify features, patterns to follow, best practices |
| `Configuration.md` | All configurable options with defaults and examples |
| `Troubleshooting.md` | Common issues, error messages, debugging steps |
Create additional topic-specific files as needed (e.g., `Search/Criteria.md`, `DataSync/JDE.md`, `Export/Excel.md`).
## Generation Process
### Step 1: Identify Scope
Determine what you're documenting:
- Which component folder does this belong to? (See [ComponentMap.md](./ComponentMap.md))
- Is this a new document or an addition to an existing one?
- What source files contain the implementation?
### Step 2: Read Source Code
Before writing any documentation:
1. Read the relevant source files thoroughly
2. Understand the current implementation, not assumptions
3. Identify key classes, methods, and patterns
4. Note any configuration options or environment variables
5. Look for existing code comments that explain "why"
### Step 3: Check Existing Documentation
Avoid duplication:
1. Search `DOCUMENTATION/` for related content
2. If similar content exists, update it rather than creating new
3. Cross-reference related docs rather than repeating information
### Step 4: Write Documentation
Structure your document following the [StyleGuide.md](./StyleGuide.md):
```markdown
# Component/Feature Name
Brief 1-2 sentence description of what this is and why it exists.
## Key Concepts
Explain the important ideas a developer needs to understand.
## Usage
### Basic Example
```csharp
// Code snippet from actual source
```
### Common Patterns
Describe typical usage patterns with code examples.
## Configuration
| Option | Default | Description |
|--------|---------|-------------|
| `OptionName` | `value` | What it does |
## Related Documentation
- [Related Topic](../OtherComponent/RelatedTopic.md)
```
### Step 5: Verify Accuracy
Before finalizing:
1. Confirm all code snippets match actual source code
2. Verify file paths and class names are correct
3. Test any commands or configuration examples
4. Ensure cross-references point to existing files
## Required Sections
Every documentation file must include:
1. **Title and Purpose** - H1 heading with 1-2 sentence description
2. **Key Concepts** - If the topic requires background understanding
3. **Code Examples** - Embedded snippets from actual codebase
4. **Configuration** - If the component has configurable options
5. **Related Documentation** - Links to related topics
## Code Snippet Guidelines
### Do
- Copy snippets from actual source files
- Include enough context (class name, method signature)
- Show typical 5-25 line examples
- Specify the language in code blocks
```csharp
public class SearchProcessor : BackgroundService
{
private readonly ISearchRepository _searchRepository;
public SearchProcessor(ISearchRepository searchRepository)
{
_searchRepository = searchRepository;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ProcessQueuedSearchesAsync(stoppingToken);
}
}
```
### Don't
- Invent example code that doesn't exist in the codebase
- Include 100+ line dumps without explanation
- Use pseudocode when real code is available
- Omit the language specifier on code blocks
## File Naming
- Use `PascalCase.md` for all documentation files
- Match the concept being documented: `Actors.md`, `Migrations.md`, `SignalR.md`
- For multi-word topics: `HealthChecks.md`, `StateMachines.md`
- Avoid abbreviations unless universally understood: `API.md` is fine, `TIA.md` is not
## Creating New Component Folders
When documenting a new system component:
1. Create the folder under `DOCUMENTATION/`
2. Add at minimum `Overview.md`
3. Add other standard files as content warrants
4. Update [ComponentMap.md](./ComponentMap.md) with the new mapping
5. Add cross-references from related documentation
+282
View File
@@ -0,0 +1,282 @@
# Documentation Style Guide
This guide defines writing conventions and formatting rules for all JDE Scoping Tool documentation.
## Tone and Voice
### Be Technical and Direct
Write for developers who are familiar with .NET. Don't explain basic concepts like dependency injection or async/await unless they're used in an unusual way.
**Good:**
> The `SearchProcessor` executes queued searches against the local cache and generates Excel results.
**Avoid:**
> The SearchProcessor is a really powerful component that helps manage all your searches efficiently!
### Explain "Why" Not Just "What"
Document the reasoning behind patterns and decisions, not just the mechanics.
**Good:**
> Data sync uses a retry pattern with exponential backoff because JDE connections can be temporarily unavailable during peak ERP usage.
**Avoid:**
> Data sync uses retry with backoff.
### Use Present Tense
Describe what the code does, not what it will do.
**Good:**
> The actor validates the message before processing.
**Avoid:**
> The actor will validate the message before processing.
### No Marketing Language
This is internal technical documentation. Avoid superlatives and promotional language.
**Avoid:** "powerful", "robust", "cutting-edge", "seamless", "blazing fast"
## Formatting Rules
### File Names
Use `PascalCase.md` for all documentation files:
- `Overview.md`
- `HealthChecks.md`
- `StateMachines.md`
- `SignalR.md`
### Headings
- **H1 (`#`):** Document title only, Title Case
- **H2 (`##`):** Major sections, Title Case
- **H3 (`###`):** Subsections, Sentence case
- **H4+ (`####`):** Rarely needed, Sentence case
```markdown
# Actor Health Checks
## Configuration Options
### Setting the timeout
#### Default values
```
### Code Blocks
Always specify the language:
````markdown
```csharp
public class MyActor : ReceiveActor { }
```
```json
{
"Setting": "value"
}
```
```bash
dotnet build
```
````
Supported languages: `csharp`, `json`, `bash`, `xml`, `sql`, `yaml`, `html`, `css`, `javascript`
### Code Snippets
**Length:** 5-25 lines is typical. Shorter for simple concepts, longer for complete examples.
**Context:** Include enough to understand where the code lives:
```csharp
// Good - shows class context
public class SearchProcessor : BackgroundService
{
public SearchProcessor(ISearchRepository repository)
{
_repository = repository;
}
}
// Avoid - orphaned snippet
_repository = repository;
```
**Accuracy:** Only use code that exists in the codebase. Never invent examples.
### Lists
Use bullet points for unordered items:
```markdown
- First item
- Second item
- Third item
```
Use numbers for sequential steps:
```markdown
1. Do this first
2. Then do this
3. Finally do this
```
### Tables
Use tables for structured reference information:
```markdown
| Option | Default | Description |
|--------|---------|-------------|
| `Timeout` | `5000` | Milliseconds to wait |
| `RetryCount` | `3` | Number of retry attempts |
```
### Inline Code
Use backticks for:
- Class names: `SearchProcessor`
- Method names: `ProcessQueuedSearches()`
- File names: `appsettings.json`
- Configuration keys: `JdeScoping:ConnectionStrings`
- Command-line commands: `dotnet build`
### Links
Use relative paths for internal documentation:
```markdown
[See the Search guide](../Search/Overview.md)
[Configuration options](./Configuration.md)
```
Use descriptive link text:
```markdown
<!-- Good -->
See the [Data Sync Configuration](../DataSync/Configuration.md) documentation.
<!-- Avoid -->
See [here](../DataSync/Configuration.md) for more.
```
## Structure Conventions
### Document Opening
Every document starts with:
1. H1 title
2. 1-2 sentence description of purpose
```markdown
# Search Processor
The search processor handles queued searches, executing queries against the local cache and generating Excel results.
```
### Section Organization
Organize content from general to specific:
1. Overview/introduction
2. Key concepts (if needed)
3. Basic usage
4. Advanced usage
5. Configuration
6. Troubleshooting
7. Related documentation
### Code Example Placement
Place code examples immediately after the concept they illustrate:
```markdown
## Search Execution
Searches are executed against the local cache using Dapper:
```csharp
var results = await connection.QueryAsync<WorkOrder>(query, parameters);
```
Each search returns a collection of matching records...
```
### Related Documentation Section
End each document with links to related topics:
```markdown
## Related Documentation
- [Search Criteria](./Criteria.md)
- [Excel Export](../Export/Excel.md)
- [Configuration](../Configuration/Search.md)
```
## Naming Conventions
### Match Code Exactly
Use the exact names from source code:
- `SearchProcessor` not "Search Processor"
- `WorkOrder` not "Work Order"
- `ISearchRepository` not "search repository interface"
### Acronyms
Spell out on first use, then use acronym:
> JD Edwards (JDE) is an Oracle ERP system. JDE stores work order and lot data...
Common acronyms that don't need expansion:
- API
- JSON
- SQL
- HTTP/HTTPS
- REST
- JWT
- UI
### File Paths
Use forward slashes and backticks:
- `NEW/src/Services/SearchProcessor.cs`
- `appsettings.json`
- `DOCUMENTATION/Search/Overview.md`
## What to Avoid
### Don't Document the Obvious
```markdown
<!-- Avoid -->
## Constructor
The constructor creates a new instance of the class.
<!-- Better - only document if there's something notable -->
## Constructor
The constructor accepts an `IActorRef` for the gateway actor, which must be resolved before actor creation.
```
### Don't Duplicate Source Code Comments
If code has good comments, reference the file rather than copying:
> See `SearchProcessor.cs` lines 45-60 for the search execution logic.
### Don't Include Temporary Information
Avoid dates, version numbers, or "coming soon" notes that will become stale.
### Don't Over-Explain .NET Basics
Assume readers know:
- Dependency injection
- async/await
- LINQ
- Entity Framework basics
- ASP.NET Core middleware pipeline
+156
View File
@@ -0,0 +1,156 @@
# Updating Documentation
This guide defines when and how to update existing documentation. Documentation should always reflect the current codebase state.
## Update Triggers
When these code changes occur, update the corresponding documentation:
| Code Change | Update These Docs |
|-------------|-------------------|
| New API endpoint | `API/Endpoints.md`, update `API/Overview.md` |
| API endpoint changed | Corresponding endpoint documentation |
| New entity added | `Database/Entities.md` |
| Search criteria modified | `Search/Criteria.md` |
| New data sync source | `DataSync/` relevant file (JDE, CMS, etc.) |
| Data sync logic changed | Corresponding data sync documentation |
| Excel export modified | `Export/Excel.md` |
| New Blazor component | `WebClient/Components.md` |
| SignalR hub changed | `API/SignalR.md` |
| Background service modified | `BackgroundServices/` relevant file |
| Config option added | Component's `Configuration.md` |
| Config option removed | Remove from docs |
| Authentication changed | `Authentication/LDAP.md` |
| Deployment config changed | `Operations/Deployment.md` |
| appsettings changed | `Configuration/` relevant file |
## Update Process
### Step 1: Identify Affected Documentation
Use [ComponentMap.md](./ComponentMap.md) to determine which docs need updating:
1. Identify the source files that changed
2. Map them to documentation folders
3. List all potentially affected documentation files
### Step 2: Read Current Documentation
Before making changes:
1. Read the entire document you're updating
2. Understand the existing structure and flow
3. Identify sections that need modification
### Step 3: Make Targeted Updates
Keep changes minimal and focused:
- Only modify sections affected by the code change
- Don't rewrite unaffected sections
- Preserve existing explanations that are still accurate
- Maintain consistent style with surrounding content
### Step 4: Update Code Snippets
If the code change affects documented examples:
1. Locate all code snippets that reference the changed code
2. Update snippets to match the new implementation
3. Verify the updated snippets compile/work correctly
### Step 5: Update Cross-References
If the change creates or removes relationships:
1. Add links to newly related documentation
2. Remove links to deleted content
3. Update link text if document titles changed
### Step 6: Add Verification Comment
At the bottom of updated documents, add or update:
```markdown
<!-- Last verified against codebase: YYYY-MM-DD -->
```
## Deletion Handling
### When Code Is Removed
1. **Remove corresponding documentation sections**
- Delete paragraphs describing removed features
- Remove code snippets that no longer apply
- Update "Key Concepts" if concepts no longer exist
2. **Update cross-references**
- Search all docs for links to removed content
- Either remove the link or redirect to replacement content
- Update any "See also" or "Related" sections
3. **Handle complete feature removal**
- If an entire file's subject is removed, delete the file
- Update any index or overview docs that referenced it
- Check navigation/table of contents if applicable
### When Code Is Renamed
1. Update all documentation references to use new names
2. Update code snippets with new class/method/variable names
3. Rename documentation files if they match the old name
4. Update all cross-reference links to renamed files
## Batch Updates
When making large-scale code changes:
1. **List all affected documentation** before starting
2. **Update systematically** - one document at a time
3. **Verify consistency** across updated documents
4. **Check cross-references** after all updates complete
## Common Update Scenarios
### Adding a New Search Field
1. Add entry to `Search/Criteria.md` with:
- Field name and data type
- Which data source it queries (JDE, CMS, local cache)
- How it affects search results
- Example usage in search criteria
2. Update `Search/Overview.md` if the field affects search architecture
3. Update `Database/Entities.md` if new cache tables are involved
### Adding a New API Endpoint
1. Add entry to `API/Endpoints.md` with:
- HTTP method and route
- Request/response format
- Authentication requirements
- Example request/response
2. Update `API/Overview.md` if endpoint represents new functionality
### Adding a New Data Sync Source
1. Add entry to `DataSync/` folder with:
- Source system details (connection type, schema)
- Tables/data being synced
- Sync schedule (mass/daily/hourly)
- Code snippet showing query patterns
2. Update `DataSync/Overview.md` with the new source
3. Update `Database/Entities.md` with any new cache tables
### Changing Configuration Options
1. Update the relevant `Configuration.md` file
2. If option affects multiple components, update each
3. Update any deployment documentation if defaults changed
4. Update troubleshooting docs if option helps debug issues
## What Not to Update
Avoid unnecessary changes:
- Don't reformat documentation that wasn't affected
- Don't update examples if they still work correctly
- Don't add new content unrelated to the code change
- Don't change writing style in unaffected sections
+473
View File
@@ -0,0 +1,473 @@
# Search Creation Page - Functionality Analysis
This document provides a comprehensive analysis of the legacy search creation page (`OLD/WebInterface/Views/Search/Create.cshtml`) for migration to the new .NET 10 Blazor application.
## Overview
The search creation page allows users to create complex manufacturing/ERP searches by combining various filter criteria. It uses Kendo UI for data binding and widgets, jQuery FileUpload for Excel file handling, and SignalR for real-time status updates.
---
## Page Structure
### Header Section
- **Title**: "Search"
- **Submit Button**: Triggers validation and saves the search
### Search Details Panel
| Field | Type | Behavior |
|-------|------|----------|
| Search Type | Dropdown | **Required**. Selects from 16 predefined filter combinations. Controls which filter panels are visible. |
| Name | Text input | **Required**. User-friendly name for the search. |
| Submitted At | Read-only text | Displays when search was submitted (formatted: `MM/dd/yyyy hh:mm:ss tt`) |
| Started At | Read-only text | Displays when processing started |
| Completed At | Read-only text | Displays when processing completed |
| User | Read-only text | Username of search creator (auto-populated) |
| Status | Read-only text | Current status with color coding (red background for Error status) |
| Download Results | Button | Visible only when `Status === 'Ended'`. Downloads Excel results. |
### Read-Only Mode
When a search has been submitted (`Status !== 'New'`):
- Submit button is hidden
- All inputs are disabled
- Template upload/download/clear buttons are hidden
- A warning notice is displayed with a **Copy** button to duplicate the search
---
## Valid Search Type Combinations
The system enforces 16 predefined filter combinations defined in `OLD/WebInterface/Scripts/model/models.js`:
| ID | Name | Timespan | Work Order | Item Number | Profit Center | Work Center | Component Lot | Operator | Item/Op/MIS | Extract MIS |
|----|------|----------|------------|-------------|---------------|-------------|---------------|----------|-------------|-------------|
| 10 | Work Order | | x | | | | | | | |
| 20 | Component Lot | | | | | | x | | | |
| 30 | Time Span + Profit Center | x | | | x | | | | | |
| 40 | Time Span + Work Center | x | | | | x | | | | |
| 50 | Time Span + Operator | x | | | | | | x | | |
| 60 | Time Span + Profit Center + Item Number | x | | x | x | | | | | |
| 70 | Time Span + Profit Center + Item/Operation/MIS | x | | | x | | | | x | |
| 80 | Time Span + Profit Center + Work Order + Item/Operation/MIS | x | x | | x | | | | x | |
| 90 | Time Span + Profit Center + Extract MIS | x | | | x | | | | | x |
| 100 | Time Span + Work Center + Item Number | x | | x | | x | | | | |
| 110 | Time Span + Work Center + Extract MIS | x | | | | x | | | | x |
| 120 | Time Span + Work Center + Item/Operation/MIS | x | | | | x | | | x | |
| 130 | Time Span + Work Center + Work Order + Item/Operation/MIS | x | x | | | x | | | x | |
| 140 | Time Span + Item Number | x | | x | | | | | | |
| 150 | Time Span + Work Center + Operator | x | | | | x | | x | | |
| 160 | Time Span + Profit Center + Operator | x | | | x | | | x | | |
---
## Filter Panels
### 1. Time Span Filter
**Panel Header**: "Filter by timespan"
| Field | Type | Validation | Notes |
|-------|------|------------|-------|
| Min Date | Kendo DatePicker | **Required** when filter is active. Must be valid date. | Min: Nov 1, 2002. Max: Today or Max Date if set. |
| Max Date | Kendo DatePicker | **Required** when filter is active. Must be valid date. | Min: Nov 1, 2002 or Min Date if set. Max: Today. |
**Interactions**:
- Min/Max pickers constrain each other (selecting min date sets min of max picker, and vice versa)
- Custom validation prevents invalid date text
---
### 2. Work Order Filter
**Panel Header**: "Filter by work order"
**Data Display**: Kendo Grid showing:
- Work Order Number
- Item Number (looked up from database)
**File Operations**:
| Button | Action | Endpoint |
|--------|--------|----------|
| Download Template | Downloads Excel with current data | `POST FileIO/DownloadWorkOrders``GET FileIO/DownloadWorkOrders?key` |
| Upload Data | Uploads Excel file, validates work orders against DB | `POST FileIO/UploadWorkOrders` |
| Clear Data | Clears grid after confirmation dialog | Local action |
**Upload Format**: Excel file with column "Work Order Number" (starting row 2)
**Validation**: At least one work order required when filter is active
---
### 3. Item Number Filter
**Panel Header**: "Filter by item number"
**Input Method**: Kendo ComboBox with server-side autocomplete
- Minimum 3 characters to trigger search
- Filter: "contains"
- Template displays: Item Number | Description
- Endpoint: `GET Lookup/FindItem?itemNumber=...`
**Data Display**: Kendo Grid showing:
- Item Number
- Description
- Delete action button
**Actions**:
| Button | Action |
|--------|--------|
| Add to filter | Adds selected item from combobox to grid |
| Delete (per row) | Removes item from grid |
| Clear Data | Clears grid after confirmation |
| Download Template | Downloads Excel with current items |
| Upload Data | Uploads Excel file of item numbers |
**File Operations**:
| Button | Endpoint |
|--------|----------|
| Download Template | `POST FileIO/DownloadPartNumbers``GET FileIO/DownloadPartNumbers?key` |
| Upload Data | `POST FileIO/UploadPartNumbers` |
**Upload Format**: Excel file with column "Item Number" (starting row 2)
**Validation**: At least one item required when filter is active
---
### 4. Profit Center Filter
**Panel Header**: "Filter by profit center"
**Input Method**: Kendo ComboBox with server-side autocomplete
- Minimum 3 characters to trigger search
- Filter: "contains"
- Template displays: Code | Description
- Endpoint: `GET Lookup/FindProfitCenter?profitCenter=...`
**Data Display**: Kendo Grid showing:
- Code (Profit Center)
- Description
- Delete action button
**Actions**:
| Button | Action |
|--------|--------|
| Add to filter | Adds selected profit center from combobox to grid |
| Delete (per row) | Removes profit center from grid |
| Clear Data | Clears grid after confirmation |
**No file upload/download** for this filter.
**Validation**: At least one profit center required when filter is active
---
### 5. Work Center Filter
**Panel Header**: "Filter by work center"
**Input Method**: Kendo ComboBox with server-side autocomplete
- Minimum 3 characters to trigger search
- Filter: "contains"
- Template displays: Code | Description
- Endpoint: `GET Lookup/FindWorkCenter?workCenter=...`
**Data Display**: Kendo Grid showing:
- Code (Work Center)
- Description
- Delete action button
**Actions**:
| Button | Action |
|--------|--------|
| Add to filter | Adds selected work center from combobox to grid |
| Delete (per row) | Removes work center from grid |
| Clear Data | Clears grid after confirmation |
**No file upload/download** for this filter.
**Validation**: At least one work center required when filter is active
---
### 6. Component Lot Filter
**Panel Header**: "Filter by component lot"
**Data Display**: Kendo Grid showing:
- Lot Number
- Item Number
**File Operations**:
| Button | Action | Endpoint |
|--------|--------|----------|
| Download Template | Downloads Excel with current data | `POST FileIO/DownloadComponentLots``GET FileIO/DownloadComponentLots?key` |
| Upload Data | Uploads Excel file, validates lots against DB | `POST FileIO/UploadComponentLots` |
| Clear Data | Clears grid after confirmation dialog | Local action |
**Upload Format**: Excel file with columns "Component Lot Number", "Component Item Number" (starting row 2)
**Validation**: At least one component lot required when filter is active
---
### 7. Operator Filter
**Panel Header**: "Filter by operator"
**Input Method**: Kendo ComboBox with server-side autocomplete
- Minimum 3 characters to trigger search
- Filter: "contains"
- Template displays: Address Number | User ID | Full Name
- Endpoint: `GET Lookup/FindOperator?operatorName=...`
**Data Display**: Kendo Grid showing:
- Address Number
- User ID
- Full Name
- Delete action button
**Actions**:
| Button | Action |
|--------|--------|
| Add to filter | Adds selected operator from combobox to grid |
| Delete (per row) | Removes operator from grid |
| Clear Data | Clears grid after confirmation |
**No file upload/download** for this filter.
**Validation**: At least one operator required when filter is active
---
### 8. Item/Operation/MIS Filter
**Panel Header**: "Filter By Item/Operation/MIS"
**Data Display**: Kendo Grid showing:
- Item Number
- Operation Step Number
- MIS Number
- MIS Revision
**File Operations**:
| Button | Action | Endpoint |
|--------|--------|----------|
| Download Template | Downloads Excel with current data | `POST FileIO/DownloadPartOperations``GET FileIO/DownloadPartOperations?key` |
| Upload Data | Uploads Excel file (no DB validation) | `POST FileIO/UploadPartOperations` |
| Clear Data | Clears grid after confirmation dialog | Local action |
**Upload Format**: Excel file with columns "Item Number", "Operation Number", "MIS Number", "MIS Revision" (starting row 2)
**Note**: Operation numbers with decimals are truncated to integers during upload.
**Validation**: At least one entry required when filter is active
---
### 9. Extract MIS Data Option
**Panel Header**: "Extract MIS data"
**Display**: Read-only checkbox that is automatically checked when this search type is selected. Not user-editable.
---
## API Endpoints Summary
### Search Operations
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `Search/Create` | GET | Renders the search creation view |
| `Search/GetSearch?id=` | GET | Loads existing search or creates blank search |
| `Search/CopySearch?id=` | GET | Creates copy of existing search (resets status/timestamps) |
| `Search/Save` | POST | Saves search criteria, queues for processing |
| `Search/GetResults?id=` | GET | Downloads Excel results for completed search |
### Lookup/Autocomplete
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `Lookup/FindItem?itemNumber=` | GET | Searches items by number (contains filter) |
| `Lookup/FindProfitCenter?profitCenter=` | GET | Searches profit centers by code |
| `Lookup/FindWorkCenter?workCenter=` | GET | Searches work centers by code |
| `Lookup/FindOperator?operatorName=` | GET | Searches operators by name |
### File I/O
| Endpoint | Method | Purpose | Template File |
|----------|--------|---------|---------------|
| `FileIO/UploadWorkOrders` | POST | Upload work order Excel | - |
| `FileIO/DownloadWorkOrders` | POST/GET | Download work order template | `work_order_template.xlsx` |
| `FileIO/UploadPartNumbers` | POST | Upload item number Excel | - |
| `FileIO/DownloadPartNumbers` | POST/GET | Download item template | `item_number_template.xlsx` |
| `FileIO/UploadComponentLots` | POST | Upload component lot Excel | - |
| `FileIO/DownloadComponentLots` | POST/GET | Download component lot template | `component_lot_template.xlsx` |
| `FileIO/UploadPartOperations` | POST | Upload item/op/MIS Excel | - |
| `FileIO/DownloadPartOperations` | POST/GET | Download item/op/MIS template | `item_operations_mis_template.xlsx` |
---
## JavaScript Architecture
### Libraries Used
- **Kendo UI**: Observable viewModel, DataSource, Grid, ComboBox, DatePicker, DropDownList, Validator, Alert, Window
- **jQuery FileUpload**: Handles Excel file uploads with iframe transport
- **SignalR 2.2.1**: Real-time status updates from server
- **js-cookie**: Cookie handling (included but usage not prominent)
- **jQuery UI**: General UI utilities
### ViewModel Structure
The page uses a Kendo Observable viewModel with the following properties:
```javascript
{
// Search details
ID: null,
Name: null,
SubmitDT: null,
StartDT: null,
EndDT: null,
UserName: null,
Status: null,
StatusColor: function(), // Returns '#FF6347' for Error, '#eee' otherwise
// Filter flags (control panel visibility)
TimeSpan_FilterFlag: false,
LotNumbers_FilterFlag: false,
PartNumbers_FilterFlag: false,
ProfitCenters_FilterFlag: false,
WorkCenters_FilterFlag: false,
ComponentLotNumbers_FilterFlag: false,
OperatorIDs_FilterFlag: false,
PartOperations_FilterFlag: false,
ExtractMisData_FilterFlag: false,
// Filter data
MinimumDT: null,
MaximumDT: null,
LotNumbers: DataSource,
PartNumbers: DataSource,
ProfitCenters: DataSource,
WorkCenters: DataSource,
ComponentLotNumbers: DataSource,
OperatorIDs: DataSource,
PartOperations: DataSource,
// Combobox selection state
PartNumbers_AddItem: null,
ProfitCenters_AddItem: null,
WorkCenters_AddItem: null,
OperatorIDs_AddItem: null,
// UI state
IsReadOnly: true,
HasResults: false,
ValidCombinations: [...], // 16 valid search types
SearchType: null
}
```
### Key Functions
| Function | Purpose |
|----------|---------|
| `viewModel.setData(data)` | Populates viewModel from server response, determines search type from filter flags |
| `viewModel.SearchType_Change()` | Shows/hides filter panels based on selected search type |
| `submitSearch()` | Extracts form data, sends to server, handles timeout/errors |
| `loadSearchDetails(id)` | Loads existing search from server |
| `copySearchDetails(id)` | Loads search for copying (resets ID to 0) |
| `showConfirmationWindow(message)` | Displays Kendo confirmation dialog, returns Promise |
| `getParameterByName(name)` | Extracts URL query parameter |
---
## SignalR Integration
### Hub Connection
- Connects to `StatusHub` via `/signalr/hubs`
- Auto-reconnects after 5 seconds on disconnect
### Events
| Event | Purpose |
|-------|---------|
| `searchUpdate` | Receives status updates when search status changes. Updates SubmitDT, StartDT, EndDT, Status, HasResults. |
### Status Values
| Status | Description | UI Behavior |
|--------|-------------|-------------|
| `New` | Not yet submitted | Editable mode |
| `Queued` | Waiting to be processed | Read-only mode |
| `Running` | Currently processing | Read-only mode |
| `Ended` | Completed successfully | Read-only mode, Download Results visible |
| `Error` | Failed | Read-only mode, Status field has red background |
---
## Validation Rules
### Form-Level Validation (Kendo Validator)
1. **Search Type**: Required
2. **Name**: Required
3. **Date Pickers**: Must be valid dates when visible
### Filter-Level Validation (Submit Handler)
When a filter panel is active, its data collection must have at least one item:
| Filter | Validation Message |
|--------|-------------------|
| Work Orders | "At least one work order must be specified for the work order filter." |
| Item Numbers | "At least one item number must be specified for the item number filter." |
| Profit Centers | "At least one profit center must be specified for the profit center filter." |
| Work Centers | "At least one work center must be specified for the work center filter." |
| Component Lots | "At least one component lot must be specified for the component lot filter." |
| Operators | "At least one operator must be specified for the operator filter." |
| Part Operations | "At least one item/operation/MIS entry must be specified for the MIS data filter." |
### Confirmation Dialogs
Shown before:
- Submitting the search
- Clearing any filter data collection
---
## File Upload/Download Flow
### Upload Flow
1. User clicks hidden file input via styled label
2. jQuery FileUpload sends file to endpoint with `autoUpload: true`
3. Server parses Excel, optionally validates against database
4. Server returns `{ WasSuccessful: true/false, Data: [...], ErrorMessage: "..." }`
5. On success, viewModel DataSource is updated with returned data
6. On failure, `alert()` displays error message
### Download Flow
1. User clicks Download Template button
2. Current data is POSTed to server
3. Server generates Excel, caches it with GUID key (1 minute TTL)
4. Server returns cache key
5. Client appends hidden iframe with `src` pointing to GET endpoint with key
6. Browser downloads file via iframe
---
## Dead Code / Legacy References
- `CheckCamstar_Flag`: Referenced in submit payload (`Create.cshtml:1343`) but no corresponding viewModel property or UI element exists. Likely deprecated functionality.
---
## Migration Considerations
### UI Framework Changes
- Replace Kendo UI with MudBlazor or similar Blazor component library
- Replace Kendo Observable with Blazor component state
- Replace Kendo DataSource with standard .NET collections
- Replace Kendo Validator with Blazor EditForm validation
### File Handling
- Replace jQuery FileUpload with Blazor file upload (InputFile component)
- Keep EPPlus for Excel generation
- Consider streaming large files
### Real-Time Updates
- Replace legacy SignalR with ASP.NET Core SignalR
- Update hub connection patterns for Blazor
### API Structure
- Keep similar endpoint structure
- Update controllers for ASP.NET Core
- Consider REST API patterns with proper HTTP methods
### State Management
- Consider Fluxor or similar state management for complex form state
- Or use cascading parameters for simpler approach
+859
View File
@@ -0,0 +1,859 @@
# Search Creation Page - New Implementation Guide
This document provides the implementation specification for the new search creation page (`Search.razor` / `SearchCriteriaForm.razor`) based on the legacy functionality analysis and the project's architecture choices.
## Technology Stack
| Legacy | New | Notes |
|--------|-----|-------|
| Kendo UI | **Radzen Blazor** | Free MIT license, replaces all Kendo components |
| jQuery FileUpload | **Blazor InputFile** | Native Blazor file upload component |
| EPPlus | **ClosedXML** | Free MIT license for Excel generation |
| SignalR 2.2.1 | **ASP.NET Core SignalR** | Modern SignalR with `WithAutomaticReconnect()` |
| Kendo Observable | **Blazor Component State** | Standard Blazor state management |
| jQuery | N/A | Not needed in Blazor |
| .NET Framework 4.8 | **.NET 10** | Target framework |
---
## Project Structure
Based on `BlazorClient.md`, the search functionality spans these files:
```
JdeScoping.Client/
├── Pages/
│ └── Search.razor # Main search page (routes to /search, /search/{id})
├── Components/
│ ├── SearchCriteriaForm.razor # Complex search form with all filter panels
│ ├── SearchStatusCard.razor # Real-time status display
│ └── LookupDropdown.razor # Reusable autocomplete wrapper
├── Services/
│ ├── SearchApiClient.cs # HTTP calls to SearchController
│ ├── LookupApiClient.cs # HTTP calls to LookupController
│ └── StatusHubClient.cs # SignalR connection
└── Models/
├── SearchViewModel.cs # Search details model
├── SearchCriteriaViewModel.cs # Filter criteria model
└── ValidCombination.cs # Search type definitions
```
---
## Radzen Component Mapping
| Legacy Kendo | Radzen Replacement | Usage |
|--------------|-------------------|-------|
| `DropDownList` | `RadzenDropDown` | Search Type selection |
| `ComboBox` (autocomplete) | `RadzenAutoComplete` | Item, Profit Center, Work Center, Operator lookup |
| `DatePicker` | `RadzenDatePicker` | Min/Max date selection |
| `Grid` | `RadzenDataGrid` | Display filter data collections |
| `Button` | `RadzenButton` | Submit, Clear, Add, Delete actions |
| `Alert` | `RadzenNotification` | Validation error messages |
| `Window` (confirm) | `RadzenDialog` | Confirmation dialogs |
| `Validator` | `EditForm` + `DataAnnotationsValidator` | Form validation |
| `ProgressBar` | `RadzenProgressBar` | Loading indicators |
---
## Page Structure
### Search.razor (Page)
```razor
@page "/search"
@page "/search/{Id:int?}"
@inject SearchApiClient SearchApi
@inject StatusHubClient StatusHub
@inject NavigationManager Navigation
<PageTitle>Search</PageTitle>
<RadzenCard>
<h2>
Search
@if (!IsReadOnly)
{
<RadzenButton Text="Submit"
Click="@OnSubmit"
ButtonStyle="ButtonStyle.Primary"
Size="ButtonSize.Small" />
}
</h2>
@if (IsReadOnly)
{
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true">
Search is read-only because it has already been submitted.
<RadzenButton Text="Copy" Click="@OnCopy" />
</RadzenAlert>
}
<SearchCriteriaForm @ref="criteriaForm"
ViewModel="@viewModel"
IsReadOnly="@IsReadOnly"
OnValidSubmit="@OnValidSubmit" />
</RadzenCard>
```
### SearchCriteriaForm.razor (Component)
Contains all filter panels with conditional visibility based on selected search type.
---
## Search Details Panel
| Field | Radzen Component | Binding |
|-------|------------------|---------|
| Search Type | `RadzenDropDown<ValidCombination>` | `@bind-Value="ViewModel.SearchType"` with `Change` event |
| Name | `RadzenTextBox` | `@bind-Value="ViewModel.Name"` |
| Submitted At | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.SubmitDT?.ToString("MM/dd/yyyy hh:mm:ss tt")"` |
| Started At | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.StartDT?.ToString(...)"` |
| Completed At | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.EndDT?.ToString(...)"` |
| User | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.UserName"` |
| Status | `RadzenTextBox` (ReadOnly) | `Value="@ViewModel.Status"` with conditional `Style` |
| Download Results | `RadzenButton` | `Visible="@ViewModel.HasResults"` |
### Status Styling
```csharp
private string GetStatusStyle() => ViewModel.Status == SearchStatus.Error
? "background-color: #FF6347;"
: "background-color: #eee;";
```
---
## Valid Search Type Combinations
Defined in `ValidCombination.cs` as a static list:
```csharp
public record ValidCombination(
int Id,
string Name,
bool Timespan,
bool WorkOrder,
bool ItemNumber,
bool ProfitCenter,
bool WorkCenter,
bool ComponentLot,
bool Operator,
bool ItemOperationMis,
bool ExtractMis);
public static class ValidCombinations
{
public static readonly List<ValidCombination> All = new()
{
new(10, "Work Order", false, true, false, false, false, false, false, false, false),
new(20, "Component Lot", false, false, false, false, false, true, false, false, false),
new(30, "Time Span + Profit Center", true, false, false, true, false, false, false, false, false),
new(40, "Time Span + Work Center", true, false, false, false, true, false, false, false, false),
new(50, "Time Span + Operator", true, false, false, false, false, false, true, false, false),
new(60, "Time Span + Profit Center + Item Number", true, false, true, true, false, false, false, false, false),
new(70, "Time Span + Profit Center + Item/Operation/MIS", true, false, false, true, false, false, false, true, false),
new(80, "Time Span + Profit Center + Work Order + Item/Operation/MIS", true, true, false, true, false, false, false, true, false),
new(90, "Time Span + Profit Center + Extract MIS", true, false, false, true, false, false, false, false, true),
new(100, "Time Span + Work Center + Item Number", true, false, true, false, true, false, false, false, false),
new(110, "Time Span + Work Center + Extract MIS", true, false, false, false, true, false, false, false, true),
new(120, "Time Span + Work Center + Item/Operation/MIS", true, false, false, false, true, false, false, true, false),
new(130, "Time Span + Work Center + Work Order + Item/Operation/MIS", true, true, false, false, true, false, false, true, false),
new(140, "Time Span + Item Number", true, false, true, false, false, false, false, false, false),
new(150, "Time Span + Work Center + Operator", true, false, false, false, true, false, true, false, false),
new(160, "Time Span + Profit Center + Operator", true, false, false, true, false, false, true, false, false)
};
}
```
---
## Filter Panels
### 1. Time Span Filter
```razor
@if (ViewModel.SearchType?.Timespan == true)
{
<RadzenCard>
<RadzenText TextStyle="TextStyle.H6">Filter by timespan</RadzenText>
<div class="row">
<div class="col-md-5">
<RadzenLabel Text="Min Date" />
<RadzenDatePicker @bind-Value="ViewModel.MinimumDT"
Min="@(new DateTime(2002, 11, 1))"
Max="@(ViewModel.MaximumDT ?? DateTime.Today)"
Disabled="@IsReadOnly" />
</div>
<div class="col-md-5 offset-md-1">
<RadzenLabel Text="Max Date" />
<RadzenDatePicker @bind-Value="ViewModel.MaximumDT"
Min="@(ViewModel.MinimumDT ?? new DateTime(2002, 11, 1))"
Max="@DateTime.Today"
Disabled="@IsReadOnly" />
</div>
</div>
</RadzenCard>
}
```
---
### 2. Work Order Filter (with file upload/download)
```razor
@if (ViewModel.SearchType?.WorkOrder == true)
{
<RadzenCard>
<div class="d-flex justify-content-between align-items-center">
<RadzenText TextStyle="TextStyle.H6">Filter by work order</RadzenText>
@if (!IsReadOnly)
{
<div class="btn-group">
<RadzenButton Text="Download Template"
Click="@DownloadWorkOrderTemplate"
ButtonStyle="ButtonStyle.Light" />
<InputFile OnChange="@UploadWorkOrders" accept=".xlsx" />
<RadzenButton Text="Clear Data"
Click="@ClearWorkOrders"
ButtonStyle="ButtonStyle.Light" />
</div>
}
</div>
<RadzenDataGrid Data="@ViewModel.WorkOrders" TItem="WorkOrderViewModel">
<Columns>
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="WorkOrderNumber" Title="Work Order Number" />
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="ItemNumber" Title="Item Number" />
</Columns>
</RadzenDataGrid>
<RadzenText><strong># of work orders:</strong> @ViewModel.WorkOrders.Count</RadzenText>
</RadzenCard>
}
```
---
### 3. Item Number Filter (with autocomplete + file upload)
```razor
@if (ViewModel.SearchType?.ItemNumber == true)
{
<RadzenCard>
<div class="d-flex justify-content-between align-items-center">
<RadzenText TextStyle="TextStyle.H6">Filter by item number</RadzenText>
@if (!IsReadOnly)
{
<div class="btn-group">
<RadzenButton Text="Download Template" Click="@DownloadItemTemplate" />
<InputFile OnChange="@UploadItems" accept=".xlsx" />
<RadzenButton Text="Clear Data" Click="@ClearItems" />
</div>
}
</div>
@if (!IsReadOnly)
{
<div class="form-group">
<RadzenLabel Text="Item Number" />
<RadzenAutoComplete @bind-Value="selectedItemText"
Data="@itemSearchResults"
TextProperty="ItemNumber"
MinLength="3"
LoadData="@SearchItems"
Placeholder="Type 3+ characters to search..."
Style="width: 550px;" />
<RadzenButton Text="Add to filter"
Click="@AddSelectedItem"
Visible="@(selectedItem != null)" />
</div>
}
<RadzenDataGrid Data="@ViewModel.Items" TItem="ItemViewModel">
<Columns>
<RadzenDataGridColumn TItem="ItemViewModel" Property="ItemNumber" Title="Item Number" />
<RadzenDataGridColumn TItem="ItemViewModel" Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="ItemViewModel" Width="100px" Title="Actions">
<Template Context="item">
<RadzenButton Text="Delete" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
<RadzenText><strong># of item numbers:</strong> @ViewModel.Items.Count</RadzenText>
</RadzenCard>
}
```
---
### 4-5. Profit Center / Work Center Filters (autocomplete only, no file upload)
```razor
@if (ViewModel.SearchType?.ProfitCenter == true)
{
<RadzenCard>
<div class="d-flex justify-content-between align-items-center">
<RadzenText TextStyle="TextStyle.H6">Filter by profit center</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Click="@ClearProfitCenters" />
}
</div>
@if (!IsReadOnly)
{
<LookupDropdown TItem="ProfitCenterViewModel"
TextProperty="Code"
SearchEndpoint="@LookupApi.SearchProfitCentersAsync"
OnItemSelected="@AddProfitCenter"
Placeholder="Type 3+ characters to search..." />
}
<RadzenDataGrid Data="@ViewModel.ProfitCenters" TItem="ProfitCenterViewModel">
<Columns>
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Code" Title="Profit Center" />
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Width="100px" Title="Actions">
<Template Context="item">
<RadzenButton Text="Delete" Click="@(() => DeleteProfitCenter(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
</RadzenCard>
}
```
Work Center filter follows the same pattern with `WorkCenterViewModel`.
---
### 6. Component Lot Filter (file upload only)
```razor
@if (ViewModel.SearchType?.ComponentLot == true)
{
<RadzenCard>
<div class="d-flex justify-content-between align-items-center">
<RadzenText TextStyle="TextStyle.H6">Filter by component lot</RadzenText>
@if (!IsReadOnly)
{
<div class="btn-group">
<RadzenButton Text="Download Template" Click="@DownloadComponentLotTemplate" />
<InputFile OnChange="@UploadComponentLots" accept=".xlsx" />
<RadzenButton Text="Clear Data" Click="@ClearComponentLots" />
</div>
}
</div>
<RadzenDataGrid Data="@ViewModel.ComponentLots" TItem="LotViewModel">
<Columns>
<RadzenDataGridColumn TItem="LotViewModel" Property="LotNumber" Title="Lot Number" />
<RadzenDataGridColumn TItem="LotViewModel" Property="ItemNumber" Title="Item Number" />
</Columns>
</RadzenDataGrid>
<RadzenText><strong># of component lots:</strong> @ViewModel.ComponentLots.Count</RadzenText>
</RadzenCard>
}
```
---
### 7. Operator Filter (autocomplete only)
```razor
@if (ViewModel.SearchType?.Operator == true)
{
<RadzenCard>
<div class="d-flex justify-content-between align-items-center">
<RadzenText TextStyle="TextStyle.H6">Filter by operator</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Click="@ClearOperators" />
}
</div>
@if (!IsReadOnly)
{
<LookupDropdown TItem="JdeUserViewModel"
TextProperty="FullName"
SearchEndpoint="@LookupApi.SearchOperatorsAsync"
OnItemSelected="@AddOperator"
Placeholder="Type 3+ characters to search..." />
}
<RadzenDataGrid Data="@ViewModel.Operators" TItem="JdeUserViewModel">
<Columns>
<RadzenDataGridColumn TItem="JdeUserViewModel" Property="AddressNumber" Title="Address Number" />
<RadzenDataGridColumn TItem="JdeUserViewModel" Property="UserID" Title="User Name" />
<RadzenDataGridColumn TItem="JdeUserViewModel" Property="FullName" Title="Full Name" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="JdeUserViewModel" Width="100px" Title="Actions">
<Template Context="item">
<RadzenButton Text="Delete" Click="@(() => DeleteOperator(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
</RadzenCard>
}
```
---
### 8. Item/Operation/MIS Filter (file upload only)
```razor
@if (ViewModel.SearchType?.ItemOperationMis == true)
{
<RadzenCard>
<div class="d-flex justify-content-between align-items-center">
<RadzenText TextStyle="TextStyle.H6">Filter By Item/Operation/MIS</RadzenText>
@if (!IsReadOnly)
{
<div class="btn-group">
<RadzenButton Text="Download Template" Click="@DownloadPartOperationTemplate" />
<InputFile OnChange="@UploadPartOperations" accept=".xlsx" />
<RadzenButton Text="Clear Data" Click="@ClearPartOperations" />
</div>
}
</div>
<RadzenDataGrid Data="@ViewModel.PartOperations" TItem="PartOperationViewModel">
<Columns>
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="ItemNumber" Title="Item Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="OperationNumber" Title="Operation Step Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="MisNumber" Title="MIS Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="MisRevision" Title="MIS Revision" />
</Columns>
</RadzenDataGrid>
<RadzenText><strong># of item / operations:</strong> @ViewModel.PartOperations.Count</RadzenText>
</RadzenCard>
}
```
---
### 9. Extract MIS Data Option
```razor
@if (ViewModel.SearchType?.ExtractMis == true)
{
<RadzenCard>
<RadzenCheckBox @bind-Value="@extractMisChecked"
Disabled="true"
Name="ExtractMisData" />
<RadzenLabel Text="Extract MIS data" Component="ExtractMisData" />
</RadzenCard>
}
@code {
private bool extractMisChecked = true; // Always true when this panel is visible
}
```
---
## API Clients
### SearchApiClient.cs
```csharp
public class SearchApiClient
{
private readonly HttpClient _http;
public SearchApiClient(HttpClient http) => _http = http;
public async Task<SearchViewModel?> GetSearchAsync(int? id) =>
await _http.GetFromJsonAsync<SearchViewModel>($"api/search/{id}");
public async Task<SearchViewModel?> CopySearchAsync(int id) =>
await _http.GetFromJsonAsync<SearchViewModel>($"api/search/{id}/copy");
public async Task<int> SaveAsync(SearchViewModel viewModel) =>
await _http.PostAsJsonAsync("api/search", viewModel)
.Result.Content.ReadFromJsonAsync<int>();
public async Task<byte[]> GetResultsAsync(int id) =>
await _http.GetByteArrayAsync($"api/search/{id}/results");
}
```
### LookupApiClient.cs
```csharp
public class LookupApiClient
{
private readonly HttpClient _http;
public LookupApiClient(HttpClient http) => _http = http;
public async Task<IEnumerable<ItemViewModel>> SearchItemsAsync(string query) =>
await _http.GetFromJsonAsync<IEnumerable<ItemViewModel>>($"api/lookup/items?q={query}")
?? Enumerable.Empty<ItemViewModel>();
public async Task<IEnumerable<ProfitCenterViewModel>> SearchProfitCentersAsync(string query) =>
await _http.GetFromJsonAsync<IEnumerable<ProfitCenterViewModel>>($"api/lookup/profitcenters?q={query}")
?? Enumerable.Empty<ProfitCenterViewModel>();
public async Task<IEnumerable<WorkCenterViewModel>> SearchWorkCentersAsync(string query) =>
await _http.GetFromJsonAsync<IEnumerable<WorkCenterViewModel>>($"api/lookup/workcenters?q={query}")
?? Enumerable.Empty<WorkCenterViewModel>();
public async Task<IEnumerable<JdeUserViewModel>> SearchOperatorsAsync(string query) =>
await _http.GetFromJsonAsync<IEnumerable<JdeUserViewModel>>($"api/lookup/operators?q={query}")
?? Enumerable.Empty<JdeUserViewModel>();
}
```
---
## File I/O with ClosedXML
### FileIOController.cs (Server-side)
```csharp
using ClosedXML.Excel;
[ApiController]
[Route("api/fileio")]
public class FileIOController : ControllerBase
{
[HttpPost("workorders/upload")]
public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(IFormFile file)
{
using var stream = file.OpenReadStream();
using var workbook = new XLWorkbook(stream);
var worksheet = workbook.Worksheet(1);
var workOrderNumbers = new List<long>();
foreach (var row in worksheet.RowsUsed().Skip(1)) // Skip header
{
if (long.TryParse(row.Cell(1).GetString().Trim(), out var num))
workOrderNumbers.Add(num);
}
var validated = await _db.LookupWorkOrdersAsync(workOrderNumbers);
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = true,
Data = validated
});
}
[HttpPost("workorders/download")]
public IActionResult DownloadWorkOrders([FromBody] List<long> workOrders)
{
using var workbook = new XLWorkbook();
var worksheet = workbook.Worksheets.Add("Work Orders");
worksheet.Cell(1, 1).Value = "Work Order Number";
for (int i = 0; i < workOrders.Count; i++)
worksheet.Cell(i + 2, 1).Value = workOrders[i];
using var stream = new MemoryStream();
workbook.SaveAs(stream);
return File(stream.ToArray(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"work_order_template.xlsx");
}
// Similar methods for PartNumbers, ComponentLots, PartOperations...
}
```
### Blazor File Upload Handler
```csharp
private async Task UploadWorkOrders(InputFileChangeEventArgs e)
{
var file = e.File;
using var content = new MultipartFormDataContent();
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
content.Add(new StreamContent(stream), "file", file.Name);
var response = await Http.PostAsync("api/fileio/workorders/upload", content);
var result = await response.Content.ReadFromJsonAsync<FileUploadResult<WorkOrderViewModel>>();
if (result?.WasSuccessful == true)
{
ViewModel.WorkOrders = result.Data.ToList();
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result?.ErrorMessage);
}
}
```
### Blazor File Download Handler
```csharp
private async Task DownloadWorkOrderTemplate()
{
var workOrderNumbers = ViewModel.WorkOrders.Select(w => w.WorkOrderNumber).ToList();
var response = await Http.PostAsJsonAsync("api/fileio/workorders/download", workOrderNumbers);
var bytes = await response.Content.ReadAsByteArrayAsync();
// Use JS interop to trigger download
await JSRuntime.InvokeVoidAsync("downloadFile", "work_order_template.xlsx", bytes);
}
```
**wwwroot/js/download.js:**
```javascript
window.downloadFile = (fileName, byteArray) => {
const blob = new Blob([new Uint8Array(byteArray)]);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
};
```
---
## SignalR Integration
### StatusHubClient.cs
```csharp
public class StatusHubClient : IAsyncDisposable
{
private HubConnection? _connection;
public event Action<SearchStatusUpdate>? OnStatusChanged;
public async Task ConnectAsync(string baseUrl)
{
_connection = new HubConnectionBuilder()
.WithUrl($"{baseUrl}/hubs/status")
.WithAutomaticReconnect()
.Build();
_connection.On<SearchStatusUpdate>("StatusChanged", update =>
{
OnStatusChanged?.Invoke(update);
});
await _connection.StartAsync();
}
public async ValueTask DisposeAsync()
{
if (_connection != null)
await _connection.DisposeAsync();
}
}
```
### Usage in Search.razor
```csharp
@implements IAsyncDisposable
@code {
protected override async Task OnInitializedAsync()
{
StatusHub.OnStatusChanged += HandleStatusChanged;
await StatusHub.ConnectAsync(Navigation.BaseUri);
}
private void HandleStatusChanged(SearchStatusUpdate update)
{
if (update.Id == viewModel?.ID)
{
viewModel.SubmitDT = update.SubmitDT;
viewModel.StartDT = update.StartDT;
viewModel.EndDT = update.EndDT;
viewModel.Status = update.Status;
viewModel.HasResults = update.Status == SearchStatus.Ended;
StateHasChanged();
}
}
public async ValueTask DisposeAsync()
{
StatusHub.OnStatusChanged -= HandleStatusChanged;
await StatusHub.DisposeAsync();
}
}
```
---
## Validation
### Form-Level Validation with EditForm
```razor
<EditForm Model="@ViewModel" OnValidSubmit="@OnValidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<!-- Form content -->
</EditForm>
```
### SearchViewModel Validation Attributes
```csharp
public class SearchViewModel
{
public int? ID { get; set; }
[Required(ErrorMessage = "Name is required.")]
public string? Name { get; set; }
public string? UserName { get; set; }
[Required(ErrorMessage = "Search Type is required.")]
public ValidCombination? SearchType { get; set; }
public SearchStatus Status { get; set; }
// ... other properties
}
```
### Custom Filter Validation (Submit Handler)
```csharp
private async Task OnValidSubmit()
{
// Filter-level validation
if (ViewModel.SearchType?.WorkOrder == true && !ViewModel.WorkOrders.Any())
{
NotificationService.Notify(NotificationSeverity.Error,
"Validation Error",
"At least one work order must be specified for the work order filter.");
return;
}
if (ViewModel.SearchType?.ItemNumber == true && !ViewModel.Items.Any())
{
NotificationService.Notify(NotificationSeverity.Error,
"Validation Error",
"At least one item number must be specified for the item number filter.");
return;
}
// Similar checks for ProfitCenters, WorkCenters, ComponentLots, Operators, PartOperations
// Confirmation dialog
var confirmed = await DialogService.Confirm(
"Are you sure you want to submit the search?",
"Confirm Submit",
new ConfirmOptions { OkButtonText = "OK", CancelButtonText = "Cancel" });
if (confirmed == true)
{
await SubmitSearch();
}
}
```
---
## Confirmation Dialogs
Using `RadzenDialog` service:
```csharp
@inject DialogService DialogService
private async Task ClearWorkOrders()
{
var confirmed = await DialogService.Confirm(
"Are you sure you want to clear all work orders?",
"Action Confirmation",
new ConfirmOptions { OkButtonText = "OK", CancelButtonText = "Cancel" });
if (confirmed == true)
{
ViewModel.WorkOrders.Clear();
}
}
```
---
## API Endpoints Summary (New)
### Search Operations
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `api/search/{id?}` | GET | Get search by ID or create blank |
| `api/search/{id}/copy` | GET | Copy existing search |
| `api/search` | POST | Save search criteria |
| `api/search/{id}/results` | GET | Download Excel results |
### Lookup/Autocomplete
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `api/lookup/items?q=` | GET | Search items |
| `api/lookup/profitcenters?q=` | GET | Search profit centers |
| `api/lookup/workcenters?q=` | GET | Search work centers |
| `api/lookup/operators?q=` | GET | Search operators |
### File I/O
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `api/fileio/workorders/upload` | POST | Upload work order Excel |
| `api/fileio/workorders/download` | POST | Download work order template |
| `api/fileio/items/upload` | POST | Upload item Excel |
| `api/fileio/items/download` | POST | Download item template |
| `api/fileio/componentlots/upload` | POST | Upload component lot Excel |
| `api/fileio/componentlots/download` | POST | Download component lot template |
| `api/fileio/partoperations/upload` | POST | Upload part operation Excel |
| `api/fileio/partoperations/download` | POST | Download part operation template |
---
## Status Values
| Status | Description | UI Behavior |
|--------|-------------|-------------|
| `New` | Not yet submitted | Editable mode |
| `Queued` | Waiting to be processed | Read-only mode |
| `Running` | Currently processing | Read-only mode |
| `Ended` | Completed successfully | Read-only mode, Download Results visible |
| `Error` | Failed | Read-only mode, Status field has red background |
---
## Removed/Deprecated
- **CheckCamstar_Flag**: Not migrated (dead code in legacy)
- **jQuery FileUpload**: Replaced with Blazor `InputFile`
- **Kendo Observable**: Replaced with Blazor component state
- **EPPlus**: Replaced with **ClosedXML** (MIT license)
- **iframe download trick**: Replaced with JS interop blob download
---
## Related Documentation
- [Architecture Overview](./Architecture/Overview.md)
- [Blazor Client](./Architecture/BlazorClient.md)
- [Dependencies](./Architecture/Dependencies.md)
- [Data Flow](./Architecture/DataFlow.md)
+27
View File
@@ -0,0 +1,27 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/JdeScoping.Api/JdeScoping.Api.csproj" />
<Project Path="src/JdeScoping.Client/JdeScoping.Client.csproj" />
<Project Path="src/JdeScoping.Core/JdeScoping.Core.csproj" />
<Project Path="src/JdeScoping.DataAccess/JdeScoping.DataAccess.csproj" />
<Project Path="src/JdeScoping.Database/JdeScoping.Database.csproj" />
<Project Path="src/JdeScoping.DataSync.SourceGenerators/JdeScoping.DataSync.SourceGenerators.csproj" />
<Project Path="src/JdeScoping.DataSync/JdeScoping.DataSync.csproj" />
<Project Path="src/JdeScoping.ExcelIO/JdeScoping.ExcelIO.csproj" />
<Project Path="src/JdeScoping.Host/JdeScoping.Host.csproj" />
<Project Path="src/JdeScoping.Infrastructure/JdeScoping.Infrastructure.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/JdeScoping.Api.Tests/JdeScoping.Api.Tests.csproj" />
<Project Path="tests/JdeScoping.Api.IntegrationTests/JdeScoping.Api.IntegrationTests.csproj" />
<Project Path="tests/JdeScoping.Client.Tests/JdeScoping.Client.Tests.csproj" />
<Project Path="tests/JdeScoping.Core.Tests/JdeScoping.Core.Tests.csproj" />
<Project Path="tests/JdeScoping.DataAccess.Tests/JdeScoping.DataAccess.Tests.csproj" />
<Project Path="tests/JdeScoping.Database.Tests/JdeScoping.Database.Tests.csproj" />
<Project Path="tests/JdeScoping.DataSync.IntegrationTests/JdeScoping.DataSync.IntegrationTests.csproj" />
<Project Path="tests/JdeScoping.DataSync.Tests/JdeScoping.DataSync.Tests.csproj" />
<Project Path="tests/JdeScoping.ExcelIO.Tests/JdeScoping.ExcelIO.Tests.csproj" />
<Project Path="tests/JdeScoping.Host.Tests/JdeScoping.Host.Tests.csproj" />
<Project Path="tests/JdeScoping.Infrastructure.Tests/JdeScoping.Infrastructure.Tests.csproj" />
</Folder>
</Solution>
@@ -0,0 +1,26 @@
using System.Security.Claims;
using JdeScoping.Api.Extensions;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Mvc;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Base controller providing access to current user context
/// </summary>
[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
/// <summary>
/// Gets the current authenticated user from claims.
/// Returns null if not authenticated.
/// </summary>
protected UserInfo? CurrentUser => User.Identity?.IsAuthenticated == true
? User.ToUserInfo()
: null;
/// <summary>
/// Gets the current username from claims.
/// </summary>
protected string? CurrentUserName => User.FindFirstValue(ClaimTypes.Name);
}
@@ -0,0 +1,100 @@
using System.Security.Claims;
using JdeScoping.Api.Extensions;
using JdeScoping.Api.Models;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Authentication endpoints for Blazor WASM client
/// </summary>
[Route("api/auth")]
[ApiController]
public class AuthController : ApiControllerBase
{
private readonly IAuthService _authService;
private readonly ILogger<AuthController> _logger;
public AuthController(
IAuthService authService,
ILogger<AuthController> logger)
{
_authService = authService;
_logger = logger;
}
/// <summary>
/// Authenticates a user and creates a session cookie
/// </summary>
/// <param name="request">Login credentials</param>
/// <param name="ct">Cancellation token</param>
/// <returns>User info on success, 401 on failure</returns>
[HttpPost("login")]
[AllowAnonymous]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public async Task<ActionResult<UserInfo>> Login(
[FromBody] LoginRequest request,
CancellationToken ct)
{
var result = await _authService.AuthenticateAsync(
request.Username, request.Password, ct);
if (!result.Success)
{
_logger.LogWarning("Failed login attempt for user {Username}", request.Username);
return Unauthorized(new { message = result.ErrorMessage });
}
// Sign out existing session
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// Create claims identity from user info
var identity = ClaimsExtensions.FromUserInfo(result.User!);
var principal = new ClaimsPrincipal(identity);
// Sign in with non-persistent cookie
await HttpContext.SignInAsync(
CookieAuthenticationDefaults.AuthenticationScheme,
principal,
new AuthenticationProperties { IsPersistent = false });
_logger.LogInformation("User {Username} logged in successfully", request.Username);
return Ok(result.User);
}
/// <summary>
/// Logs out the current user
/// </summary>
/// <returns>200 OK on success</returns>
[HttpPost("logout")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> Logout()
{
var username = CurrentUserName;
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
_logger.LogInformation("User {Username} logged out", username);
return Ok();
}
/// <summary>
/// Gets the current authenticated user's information
/// </summary>
/// <returns>User info on success, 401 if not authenticated</returns>
[HttpGet("me")]
[Authorize]
[ProducesResponseType(typeof(UserInfo), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public ActionResult<UserInfo> GetCurrentUser()
{
return Ok(CurrentUser);
}
}
@@ -0,0 +1,34 @@
using JdeScoping.Core.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Handles file upload/download operations for Excel templates.
/// </summary>
[Authorize]
[ApiController]
[Route("api/fileio")]
public partial class FileIOController : ApiControllerBase
{
private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
private readonly ILotFinderRepository _repository;
private readonly IExcelParserService _parserService;
private readonly IExcelTemplateService _templateService;
private readonly ILogger<FileIOController> _logger;
public FileIOController(
ILotFinderRepository repository,
IExcelParserService parserService,
IExcelTemplateService templateService,
ILogger<FileIOController> logger)
{
_repository = repository;
_parserService = parserService;
_templateService = templateService;
_logger = logger;
}
}
@@ -0,0 +1,75 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Component lot file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing component lot/item pairs and returns the matched lots
/// </summary>
[HttpPost("componentlots/upload")]
[ProducesResponseType(typeof(FileUploadResult<LotViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<LotViewModel>>> UploadComponentLots(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var lotViewModels = _parserService.ParseComponentLots(stream);
var lots = await _repository.LookupLotsAsync(lotViewModels, ct);
var viewModels = lots
.Select(l => l.ToViewModel())
.DistinctBy(l => new { l.LotNumber, l.ItemNumber })
.OrderBy(l => l.LotNumber)
.ToArray();
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded component lots file");
return Ok(new FileUploadResult<LotViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current component lot data
/// </summary>
[HttpPost("componentlots/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadComponentLots([FromBody] List<LotViewModel>? lotNumbers)
{
var sourceData = (lotNumbers ?? [])
.Select(l => new object?[] { l.LotNumber, l.ItemNumber })
.ToArray();
var headers = new[] { "Component Lot Number", "Component Item Number" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "component_lot_template.xlsx");
}
}
@@ -0,0 +1,74 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Item file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing item numbers and returns the matched items
/// </summary>
[HttpPost("items/upload")]
[ProducesResponseType(typeof(FileUploadResult<ItemViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<ItemViewModel>>> UploadItems(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var itemNumbers = _parserService.ParseItems(stream);
var items = await _repository.LookupItemsAsync(itemNumbers, ct);
var viewModels = items
.Select(i => i.ToViewModel())
.DistinctBy(i => new { i.ItemNumber, i.Description })
.ToArray();
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded items file");
return Ok(new FileUploadResult<ItemViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current item data
/// </summary>
[HttpPost("items/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadItems([FromBody] List<ItemViewModel>? items)
{
var sourceData = (items ?? [])
.Select(i => new object?[] { i.ItemNumber })
.ToArray();
var headers = new[] { "Item Number" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "item_number_template.xlsx");
}
}
@@ -0,0 +1,66 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Part operations file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing part operations and returns the parsed data
/// </summary>
[HttpPost("partoperations/upload")]
[ProducesResponseType(typeof(FileUploadResult<PartOperationViewModel>), StatusCodes.Status200OK)]
public ActionResult<FileUploadResult<PartOperationViewModel>> UploadPartOperations(IFormFile? file)
{
if (file is null)
{
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var partOperations = _parserService.ParsePartOperations(stream);
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = true,
Data = partOperations.ToArray()
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded part operations file");
return Ok(new FileUploadResult<PartOperationViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current part operation data
/// </summary>
[HttpPost("partoperations/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadPartOperations([FromBody] List<PartOperationViewModel>? partOperations)
{
var sourceData = (partOperations ?? [])
.Select(po => new object?[] { po.ItemNumber, po.OperationNumber, po.MisNumber, po.MisRevision })
.ToArray();
var headers = new[] { "Item Number", "Operation Number", "MIS Number", "MIS Revision" };
var data = _templateService.GenerateMultiColumn(sourceData, headers);
return File(data, ContentType, "item_operations_mis_template.xlsx");
}
}
@@ -0,0 +1,71 @@
using JdeScoping.Api.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Work order file operations.
/// </summary>
public partial class FileIOController
{
/// <summary>
/// Uploads an Excel file containing work order numbers and returns the matched work orders
/// </summary>
[HttpPost("workorders/upload")]
[ProducesResponseType(typeof(FileUploadResult<WorkOrderViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<FileUploadResult<WorkOrderViewModel>>> UploadWorkOrders(
IFormFile? file,
CancellationToken ct)
{
if (file is null)
{
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = false,
ErrorMessage = "No file uploaded"
});
}
try
{
using var stream = file.OpenReadStream();
var workOrderNumbers = _parserService.ParseWorkOrders(stream);
var workOrders = await _repository.LookupWorkordersAsync(workOrderNumbers, ct);
var viewModels = workOrders
.Select(wo => wo.ToViewModel())
.DistinctBy(wo => new { wo.WorkOrderNumber, wo.ItemNumber })
.OrderBy(wo => wo.WorkOrderNumber)
.ToArray();
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = true,
Data = viewModels
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to parse uploaded work order file");
return Ok(new FileUploadResult<WorkOrderViewModel>
{
WasSuccessful = false,
ErrorMessage = "Failed to parse uploaded file"
});
}
}
/// <summary>
/// Downloads an Excel template with current work order data
/// </summary>
[HttpPost("workorders/download")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadWorkOrders([FromBody] List<long>? workOrders)
{
var data = _templateService.GenerateSingleColumn(workOrders ?? [], "Work Order Number");
return File(data, ContentType, "work_order_template.xlsx");
}
}
@@ -0,0 +1,93 @@
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Lookup/autocomplete endpoints (no authorization required)
/// </summary>
[Route("api/lookup")]
[ApiController]
public class LookupController : ApiControllerBase
{
private readonly ILotFinderRepository _repository;
public LookupController(ILotFinderRepository repository)
{
_repository = repository;
}
/// <summary>
/// Searches for items matching the query
/// </summary>
/// <param name="q">Search query for item number or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("items")]
[ProducesResponseType(typeof(IEnumerable<ItemViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ItemViewModel>>> FindItems(
[FromQuery] string q,
CancellationToken ct)
{
var items = await _repository.SearchItemsAsync(q ?? string.Empty, ct);
var viewModels = items
.OrderBy(i => i.ItemNumber)
.Select(i => i.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for profit centers matching the query
/// </summary>
/// <param name="q">Search query for profit center code or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("profit-centers")]
[ProducesResponseType(typeof(IEnumerable<ProfitCenterViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ProfitCenterViewModel>>> FindProfitCenters(
[FromQuery] string q,
CancellationToken ct)
{
var centers = await _repository.SearchProfitCentersAsync(q ?? string.Empty, ct);
var viewModels = centers
.OrderBy(pc => pc.Code)
.Select(pc => pc.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for work centers matching the query
/// </summary>
/// <param name="q">Search query for work center code or description</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("work-centers")]
[ProducesResponseType(typeof(IEnumerable<WorkCenterViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<WorkCenterViewModel>>> FindWorkCenters(
[FromQuery] string q,
CancellationToken ct)
{
var centers = await _repository.SearchWorkCentersAsync(q ?? string.Empty, ct);
var viewModels = centers
.OrderBy(wc => wc.Code)
.Select(wc => wc.ToViewModel());
return Ok(viewModels);
}
/// <summary>
/// Searches for operators (JDE users) matching the query
/// </summary>
/// <param name="q">Search query for operator name or ID</param>
/// <param name="ct">Cancellation token</param>
[HttpGet("operators")]
[ProducesResponseType(typeof(IEnumerable<JdeUserViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<JdeUserViewModel>>> FindOperators(
[FromQuery] string q,
CancellationToken ct)
{
var users = await _repository.SearchUsersAsync(q ?? string.Empty, ct);
var viewModels = users
.OrderBy(u => u.FullName)
.Select(u => u.ToViewModel());
return Ok(viewModels);
}
}
@@ -0,0 +1,163 @@
using JdeScoping.Api.Hubs;
using JdeScoping.Core.Interfaces;
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Enums;
using JdeScoping.Core.Models.Search;
using JdeScoping.Core.ViewModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Controllers;
/// <summary>
/// Search management controller
/// </summary>
[Route("api/search")]
[ApiController]
[Authorize]
public class SearchController : ApiControllerBase
{
private readonly ILotFinderRepository _repository;
private readonly IHubContext<StatusHub> _hubContext;
private readonly ILogger<SearchController> _logger;
public SearchController(
ILotFinderRepository repository,
IHubContext<StatusHub> hubContext,
ILogger<SearchController> logger)
{
_repository = repository;
_hubContext = hubContext;
_logger = logger;
}
/// <summary>
/// Gets all searches for the current user
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetSearches(CancellationToken ct)
{
var searches = await _repository.GetUserSearchesAsync(CurrentUserName!, ct);
var viewModels = searches
.OrderByDescending(s => s.StartDt)
.Select(s => new SearchViewModel(s));
return Ok(viewModels);
}
/// <summary>
/// Gets all queued searches
/// </summary>
[HttpGet("queue")]
[ProducesResponseType(typeof(IEnumerable<SearchViewModel>), StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<SearchViewModel>>> GetQueuedSearches(CancellationToken ct)
{
var searches = await _repository.GetQueuedSearchesAsync(ct);
var viewModels = searches.Select(s => new SearchViewModel(s));
return Ok(viewModels);
}
/// <summary>
/// Gets a single search by ID
/// </summary>
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SearchViewModel>> GetSearch(int id, CancellationToken ct)
{
var search = await _repository.GetSearchAsync(id, ct);
if (search is null)
{
return NotFound();
}
return Ok(new SearchViewModel(search));
}
/// <summary>
/// Copies an existing search for the current user (returns copy without persisting)
/// </summary>
[HttpGet("{id:int}/copy")]
[ProducesResponseType(typeof(SearchViewModel), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<SearchViewModel>> CopySearch(int id, CancellationToken ct)
{
var original = await _repository.GetSearchAsync(id, ct);
if (original is null)
{
return NotFound();
}
// Return a copy with reset status/timestamps (not persisted until user submits)
var copy = new Search
{
Id = 0,
UserName = CurrentUserName!,
Name = original.Name,
Status = SearchStatus.New,
SubmitDt = null,
StartDt = null,
EndDt = null,
CriteriaJson = original.CriteriaJson
};
return Ok(new SearchViewModel(copy));
}
/// <summary>
/// Creates a new search
/// </summary>
[HttpPost]
[ProducesResponseType(typeof(int), StatusCodes.Status201Created)]
public async Task<ActionResult<int>> CreateSearch(
[FromBody] SearchViewModel viewModel,
CancellationToken ct)
{
var search = viewModel.ToEntity();
search.UserName = CurrentUserName!;
var searchId = await _repository.SubmitSearchAsync(search, ct);
// Publish to SignalR (best-effort, swallow exceptions)
try
{
var searchUpdate = new SearchUpdate
{
Id = searchId,
UserName = CurrentUserName!,
Name = search.Name,
Status = search.Status,
Timestamp = DateTime.UtcNow
};
await _hubContext.Clients.All.SendAsync("searchUpdate", searchUpdate, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to publish search update to SignalR");
}
return CreatedAtAction(nameof(GetSearch), new { id = searchId }, searchId);
}
/// <summary>
/// Downloads search results as an Excel file
/// </summary>
[HttpGet("{id:int}/results")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetResults(int id, CancellationToken ct)
{
var data = await _repository.GetSearchResultsAsync(id, ct);
if (data is null || data.Length == 0)
{
return NotFound();
}
return File(
data,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"search_results.xlsx");
}
}
@@ -0,0 +1,110 @@
using System.Text.Json.Serialization;
using JdeScoping.Api.Hubs;
using JdeScoping.Core.Options;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.OpenApi.Models;
namespace Microsoft.Extensions.DependencyInjection;
/// <summary>
/// Extension methods for registering Web API services.
/// </summary>
public static class ApiDependencyInjection
{
/// <summary>
/// Adds Web API services to the service collection.
/// </summary>
/// <param name="services">Service collection.</param>
/// <param name="configuration">Configuration.</param>
/// <returns>Service collection for chaining.</returns>
public static IServiceCollection AddWebApi(
this IServiceCollection services,
IConfiguration configuration)
{
// Read auth options for cookie configuration (binding handled by Infrastructure)
var authOptions = configuration
.GetSection(AuthOptions.SectionName)
.Get<AuthOptions>() ?? new AuthOptions();
// Register memory cache for file downloads
services.AddMemoryCache();
// Configure SignalR
services.AddSignalR();
// Configure cookie authentication
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(options =>
{
options.Cookie.Name = authOptions.CookieName;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
options.Cookie.SameSite = SameSiteMode.Lax;
options.ExpireTimeSpan = TimeSpan.FromMinutes(authOptions.CookieExpirationMinutes);
options.SlidingExpiration = true;
// Return 401 instead of redirect for API requests (Blazor WASM)
options.Events.OnRedirectToLogin = context =>
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return Task.CompletedTask;
};
options.Events.OnRedirectToAccessDenied = context =>
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
return Task.CompletedTask;
};
});
services.AddAuthorization();
// Configure controllers with JSON options
services.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
// Configure Swagger/OpenAPI
services.AddEndpointsApiExplorer();
services.AddSwaggerGen(c =>
{
c.SwaggerDoc("v1", new OpenApiInfo
{
Title = "JDE Scoping Tool API",
Version = "v1",
Description = "API for the JDE Scoping Tool application"
});
});
return services;
}
/// <summary>
/// Configures Web API middleware.
/// </summary>
/// <param name="app">Web application.</param>
/// <returns>Web application for chaining.</returns>
public static WebApplication UseWebApi(this WebApplication app)
{
// Use Swagger in development
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.MapHub<StatusHub>("/hubs/status");
return app;
}
}
@@ -0,0 +1,55 @@
using System.Security.Claims;
using JdeScoping.Core.Models;
using Microsoft.AspNetCore.Authentication.Cookies;
namespace JdeScoping.Api.Extensions;
/// <summary>
/// Extension methods for ClaimsPrincipal
/// </summary>
public static class ClaimsExtensions
{
/// <summary>
/// Converts a ClaimsPrincipal to a UserInfo instance
/// </summary>
/// <param name="principal">Claims principal to extract user info from</param>
/// <returns>UserInfo populated from claims</returns>
public static UserInfo? ToUserInfo(this ClaimsPrincipal principal)
{
if (principal.Identity?.IsAuthenticated != true)
{
return null;
}
return new UserInfo
{
Dn = principal.FindFirstValue("dn") ?? principal.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty,
Username = principal.FindFirstValue(ClaimTypes.Name) ?? string.Empty,
FirstName = principal.FindFirstValue(ClaimTypes.GivenName) ?? string.Empty,
LastName = principal.FindFirstValue(ClaimTypes.Surname) ?? string.Empty,
EmailAddress = principal.FindFirstValue(ClaimTypes.Email) ?? string.Empty,
Title = principal.FindFirstValue("title") ?? string.Empty
};
}
/// <summary>
/// Creates a ClaimsIdentity from a UserInfo instance
/// </summary>
/// <param name="user">User information to create claims from</param>
/// <returns>ClaimsIdentity with user claims</returns>
public static ClaimsIdentity FromUserInfo(UserInfo user)
{
var claims = new List<Claim>
{
new(ClaimTypes.NameIdentifier, user.Dn),
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.GivenName, user.FirstName),
new(ClaimTypes.Surname, user.LastName),
new(ClaimTypes.Email, user.EmailAddress),
new("title", user.Title),
new("dn", user.Dn)
};
return new ClaimsIdentity(claims, CookieAuthenticationDefaults.AuthenticationScheme);
}
}
+71
View File
@@ -0,0 +1,71 @@
using JdeScoping.Core.Models;
using JdeScoping.Core.Models.Infrastructure;
using JdeScoping.Core.Models.Search;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace JdeScoping.Api.Hubs;
/// <summary>
/// SignalR hub for real-time status updates
/// </summary>
public class StatusHub : Hub
{
private static StatusUpdate _cachedStatus = new()
{
Message = "Unknown",
Timestamp = DateTime.UtcNow
};
private readonly ILogger<StatusHub> _logger;
public StatusHub(ILogger<StatusHub> logger)
{
_logger = logger;
}
/// <summary>
/// Called by worker service to update status.
/// Caches the update and broadcasts to all clients.
/// </summary>
/// <param name="statusUpdate">Status update to broadcast</param>
public async Task SetStatus(StatusUpdate statusUpdate)
{
_cachedStatus = statusUpdate;
await Clients.All.SendAsync("statusUpdate", statusUpdate);
_logger.LogDebug("Status updated: {Message}", statusUpdate.Message);
}
/// <summary>
/// Called by clients to get initial cached status on connection.
/// </summary>
/// <returns>The most recent status update</returns>
public StatusUpdate GetCachedStatus()
{
return _cachedStatus;
}
/// <summary>
/// Called by controllers/services to broadcast search updates.
/// </summary>
/// <param name="searchUpdate">Search update to broadcast</param>
public async Task PublishSearchUpdate(SearchUpdate searchUpdate)
{
await Clients.All.SendAsync("searchUpdate", searchUpdate);
_logger.LogDebug("Search update published: ID={Id}, Status={Status}", searchUpdate.Id, searchUpdate.Status);
}
/// <inheritdoc />
public override Task OnConnectedAsync()
{
_logger.LogInformation("Client {ConnectionId} connected to StatusHub", Context.ConnectionId);
return base.OnConnectedAsync();
}
/// <inheritdoc />
public override Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("Client {ConnectionId} disconnected from StatusHub", Context.ConnectionId);
return base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JdeScoping.Core\JdeScoping.Core.csproj" />
<ProjectReference Include="..\JdeScoping.ExcelIO\JdeScoping.ExcelIO.csproj" />
<ProjectReference Include="..\JdeScoping.Infrastructure\JdeScoping.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="7.3.1" />
<PackageReference Include="System.DirectoryServices.Protocols" Version="10.0.1" />
</ItemGroup>
</Project>
@@ -0,0 +1,23 @@
namespace JdeScoping.Api.Models;
/// <summary>
/// Result of a file upload operation
/// </summary>
/// <typeparam name="T">Type of data parsed from the uploaded file</typeparam>
public class FileUploadResult<T>
{
/// <summary>
/// Whether the upload was successful
/// </summary>
public bool WasSuccessful { get; set; }
/// <summary>
/// Error message if the upload failed
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// Parsed data from the uploaded file
/// </summary>
public T[]? Data { get; set; }
}
@@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
namespace JdeScoping.Api.Models;
/// <summary>
/// Login request payload
/// </summary>
public class LoginRequest
{
/// <summary>
/// Username for authentication
/// </summary>
[Required(ErrorMessage = "Username is required")]
public string Username { get; set; } = string.Empty;
/// <summary>
/// Password for authentication
/// </summary>
[Required(ErrorMessage = "Password is required")]
public string Password { get; set; } = string.Empty;
}
+22
View File
@@ -0,0 +1,22 @@
<CascadingAuthenticationState>
<Router AppAssembly="@typeof(App).Assembly" NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
<NotAuthorized>
@if (context.User.Identity?.IsAuthenticated != true)
{
<RedirectToLogin />
}
else
{
<NotAuthorized />
}
</NotAuthorized>
<Authorizing>
<LoadingIndicator Message="Checking authorization..." />
</Authorizing>
</AuthorizeRouteView>
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
</Router>
</CascadingAuthenticationState>
@@ -0,0 +1,121 @@
using System.Net.Http.Json;
using System.Security.Claims;
using JdeScoping.Client.Models;
using Microsoft.AspNetCore.Components.Authorization;
namespace JdeScoping.Client.Auth;
/// <summary>
/// Provides authentication state by checking cookie auth status via API.
/// Works with cookie-based authentication where the browser automatically
/// sends cookies with each request.
/// </summary>
public class AuthStateProvider : AuthenticationStateProvider
{
private readonly IUserStorageService _userStorage;
private readonly HttpClient _httpClient;
private readonly ClaimsPrincipal _anonymous = new(new ClaimsIdentity());
public AuthStateProvider(IUserStorageService userStorage, HttpClient httpClient)
{
_userStorage = userStorage;
_httpClient = httpClient;
}
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
{
// First check cached user info
var cachedUser = await _userStorage.GetUserAsync();
if (cachedUser != null)
{
// Validate session is still active by calling the API
var validatedUser = await ValidateSessionAsync();
if (validatedUser != null)
{
return CreateAuthState(validatedUser);
}
// Session expired, clear cached data
await _userStorage.RemoveUserAsync();
}
return new AuthenticationState(_anonymous);
}
/// <summary>
/// Validates the current session by calling /api/auth/me.
/// Returns null if not authenticated.
/// </summary>
private async Task<UserInfoViewModel?> ValidateSessionAsync()
{
try
{
var response = await _httpClient.GetAsync("api/auth/me");
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<UserInfoViewModel>();
}
}
catch
{
// Network error or other issue - treat as not authenticated
}
return null;
}
/// <summary>
/// Creates an authenticated state from user info.
/// </summary>
private static AuthenticationState CreateAuthState(UserInfoViewModel user)
{
var claims = new List<Claim>
{
new(ClaimTypes.Name, user.Username),
new(ClaimTypes.GivenName, user.FirstName),
new(ClaimTypes.Surname, user.LastName),
new("display_name", user.DisplayName),
new(ClaimTypes.Email, user.EmailAddress)
};
var identity = new ClaimsIdentity(claims, "cookie");
var principal = new ClaimsPrincipal(identity);
return new AuthenticationState(principal);
}
/// <summary>
/// Called after successful login to update auth state.
/// </summary>
public async Task MarkUserAsAuthenticated(UserInfoViewModel user)
{
await _userStorage.SetUserAsync(user);
NotifyAuthenticationStateChanged(Task.FromResult(CreateAuthState(user)));
}
/// <summary>
/// Notifies that authentication state has changed.
/// </summary>
public void NotifyAuthenticationStateChanged()
{
NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());
}
/// <summary>
/// Logs out the user by removing cached data.
/// </summary>
public async Task LogoutAsync()
{
await _userStorage.RemoveUserAsync();
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_anonymous)));
}
/// <summary>
/// Gets the current username from the cached user info.
/// </summary>
public async Task<string?> GetUsernameAsync()
{
var user = await _userStorage.GetUserAsync();
return user?.Username;
}
}
@@ -0,0 +1,26 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Auth;
/// <summary>
/// Service for storing and retrieving user info in browser storage.
/// Used for cookie-based authentication where the API returns UserInfo
/// and cookies handle the actual auth.
/// </summary>
public interface IUserStorageService
{
/// <summary>
/// Gets the stored user info.
/// </summary>
Task<UserInfoViewModel?> GetUserAsync();
/// <summary>
/// Stores the user info.
/// </summary>
Task SetUserAsync(UserInfoViewModel user);
/// <summary>
/// Removes the stored user info.
/// </summary>
Task RemoveUserAsync();
}
@@ -0,0 +1,53 @@
using System.Text.Json;
using JdeScoping.Client.Models;
using Microsoft.JSInterop;
namespace JdeScoping.Client.Auth;
/// <summary>
/// Stores user info in browser sessionStorage via JS interop.
/// Uses sessionStorage (not localStorage) so it clears on browser close,
/// matching cookie session behavior.
/// </summary>
public class UserStorageService : IUserStorageService
{
private const string UserKey = "jdescoping_user";
private readonly IJSRuntime _jsRuntime;
public UserStorageService(IJSRuntime jsRuntime)
{
_jsRuntime = jsRuntime;
}
public async Task<UserInfoViewModel?> GetUserAsync()
{
try
{
var json = await _jsRuntime.InvokeAsync<string?>("jdeScopingInterop.getSessionStorage", UserKey);
if (string.IsNullOrEmpty(json))
{
return null;
}
return JsonSerializer.Deserialize<UserInfoViewModel>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
catch
{
return null;
}
}
public async Task SetUserAsync(UserInfoViewModel user)
{
var json = JsonSerializer.Serialize(user);
await _jsRuntime.InvokeVoidAsync("jdeScopingInterop.setSessionStorage", UserKey, json);
}
public async Task RemoveUserAsync()
{
await _jsRuntime.InvokeVoidAsync("jdeScopingInterop.removeSessionStorage", UserKey);
}
}
@@ -0,0 +1,100 @@
@* Component lot filter panel with upload/download/clear functionality *@
@inject IFileService FileService
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Component Lot</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="componentLotFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@ComponentLots" TItem="ComponentLotViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="LotNumber" Title="Lot Number" />
<RadzenDataGridColumn TItem="ComponentLotViewModel" Property="ItemNumber" Title="Item Number" />
</Columns>
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of component lots: @ComponentLots.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<ComponentLotViewModel> ComponentLots { get; set; } = [];
[Parameter]
public EventCallback<List<ComponentLotViewModel>> ComponentLotsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
{
var lotData = ComponentLots.Select(cl => new { cl.LotNumber, cl.ItemNumber }).ToList();
await FileService.DownloadTemplateAsync("componentlots", lotData);
}
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('componentLotFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileService.UploadAsync<ComponentLotViewModel>("componentlots", stream, e.File.Name);
if (result.WasSuccessful)
{
ComponentLots.Clear();
ComponentLots.AddRange(result.Data);
await ComponentLotsChanged.InvokeAsync(ComponentLots);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {result.Data.Count} component lots.");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result.ErrorMessage);
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all component lots?", "Confirm Clear");
if (confirmed == true)
{
ComponentLots.Clear();
await ComponentLotsChanged.InvokeAsync(ComponentLots);
}
}
}
@@ -0,0 +1,169 @@
@* Item number filter panel with autocomplete and grid *@
@inject ILookupService LookupService
@inject IFileService FileService
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Item Number</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="itemNumberFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Item Number" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="ItemNumber"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search items (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@Items" TItem="ItemViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="ItemViewModel" Property="ItemNumber" Title="Item Number" Width="150px" />
<RadzenDataGridColumn TItem="ItemViewModel" Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="ItemViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="item">
<RadzenButton Text="Delete" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Danger" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of item numbers: @Items.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<ItemViewModel> Items { get; set; } = [];
[Parameter]
public EventCallback<List<ItemViewModel>> ItemsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private string _searchText = "";
private List<ItemViewModel> _searchResults = [];
private ItemViewModel? _selectedItem;
private bool _isUploading;
private async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
_searchResults = await LookupService.FindItemsAsync(args.Filter);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.ItemNumber == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !Items.Any(i => i.ItemNumber == _selectedItem.ItemNumber))
{
Items.Add(_selectedItem);
await ItemsChanged.InvokeAsync(Items);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(ItemViewModel item)
{
Items.Remove(item);
await ItemsChanged.InvokeAsync(Items);
}
private async Task DownloadTemplateAsync()
{
await FileService.DownloadTemplateAsync("items", Items);
}
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('itemNumberFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileService.UploadAsync<ItemViewModel>("items", stream, e.File.Name);
if (result.WasSuccessful)
{
Items.Clear();
Items.AddRange(result.Data);
await ItemsChanged.InvokeAsync(Items);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {result.Data.Count} items.");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result.ErrorMessage);
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all items?", "Confirm Clear");
if (confirmed == true)
{
Items.Clear();
await ItemsChanged.InvokeAsync(Items);
}
}
}
@@ -0,0 +1,112 @@
@* Operator filter panel with autocomplete and grid *@
@inject ILookupService LookupService
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Operator</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
}
</RadzenStack>
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Name" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="FullName"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search operators (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@Operators" TItem="OperatorViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="OperatorViewModel" Property="AddressNumber" Title="Address Number" Width="150px" />
<RadzenDataGridColumn TItem="OperatorViewModel" Property="UserID" Title="User Name" Width="150px" />
<RadzenDataGridColumn TItem="OperatorViewModel" Property="FullName" Title="Full Name" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="OperatorViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="item">
<RadzenButton Text="Delete" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Danger" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
</RadzenCard>
@code {
[Parameter]
public List<OperatorViewModel> Operators { get; set; } = [];
[Parameter]
public EventCallback<List<OperatorViewModel>> OperatorsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<OperatorViewModel> _searchResults = [];
private OperatorViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
_searchResults = await LookupService.FindOperatorsAsync(args.Filter);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.FullName == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !Operators.Any(i => i.UserId == _selectedItem.UserId))
{
Operators.Add(_selectedItem);
await OperatorsChanged.InvokeAsync(Operators);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(OperatorViewModel item)
{
Operators.Remove(item);
await OperatorsChanged.InvokeAsync(Operators);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all operators?", "Confirm Clear");
if (confirmed == true)
{
Operators.Clear();
await OperatorsChanged.InvokeAsync(Operators);
}
}
}
@@ -0,0 +1,101 @@
@* Part operation/MIS filter panel with upload/download/clear functionality *@
@inject IFileService FileService
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter By Item/Operation/MIS</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="partOperationFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@PartOperations" TItem="PartOperationViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="ItemNumber" Title="Item Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="OperationNumber" Title="Operation Step Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="MisNumber" Title="MIS Number" />
<RadzenDataGridColumn TItem="PartOperationViewModel" Property="MisRevision" Title="MIS Revision" />
</Columns>
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of item / operations: @PartOperations.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<PartOperationViewModel> PartOperations { get; set; } = [];
[Parameter]
public EventCallback<List<PartOperationViewModel>> PartOperationsChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
{
await FileService.DownloadTemplateAsync("partoperations", PartOperations);
}
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('partOperationFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileService.UploadAsync<PartOperationViewModel>("partoperations", stream, e.File.Name);
if (result.WasSuccessful)
{
PartOperations.Clear();
PartOperations.AddRange(result.Data);
await PartOperationsChanged.InvokeAsync(PartOperations);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {result.Data.Count} part operations.");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result.ErrorMessage);
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all item/operation/MIS entries?", "Confirm Clear");
if (confirmed == true)
{
PartOperations.Clear();
await PartOperationsChanged.InvokeAsync(PartOperations);
}
}
}
@@ -0,0 +1,111 @@
@* Profit center filter panel with autocomplete and grid *@
@inject ILookupService LookupService
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Profit Center</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
}
</RadzenStack>
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Profit Center" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search profit centers (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@ProfitCenters" TItem="ProfitCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Code" Title="Profit Center" Width="150px" />
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="ProfitCenterViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="item">
<RadzenButton Text="Delete" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Danger" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
</RadzenCard>
@code {
[Parameter]
public List<ProfitCenterViewModel> ProfitCenters { get; set; } = [];
[Parameter]
public EventCallback<List<ProfitCenterViewModel>> ProfitCentersChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<ProfitCenterViewModel> _searchResults = [];
private ProfitCenterViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
_searchResults = await LookupService.FindProfitCentersAsync(args.Filter);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !ProfitCenters.Any(i => i.Code == _selectedItem.Code))
{
ProfitCenters.Add(_selectedItem);
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(ProfitCenterViewModel item)
{
ProfitCenters.Remove(item);
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all profit centers?", "Confirm Clear");
if (confirmed == true)
{
ProfitCenters.Clear();
await ProfitCentersChanged.InvokeAsync(ProfitCenters);
}
}
}
@@ -0,0 +1,46 @@
@* Time span filter panel with min/max date pickers *@
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Filter by Timespan</RadzenText>
<RadzenRow Gap="1rem">
<RadzenColumn Size="5">
<RadzenFormField Text="Min Date" Style="width: 100%;">
<RadzenDatePicker @bind-Value="MinimumDt" DateFormat="MM/dd/yyyy" Disabled="@IsReadOnly"
Min="@_minAllowedDate" Max="@MaxAllowedDate" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="5" Offset="1">
<RadzenFormField Text="Max Date" Style="width: 100%;">
<RadzenDatePicker @bind-Value="MaximumDt" DateFormat="MM/dd/yyyy" Disabled="@IsReadOnly"
Min="@GetMinDateForMax()" Max="@MaxAllowedDate" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
</RadzenCard>
@code {
[Parameter]
public DateTime? MinimumDt { get; set; }
[Parameter]
public EventCallback<DateTime?> MinimumDtChanged { get; set; }
[Parameter]
public DateTime? MaximumDt { get; set; }
[Parameter]
public EventCallback<DateTime?> MaximumDtChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
// Business rules: Min >= 2002-11-01, Max <= today
private readonly DateTime _minAllowedDate = new(2002, 11, 1);
private DateTime MaxAllowedDate => DateTime.Today;
private DateTime GetMinDateForMax()
{
return MinimumDt ?? _minAllowedDate;
}
}
@@ -0,0 +1,111 @@
@* Work center filter panel with autocomplete and grid *@
@inject ILookupService LookupService
@inject DialogService DialogService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Center</RadzenText>
@if (!IsReadOnly)
{
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
}
</RadzenStack>
@if (!IsReadOnly)
{
<RadzenRow Gap="0.5rem" class="rz-mb-3">
<RadzenColumn Size="10">
<RadzenFormField Text="Work Center" Style="width: 100%;">
<RadzenAutoComplete @bind-Value="_searchText" Data="@_searchResults" TextProperty="Code"
LoadData="@OnSearchAsync" MinLength="3" Placeholder="Search work centers (3+ chars)..."
Style="width: 100%;" Change="@OnItemSelected" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Add" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@AddItemAsync"
Disabled="@(_selectedItem == null)" Style="margin-top: 24px;" />
</RadzenColumn>
</RadzenRow>
}
<RadzenDataGrid Data="@WorkCenters" TItem="WorkCenterViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Code" Title="Work Center" Width="150px" />
<RadzenDataGridColumn TItem="WorkCenterViewModel" Property="Description" Title="Description" />
@if (!IsReadOnly)
{
<RadzenDataGridColumn TItem="WorkCenterViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="item">
<RadzenButton Text="Delete" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Danger" Click="@(() => DeleteItem(item))" />
</Template>
</RadzenDataGridColumn>
}
</Columns>
</RadzenDataGrid>
</RadzenCard>
@code {
[Parameter]
public List<WorkCenterViewModel> WorkCenters { get; set; } = [];
[Parameter]
public EventCallback<List<WorkCenterViewModel>> WorkCentersChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
private string _searchText = "";
private List<WorkCenterViewModel> _searchResults = [];
private WorkCenterViewModel? _selectedItem;
private async Task OnSearchAsync(LoadDataArgs args)
{
if (!string.IsNullOrEmpty(args.Filter) && args.Filter.Length >= 3)
{
_searchResults = await LookupService.FindWorkCentersAsync(args.Filter);
}
else
{
_searchResults = [];
}
}
private void OnItemSelected(object value)
{
if (value is string text && !string.IsNullOrEmpty(text))
{
_selectedItem = _searchResults.FirstOrDefault(i => i.Code == text);
}
else
{
_selectedItem = null;
}
}
private async Task AddItemAsync()
{
if (_selectedItem != null && !WorkCenters.Any(i => i.Code == _selectedItem.Code))
{
WorkCenters.Add(_selectedItem);
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
_searchText = "";
_selectedItem = null;
}
private async Task DeleteItem(WorkCenterViewModel item)
{
WorkCenters.Remove(item);
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work centers?", "Confirm Clear");
if (confirmed == true)
{
WorkCenters.Clear();
await WorkCentersChanged.InvokeAsync(WorkCenters);
}
}
}
@@ -0,0 +1,100 @@
@* Work order filter panel with upload/download/clear functionality *@
@inject IFileService FileService
@inject DialogService DialogService
@inject NotificationService NotificationService
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-3">
<RadzenText TextStyle="TextStyle.H6" class="rz-m-0">Filter by Work Order</RadzenText>
@if (!IsReadOnly)
{
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.25rem">
<RadzenButton Text="Download Template" Icon="download" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@DownloadTemplateAsync" />
<InputFile OnChange="@OnFileSelected" accept=".xlsx,.xls" style="display: none;" id="workOrderFileInput" />
<RadzenButton Text="Upload Data" Icon="upload" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small"
Click="@(() => TriggerFileInput())" IsBusy="@_isUploading" />
<RadzenButton Text="Clear Data" Icon="clear" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@ClearDataAsync" />
</RadzenStack>
}
</RadzenStack>
<RadzenDataGrid Data="@WorkOrders" TItem="WorkOrderViewModel" AllowSorting="true" Style="min-height: 150px; max-height: 300px;">
<Columns>
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="WorkOrderNumber" Title="Work Order Number" />
<RadzenDataGridColumn TItem="WorkOrderViewModel" Property="ItemNumber" Title="Item Number" />
</Columns>
</RadzenDataGrid>
<RadzenText TextStyle="TextStyle.Body2" class="rz-mt-2">
<strong># of work orders: @WorkOrders.Count</strong>
</RadzenText>
</RadzenCard>
@code {
[Parameter]
public List<WorkOrderViewModel> WorkOrders { get; set; } = [];
[Parameter]
public EventCallback<List<WorkOrderViewModel>> WorkOrdersChanged { get; set; }
[Parameter]
public bool IsReadOnly { get; set; }
[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
private bool _isUploading;
private async Task DownloadTemplateAsync()
{
var workOrderNumbers = WorkOrders.Select(wo => wo.WorkOrderNumber).ToList();
await FileService.DownloadTemplateAsync("workorders", workOrderNumbers);
}
private async Task TriggerFileInput()
{
await JSRuntime.InvokeVoidAsync("eval", "document.getElementById('workOrderFileInput').click()");
}
private async Task OnFileSelected(InputFileChangeEventArgs e)
{
if (e.File == null) return;
_isUploading = true;
try
{
using var stream = e.File.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024); // 10MB max
var result = await FileService.UploadAsync<WorkOrderViewModel>("workorders", stream, e.File.Name);
if (result.WasSuccessful)
{
WorkOrders.Clear();
WorkOrders.AddRange(result.Data);
await WorkOrdersChanged.InvokeAsync(WorkOrders);
NotificationService.Notify(NotificationSeverity.Success, "Upload Complete", $"Loaded {result.Data.Count} work orders.");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", result.ErrorMessage);
}
}
catch (Exception ex)
{
NotificationService.Notify(NotificationSeverity.Error, "Upload Failed", ex.Message);
}
finally
{
_isUploading = false;
}
}
private async Task ClearDataAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to clear all work orders?", "Confirm Clear");
if (confirmed == true)
{
WorkOrders.Clear();
await WorkOrdersChanged.InvokeAsync(WorkOrders);
}
}
}
@@ -0,0 +1,24 @@
@* Loading indicator component with optional message *@
<div class="loading-container">
<RadzenProgressBarCircular ShowValue="false" Mode="ProgressBarMode.Indeterminate" Size="ProgressBarCircularSize.Large" />
@if (!string.IsNullOrEmpty(Message))
{
<RadzenText TextStyle="TextStyle.Body1" class="rz-mt-2">@Message</RadzenText>
}
</div>
<style>
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
</style>
@code {
[Parameter]
public string? Message { get; set; }
}
@@ -0,0 +1,9 @@
@inject NavigationManager NavigationManager
@code {
protected override void OnInitialized()
{
var returnUrl = Uri.EscapeDataString(NavigationManager.Uri);
NavigationManager.NavigateTo($"/login?returnUrl={returnUrl}");
}
}
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<OverrideHtmlAssetPlaceholders>true</OverrideHtmlAssetPlaceholders>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.1" />
<PackageReference Include="Radzen.Blazor" Version="8.4.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JdeScoping.Core\JdeScoping.Core.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,64 @@
@inherits LayoutComponentBase
@inject IAuthService AuthService
@inject NavigationManager NavigationManager
<RadzenLayout>
<RadzenHeader class="navbar-fixed-top">
<div class="navbar-container">
<div class="navbar-left">
<a href="/" class="navbar-brand">JDE Scoping Tool</a>
<nav class="navbar-nav">
<NavLink class="nav-link" href="/" Match="NavLinkMatch.All">Searches</NavLink>
<NavLink class="nav-link" href="/search">New Search</NavLink>
<NavLink class="nav-link" href="/search/queue">Search Queue</NavLink>
<NavLink class="nav-link" href="/refresh-status">Refresh Status</NavLink>
</nav>
</div>
<div class="navbar-right">
<AuthorizeView>
<Authorized>
<span class="navbar-user">@context.User.Identity?.Name</span>
<RadzenButton Text="Logout" ButtonStyle="ButtonStyle.Light" Size="ButtonSize.Small" Click="@LogoutAsync" />
</Authorized>
<NotAuthorized>
<RadzenButton Text="Login" ButtonStyle="ButtonStyle.Primary" Size="ButtonSize.Small" Click="@GoToLogin" />
</NotAuthorized>
</AuthorizeView>
</div>
</div>
</RadzenHeader>
<RadzenBody class="main-body">
<div class="body-content">
<div class="container-fluid">
@Body
</div>
</div>
</RadzenBody>
<RadzenFooter>
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.Center" Gap="0" class="rz-px-4">
<RadzenText TextStyle="TextStyle.Caption" class="rz-m-0">
JDE Scoping Tool Version 4
</RadzenText>
</RadzenStack>
</RadzenFooter>
</RadzenLayout>
<RadzenDialog />
<RadzenNotification />
<RadzenContextMenu />
<RadzenTooltip />
@code {
private async Task LogoutAsync()
{
await AuthService.LogoutAsync();
NavigationManager.NavigateTo("/login");
}
private void GoToLogin()
{
NavigationManager.NavigateTo("/login");
}
}
@@ -0,0 +1,21 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for component lot filter.
/// </summary>
public class ComponentLotViewModel
{
public string LotNumber { get; set; } = string.Empty;
public string ItemNumber { get; set; } = string.Empty;
public override bool Equals(object? obj)
{
if (obj is ComponentLotViewModel other)
{
return LotNumber == other.LotNumber && ItemNumber == other.ItemNumber;
}
return false;
}
public override int GetHashCode() => HashCode.Combine(LotNumber, ItemNumber);
}
@@ -0,0 +1,24 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for data refresh/sync status display.
/// </summary>
public class DataUpdateViewModel
{
public DateTime StartDt { get; set; }
public DateTime? EndDt { get; set; }
public int BranchRecords { get; set; }
public int ProfitCenterRecords { get; set; }
public int WorkCenterRecords { get; set; }
public int OrgHierarchyRecords { get; set; }
public int StatusCodeRecords { get; set; }
public int UserRecords { get; set; }
public int ItemRecords { get; set; }
public int LotRecords { get; set; }
public int WorkOrderRecords { get; set; }
public int WorkOrderStepRecords { get; set; }
public int WorkOrderComponentRecords { get; set; }
public bool WasSuccessful { get; set; }
}
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace JdeScoping.Client.Models;
/// <summary>
/// Login form model with validation.
/// </summary>
public class LoginModel
{
[Required(ErrorMessage = "Username is required")]
public string Username { get; set; } = string.Empty;
[Required(ErrorMessage = "Password is required")]
public string Password { get; set; } = string.Empty;
}
@@ -0,0 +1,22 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for operator filter.
/// </summary>
public class OperatorViewModel
{
public int AddressNumber { get; set; }
public string UserId { get; set; } = string.Empty;
public string FullName { get; set; } = string.Empty;
public override bool Equals(object? obj)
{
if (obj is OperatorViewModel other)
{
return UserId == other.UserId;
}
return false;
}
public override int GetHashCode() => UserId.GetHashCode();
}
@@ -0,0 +1,22 @@
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for search criteria filters.
/// </summary>
public class SearchCriteriaViewModel
{
public DateTime? MinimumDt { get; set; }
public DateTime? MaximumDt { get; set; }
public List<WorkOrderViewModel> WorkOrders { get; set; } = [];
public List<ItemViewModel> Items { get; set; } = [];
public List<ProfitCenterViewModel> ProfitCenters { get; set; } = [];
public List<WorkCenterViewModel> WorkCenters { get; set; } = [];
public List<ComponentLotViewModel> ComponentLots { get; set; } = [];
public List<OperatorViewModel> Operators { get; set; } = [];
public List<PartOperationViewModel> PartOperations { get; set; } = [];
public bool ExtractMisData { get; set; }
}
@@ -0,0 +1,15 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// SignalR message for search status updates.
/// </summary>
public record SearchUpdate
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public string UserName { get; init; } = string.Empty;
public string Status { get; init; } = string.Empty;
public DateTime? SubmitDt { get; init; }
public DateTime? StartDt { get; init; }
public DateTime? EndDt { get; init; }
}
@@ -0,0 +1,41 @@
using System.ComponentModel.DataAnnotations;
namespace JdeScoping.Client.Models;
/// <summary>
/// View model for displaying search information in lists and details.
/// </summary>
public class SearchViewModel
{
public int Id { get; set; }
[Required(ErrorMessage = "Name is required.")]
public string Name { get; set; } = string.Empty;
public string UserName { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public DateTime? SubmitDt { get; set; }
public DateTime? StartDt { get; set; }
public DateTime? EndDt { get; set; }
public SearchCriteriaViewModel Criteria { get; set; } = new();
/// <summary>
/// Gets the background color based on status.
/// </summary>
public string StatusColor => Status switch
{
"Error" => "#FF6347",
"Ended" => "#90EE90",
"Running" => "#87CEEB",
_ => "#EEEEEE"
};
/// <summary>
/// Returns true if the search has downloadable results.
/// </summary>
public bool HasResults => Status == "Ended";
/// <summary>
/// Returns true if the search is read-only (already submitted).
/// </summary>
public bool IsReadOnly => Status != "New";
}
@@ -0,0 +1,10 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// SignalR message for processor status updates.
/// </summary>
public record StatusUpdate
{
public string Message { get; init; } = string.Empty;
public DateTime? Timestamp { get; init; }
}
@@ -0,0 +1,38 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// Client-side view model for authenticated user information.
/// Mirrors the server-side UserInfo model returned by /api/auth/login and /api/auth/me.
/// </summary>
public class UserInfoViewModel
{
/// <summary>
/// User's login username.
/// </summary>
public string Username { get; set; } = string.Empty;
/// <summary>
/// User's first name.
/// </summary>
public string FirstName { get; set; } = string.Empty;
/// <summary>
/// User's last name.
/// </summary>
public string LastName { get; set; } = string.Empty;
/// <summary>
/// User's display name (computed on server, provided here for convenience).
/// </summary>
public string DisplayName { get; set; } = string.Empty;
/// <summary>
/// User's organization title.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// User's email address.
/// </summary>
public string EmailAddress { get; set; } = string.Empty;
}
@@ -0,0 +1,276 @@
namespace JdeScoping.Client.Models;
/// <summary>
/// Represents a valid combination of search filter criteria.
/// Each combination defines which filter panels should be visible for a given search type.
/// </summary>
public class ValidCombination
{
public int Id { get; init; }
public string Name { get; init; } = string.Empty;
public bool Timespan { get; init; }
public bool WorkOrder { get; init; }
public bool ItemNumber { get; init; }
public bool ProfitCenter { get; init; }
public bool WorkCenter { get; init; }
public bool ComponentLot { get; init; }
public bool Operator { get; init; }
public bool ItemOperationMis { get; init; }
public bool ExtractMis { get; init; }
/// <summary>
/// Checks if the given filter flags match this combination.
/// </summary>
public bool Matches(
bool timespan,
bool workOrder,
bool itemNumber,
bool profitCenter,
bool workCenter,
bool componentLot,
bool @operator,
bool itemOperationMis,
bool extractMis)
{
return Timespan == timespan &&
WorkOrder == workOrder &&
ItemNumber == itemNumber &&
ProfitCenter == profitCenter &&
WorkCenter == workCenter &&
ComponentLot == componentLot &&
Operator == @operator &&
ItemOperationMis == itemOperationMis &&
ExtractMis == extractMis;
}
/// <summary>
/// Gets all 16 valid search type combinations.
/// </summary>
public static IReadOnlyList<ValidCombination> GetAll() =>
[
new ValidCombination
{
Id = 10,
Name = "Work Order",
Timespan = false,
WorkOrder = true,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 20,
Name = "Component Lot",
Timespan = false,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = false,
ComponentLot = true,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 30,
Name = "Time Span + Profit Center",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 40,
Name = "Time Span + Work Center",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 50,
Name = "Time Span + Operator",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = false,
ComponentLot = false,
Operator = true,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 60,
Name = "Time Span + Profit Center + Item Number",
Timespan = true,
WorkOrder = false,
ItemNumber = true,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 70,
Name = "Time Span + Profit Center + Item/Operation/MIS",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
{
Id = 80,
Name = "Time Span + Profit Center + Work Order + Item/Operation/MIS",
Timespan = true,
WorkOrder = true,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
{
Id = 90,
Name = "Time Span + Profit Center + Extract MIS",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = true
},
new ValidCombination
{
Id = 100,
Name = "Time Span + Work Center + Item Number",
Timespan = true,
WorkOrder = false,
ItemNumber = true,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 110,
Name = "Time Span + Work Center + Extract MIS",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = true
},
new ValidCombination
{
Id = 120,
Name = "Time Span + Work Center + Item/Operation/MIS",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
{
Id = 130,
Name = "Time Span + Work Center + Work Order + Item/Operation/MIS",
Timespan = true,
WorkOrder = true,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = false,
ItemOperationMis = true,
ExtractMis = false
},
new ValidCombination
{
Id = 140,
Name = "Time Span + Item Number",
Timespan = true,
WorkOrder = false,
ItemNumber = true,
ProfitCenter = false,
WorkCenter = false,
ComponentLot = false,
Operator = false,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 150,
Name = "Time Span + Work Center + Operator",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = false,
WorkCenter = true,
ComponentLot = false,
Operator = true,
ItemOperationMis = false,
ExtractMis = false
},
new ValidCombination
{
Id = 160,
Name = "Time Span + Profit Center + Operator",
Timespan = true,
WorkOrder = false,
ItemNumber = false,
ProfitCenter = true,
WorkCenter = false,
ComponentLot = false,
Operator = true,
ItemOperationMis = false,
ExtractMis = false
}
];
}
@@ -0,0 +1,83 @@
@page "/login"
@inject IAuthService AuthService
@inject NavigationManager NavigationManager
<PageTitle>Login - JDE Scoping Tool</PageTitle>
<RadzenCard class="rz-mx-auto rz-mt-6" Style="max-width: 400px;">
<RadzenText TextStyle="TextStyle.H4" class="rz-mb-4">
Authentication Required
</RadzenText>
<EditForm Model="@_loginModel" OnValidSubmit="@HandleLoginAsync">
<DataAnnotationsValidator />
@if (!string.IsNullOrEmpty(_errorMessage))
{
<RadzenAlert AlertStyle="AlertStyle.Danger" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
@_errorMessage
</RadzenAlert>
}
<RadzenStack Gap="1rem">
<RadzenFormField Text="Username" Variant="Variant.Outlined">
<ChildContent>
<RadzenTextBox @bind-Value="_loginModel.Username" Name="Username" Disabled="@_isLoading" Style="width: 100%;" />
</ChildContent>
<Helper>
<ValidationMessage For="@(() => _loginModel.Username)" />
</Helper>
</RadzenFormField>
<RadzenFormField Text="Password" Variant="Variant.Outlined">
<ChildContent>
<RadzenPassword @bind-Value="_loginModel.Password" Name="Password" Disabled="@_isLoading" Style="width: 100%;" />
</ChildContent>
<Helper>
<ValidationMessage For="@(() => _loginModel.Password)" />
</Helper>
</RadzenFormField>
<RadzenButton ButtonType="ButtonType.Submit" Text="Login" ButtonStyle="ButtonStyle.Primary"
IsBusy="@_isLoading" BusyText="Logging in..." Style="width: 100%;" />
</RadzenStack>
</EditForm>
</RadzenCard>
@code {
private LoginModel _loginModel = new();
private string? _errorMessage;
private bool _isLoading;
[SupplyParameterFromQuery]
public string? ReturnUrl { get; set; }
private async Task HandleLoginAsync()
{
_isLoading = true;
_errorMessage = null;
try
{
var result = await AuthService.LoginAsync(_loginModel);
if (result.Success)
{
var returnUrl = string.IsNullOrEmpty(ReturnUrl) ? "/" : ReturnUrl;
NavigationManager.NavigateTo(returnUrl);
}
else
{
_errorMessage = result.ErrorMessage ?? "Login failed. Please check your credentials.";
}
}
catch (Exception ex)
{
_errorMessage = $"An error occurred: {ex.Message}";
}
finally
{
_isLoading = false;
}
}
}
@@ -0,0 +1,41 @@
@page "/not-authorized"
@inject NavigationManager NavigationManager
<PageTitle>Not Authorized - JDE Scoping Tool</PageTitle>
<RadzenCard class="rz-mx-auto rz-mt-6" Style="max-width: 500px;">
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" Size="AlertSize.Large">
<RadzenText TextStyle="TextStyle.H5" class="rz-mb-2">
Access Denied
</RadzenText>
<RadzenText TextStyle="TextStyle.Body1" class="rz-mb-4">
You do not have permission to access this resource.
</RadzenText>
@if (!string.IsNullOrEmpty(ResourceUrl))
{
<RadzenText TextStyle="TextStyle.Caption" class="rz-mb-4">
Requested resource: @ResourceUrl
</RadzenText>
}
</RadzenAlert>
<RadzenStack Orientation="Orientation.Horizontal" Gap="1rem" JustifyContent="JustifyContent.Center" class="rz-mt-4">
<RadzenButton Text="Go to Home" ButtonStyle="ButtonStyle.Primary" Click="@GoHome" />
<RadzenButton Text="Login" ButtonStyle="ButtonStyle.Secondary" Click="@GoToLogin" />
</RadzenStack>
</RadzenCard>
@code {
[SupplyParameterFromQuery]
public string? ResourceUrl { get; set; }
private void GoHome()
{
NavigationManager.NavigateTo("/");
}
private void GoToLogin()
{
NavigationManager.NavigateTo("/login");
}
}
@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
@@ -0,0 +1,101 @@
@page "/refresh-status"
@attribute [Authorize]
@inject IRefreshStatusService RefreshStatusService
<PageTitle>Cache Refresh Status - JDE Scoping Tool</PageTitle>
<RadzenText TextStyle="TextStyle.H4" class="rz-mb-4">Cache Refresh Status</RadzenText>
<!-- Date Filter Panel -->
<RadzenCard class="rz-mb-4">
<RadzenRow Gap="1rem" AlignItems="AlignItems.End">
<RadzenColumn Size="4">
<RadzenFormField Text="Start Time" Style="width: 100%;">
<RadzenDatePicker @bind-Value="_minDt" DateFormat="MM/dd/yyyy" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="End Time" Style="width: 100%;">
<RadzenDatePicker @bind-Value="_maxDt" DateFormat="MM/dd/yyyy" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="2">
<RadzenButton Text="Filter" Icon="filter_list" ButtonStyle="ButtonStyle.Primary" Click="@LoadDataAsync" IsBusy="@_isLoading" />
</RadzenColumn>
</RadzenRow>
</RadzenCard>
@if (_isLoading)
{
<LoadingIndicator Message="Loading refresh status..." />
}
else
{
<RadzenDataGrid Data="@_results" TItem="DataUpdateViewModel" AllowSorting="true" AllowPaging="true" PageSize="20"
PagerHorizontalAlign="HorizontalAlign.Center" AllowColumnResize="true" Style="text-align: center;">
<Columns>
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="StartDT" Title="Start" Width="160px">
<Template Context="item">
@item.StartDt.ToString("MM/dd/yyyy hh:mm tt")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="EndDT" Title="End" Width="160px">
<Template Context="item">
@(item.EndDt?.ToString("MM/dd/yyyy hh:mm tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="BranchRecords" Title="Branch" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="ProfitCenterRecords" Title="Profit Center" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkCenterRecords" Title="Work Center" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="OrgHierarchyRecords" Title="Org Hierarchy" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="StatusCodeRecords" Title="Status Code" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="UserRecords" Title="User" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="ItemRecords" Title="Item" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="LotRecords" Title="Lot" Width="80px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderRecords" Title="Work Order" Width="100px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderStepRecords" Title="WO Step" Width="90px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WorkOrderComponentRecords" Title="WO Component" Width="110px" TextAlign="TextAlign.Center" />
<RadzenDataGridColumn TItem="DataUpdateViewModel" Property="WasSuccessful" Title="Was Successful?" Width="120px" TextAlign="TextAlign.Center">
<Template Context="item">
@if (item.WasSuccessful)
{
<RadzenBadge BadgeStyle="BadgeStyle.Success" Text="YES" />
}
else
{
<RadzenBadge BadgeStyle="BadgeStyle.Danger" Text="NO" />
}
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid>
}
@code {
private List<DataUpdateViewModel> _results = [];
private bool _isLoading = true;
// Default to last 7 days
private DateTime _minDt = DateTime.Today.AddDays(-7);
private DateTime _maxDt = DateTime.Today;
protected override async Task OnInitializedAsync()
{
await LoadDataAsync();
}
private async Task LoadDataAsync()
{
_isLoading = true;
try
{
_results = await RefreshStatusService.GetRefreshStatusAsync(_minDt, _maxDt);
// Sort by StartDT descending
_results = _results.OrderByDescending(r => r.StartDt).ToList();
}
finally
{
_isLoading = false;
}
}
}
@@ -0,0 +1,433 @@
@page "/search"
@page "/search/{Id:int}"
@attribute [Authorize]
@inject ISearchService SearchService
@inject IHubConnectionService HubConnection
@inject IFileService FileService
@inject AuthStateProvider AuthStateProvider
@inject NavigationManager NavigationManager
@inject DialogService DialogService
@inject NotificationService NotificationService
@inject IJSRuntime JSRuntime
@implements IDisposable
<PageTitle>@(_search.Id == 0 ? "New Search" : "Search") - JDE Scoping Tool</PageTitle>
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H4" class="rz-m-0">Search</RadzenText>
@if (!_search.IsReadOnly)
{
<RadzenButton Text="Submit" Icon="send" ButtonStyle="ButtonStyle.Primary" Click="@SubmitSearchAsync" IsBusy="@_isSubmitting" BusyText="Submitting..." />
}
</RadzenStack>
@if (_isLoading)
{
<LoadingIndicator Message="Loading search..." />
}
else
{
<EditForm Model="@_search" OnValidSubmit="@HandleValidSubmit">
<DataAnnotationsValidator />
@if (_search.IsReadOnly)
{
<RadzenAlert AlertStyle="AlertStyle.Warning" ShowIcon="true" Variant="Variant.Flat" class="rz-mb-4">
<strong>Note:</strong> Search is read-only because it has already been submitted. To change or re-run the search again click the Copy button.
<RadzenButton Text="Copy" ButtonStyle="ButtonStyle.Secondary" Size="ButtonSize.Small" Click="@CopySearchAsync" class="rz-ml-2" />
</RadzenAlert>
}
<!-- Validation Summary -->
<ValidationSummary class="rz-mb-4" />
<!-- Search Details Panel -->
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Details</RadzenText>
<RadzenRow Gap="1rem">
<RadzenColumn Size="12">
<RadzenFormField Text="Search Type" Style="width: 100%;">
<RadzenDropDown @bind-Value="_selectedSearchType" Data="@_validCombinations" TextProperty="Name" ValueProperty="Id"
Placeholder="Select type" Disabled="@_search.IsReadOnly" Change="@OnSearchTypeChanged" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="12">
<RadzenFormField Text="Name" Style="width: 100%;">
<RadzenTextBox @bind-Value="_search.Name" Disabled="@_search.IsReadOnly" Style="width: 100%;" />
</RadzenFormField>
<ValidationMessage For="@(() => _search.Name)" class="validation-message text-danger" />
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="Submitted At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Started At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Completed At" Style="width: 100%;">
<RadzenTextBox Value="@(_search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
<RadzenRow Gap="1rem" class="rz-mt-3">
<RadzenColumn Size="4">
<RadzenFormField Text="User" Style="width: 100%;">
<RadzenTextBox Value="@_search.UserName" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Status" Style="width: 100%;">
<RadzenTextBox Value="@_search.Status" ReadOnly="true" Style="@($"width: 100%; background-color: {_search.StatusColor};")" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
@if (_search.HasResults)
{
<RadzenFormField Text=" " Style="width: 100%;">
<RadzenButton Text="Download Results" Icon="download" ButtonStyle="ButtonStyle.Success" Click="@DownloadResultsAsync" Style="width: 100%;" />
</RadzenFormField>
}
</RadzenColumn>
</RadzenRow>
</RadzenCard>
<!-- Filter Panels -->
@if (_showTimespan)
{
<TimeSpanFilterPanel @bind-MinimumDT="_search.Criteria.MinimumDt" @bind-MaximumDT="_search.Criteria.MaximumDt" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showWorkOrder)
{
<WorkOrderFilterPanel @bind-WorkOrders="_search.Criteria.WorkOrders" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showItemNumber)
{
<ItemNumberFilterPanel @bind-Items="_search.Criteria.Items" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showProfitCenter)
{
<ProfitCenterFilterPanel @bind-ProfitCenters="_search.Criteria.ProfitCenters" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showWorkCenter)
{
<WorkCenterFilterPanel @bind-WorkCenters="_search.Criteria.WorkCenters" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showComponentLot)
{
<ComponentLotFilterPanel @bind-ComponentLots="_search.Criteria.ComponentLots" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showOperator)
{
<OperatorFilterPanel @bind-Operators="_search.Criteria.Operators" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showItemOperationMis)
{
<PartOperationFilterPanel @bind-PartOperations="_search.Criteria.PartOperations" IsReadOnly="@_search.IsReadOnly" />
}
@if (_showExtractMis)
{
<RadzenCard class="rz-mb-4">
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" Gap="0.5rem">
<RadzenCheckBox @bind-Value="_search.Criteria.ExtractMisData" Disabled="true" />
<RadzenText TextStyle="TextStyle.Body1">Extract MIS data</RadzenText>
</RadzenStack>
</RadzenCard>
}
</EditForm>
}
@code {
[Parameter]
public int? Id { get; set; }
[SupplyParameterFromQuery(Name = "copySearchId")]
public int? CopySearchId { get; set; }
private ClientSearchViewModel _search = new() { Criteria = new() };
private IReadOnlyList<ValidCombination> _validCombinations = ValidCombination.GetAll();
private int? _selectedSearchType;
private bool _isLoading = true;
private bool _isSubmitting;
// Filter visibility flags
private bool _showTimespan;
private bool _showWorkOrder;
private bool _showItemNumber;
private bool _showProfitCenter;
private bool _showWorkCenter;
private bool _showComponentLot;
private bool _showOperator;
private bool _showItemOperationMis;
private bool _showExtractMis;
protected override async Task OnInitializedAsync()
{
await LoadSearchAsync();
await SetupSignalRAsync();
}
private async Task LoadSearchAsync()
{
_isLoading = true;
try
{
if (CopySearchId.HasValue)
{
var copied = await SearchService.CopySearchAsync(CopySearchId.Value);
if (copied != null)
{
_search = copied;
_search.Id = 0;
_search.Status = "New";
}
}
else if (Id.HasValue && Id.Value > 0)
{
var loaded = await SearchService.GetSearchAsync(Id.Value);
if (loaded != null)
{
_search = loaded;
}
}
else
{
// New search
_search = new ClientSearchViewModel
{
Status = "New",
UserName = await AuthStateProvider.GetUsernameAsync() ?? "",
Criteria = new SearchCriteriaViewModel()
};
}
// Detect search type from criteria
DetectSearchType();
}
finally
{
_isLoading = false;
}
}
private void DetectSearchType()
{
var criteria = _search.Criteria;
bool hasTimespan = criteria.MinimumDt.HasValue || criteria.MaximumDt.HasValue;
bool hasWorkOrder = criteria.WorkOrders.Count > 0;
bool hasItemNumber = criteria.Items.Count > 0;
bool hasProfitCenter = criteria.ProfitCenters.Count > 0;
bool hasWorkCenter = criteria.WorkCenters.Count > 0;
bool hasComponentLot = criteria.ComponentLots.Count > 0;
bool hasOperator = criteria.Operators.Count > 0;
bool hasPartOperation = criteria.PartOperations.Count > 0;
bool hasExtractMis = criteria.ExtractMisData;
foreach (var combo in _validCombinations)
{
if (combo.Matches(hasTimespan, hasWorkOrder, hasItemNumber, hasProfitCenter, hasWorkCenter, hasComponentLot, hasOperator, hasPartOperation, hasExtractMis))
{
_selectedSearchType = combo.Id;
UpdateFilterVisibility(combo);
break;
}
}
}
private void OnSearchTypeChanged()
{
var combo = _validCombinations.FirstOrDefault(c => c.Id == _selectedSearchType);
if (combo != null)
{
UpdateFilterVisibility(combo);
}
}
private void UpdateFilterVisibility(ValidCombination combo)
{
_showTimespan = combo.Timespan;
_showWorkOrder = combo.WorkOrder;
_showItemNumber = combo.ItemNumber;
_showProfitCenter = combo.ProfitCenter;
_showWorkCenter = combo.WorkCenter;
_showComponentLot = combo.ComponentLot;
_showOperator = combo.Operator;
_showItemOperationMis = combo.ItemOperationMis;
_showExtractMis = combo.ExtractMis;
// Set ExtractMisData flag based on combo
_search.Criteria.ExtractMisData = combo.ExtractMis;
}
private async Task SetupSignalRAsync()
{
HubConnection.OnSearchUpdate += HandleSearchUpdate;
await HubConnection.StartAsync();
}
private void HandleSearchUpdate(SearchUpdate update)
{
if (update.Id == _search.Id)
{
InvokeAsync(() =>
{
_search.Status = update.Status;
_search.SubmitDt = update.SubmitDt;
_search.StartDt = update.StartDt;
_search.EndDt = update.EndDt;
StateHasChanged();
});
}
}
private async Task HandleValidSubmit()
{
// DataAnnotationsValidator has already validated the model
// Now perform additional custom validation
if (_selectedSearchType == null)
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
return;
}
// Validate filter data based on search type
var validationError = ValidateFilters();
if (!string.IsNullOrEmpty(validationError))
{
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
return;
}
await SubmitSearchInternalAsync();
}
private async Task SubmitSearchAsync()
{
// Manual submit button handler - validate and submit
if (string.IsNullOrWhiteSpace(_search.Name))
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Name is required.");
return;
}
if (_selectedSearchType == null)
{
NotificationService.Notify(NotificationSeverity.Error, "Validation Error", "Search type is required.");
return;
}
// Validate filter data based on search type
var validationError = ValidateFilters();
if (!string.IsNullOrEmpty(validationError))
{
NotificationService.Notify(NotificationSeverity.Error, "Filter Validation Error", validationError);
return;
}
await SubmitSearchInternalAsync();
}
private async Task SubmitSearchInternalAsync()
{
var confirmed = await DialogService.Confirm("Are you sure you want to submit the search?", "Confirm Submit", new ConfirmOptions
{
OkButtonText = "Submit",
CancelButtonText = "Cancel"
});
if (confirmed != true)
{
return;
}
_isSubmitting = true;
try
{
var id = await SearchService.SaveSearchAsync(_search);
if (id.HasValue)
{
NavigationManager.NavigateTo($"/search/{id}");
}
else
{
NotificationService.Notify(NotificationSeverity.Error, "Error", "Failed to submit search.");
}
}
finally
{
_isSubmitting = false;
}
}
private string? ValidateFilters()
{
if (_showWorkOrder && _search.Criteria.WorkOrders.Count == 0)
return "At least one work order must be specified for the work order filter.";
if (_showItemNumber && _search.Criteria.Items.Count == 0)
return "At least one item number must be specified for the item number filter.";
if (_showProfitCenter && _search.Criteria.ProfitCenters.Count == 0)
return "At least one profit center must be specified for the profit center filter.";
if (_showWorkCenter && _search.Criteria.WorkCenters.Count == 0)
return "At least one work center must be specified for the work center filter.";
if (_showComponentLot && _search.Criteria.ComponentLots.Count == 0)
return "At least one component lot must be specified for the component lot filter.";
if (_showOperator && _search.Criteria.Operators.Count == 0)
return "At least one operator must be specified for the operator filter.";
if (_showItemOperationMis && _search.Criteria.PartOperations.Count == 0)
return "At least one item/operation/MIS entry must be specified for the MIS data filter.";
return null;
}
private void CopySearchAsync()
{
NavigationManager.NavigateTo($"/search?copySearchId={_search.Id}");
}
private async Task DownloadResultsAsync()
{
var results = await SearchService.DownloadResultsAsync(_search.Id);
if (results != null && results.Length > 0)
{
// Trigger download via JS interop
await JSRuntime.InvokeVoidAsync("downloadFile", $"search_results_{_search.Id}.xlsx", results);
NotificationService.Notify(NotificationSeverity.Success, "Download", "Results downloaded successfully.");
}
else
{
NotificationService.Notify(NotificationSeverity.Warning, "Download", "No results available to download.");
}
}
public void Dispose()
{
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
}
}
@@ -0,0 +1,171 @@
@page "/search/queue"
@attribute [Authorize]
@inject ISearchService SearchService
@inject IHubConnectionService HubConnection
@implements IDisposable
<PageTitle>Search Queue - JDE Scoping Tool</PageTitle>
<RadzenText TextStyle="TextStyle.H4" class="rz-mb-4">Search Queue</RadzenText>
<!-- Processor Status Panel -->
<RadzenCard class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H6" class="rz-mb-3">Search Processor Status</RadzenText>
<RadzenRow Gap="1rem">
<RadzenColumn Size="8">
<RadzenFormField Text="Status Message" Style="width: 100%;">
<RadzenTextBox Value="@_statusMessage" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
<RadzenColumn Size="4">
<RadzenFormField Text="Last Update Timestamp" Style="width: 100%;">
<RadzenTextBox Value="@_statusUpdateDt" ReadOnly="true" Style="width: 100%;" />
</RadzenFormField>
</RadzenColumn>
</RadzenRow>
</RadzenCard>
@if (_isLoading)
{
<LoadingIndicator Message="Loading queue..." />
}
else
{
<RadzenDataGrid @ref="_grid" Data="@_searches" TItem="ClientSearchViewModel" AllowSorting="true" AllowPaging="true" PageSize="20"
PagerHorizontalAlign="HorizontalAlign.Center" AllowColumnResize="true">
<Columns>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="UserName" Title="Owner" Width="150px" />
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="Name" Title="Name" />
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="SubmitDT" Title="Submitted" Width="180px">
<Template Context="search">
@(search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="StartDT" Title="Started" Width="180px">
<Template Context="search">
@(search.StartDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="EndDT" Title="Ended" Width="180px">
<Template Context="search">
@(search.EndDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="Status" Title="Status" Width="100px">
<Template Context="search">
<RadzenBadge BadgeStyle="@GetBadgeStyle(search.Status)" Text="@search.Status" />
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid>
}
@code {
private List<ClientSearchViewModel> _searches = [];
private RadzenDataGrid<ClientSearchViewModel>? _grid;
private bool _isLoading = true;
private string _statusMessage = "";
private string _statusUpdateDt = "";
protected override async Task OnInitializedAsync()
{
await LoadQueueAsync();
await SetupSignalRAsync();
}
private async Task LoadQueueAsync()
{
_isLoading = true;
try
{
_searches = await SearchService.GetQueueAsync();
}
finally
{
_isLoading = false;
}
}
private async Task SetupSignalRAsync()
{
HubConnection.OnSearchUpdate += HandleSearchUpdate;
HubConnection.OnStatusUpdate += HandleStatusUpdate;
await HubConnection.StartAsync();
// Get cached status
var cachedStatus = await HubConnection.GetCachedStatusAsync();
if (cachedStatus != null)
{
HandleStatusUpdate(cachedStatus);
}
}
private void HandleSearchUpdate(SearchUpdate update)
{
InvokeAsync(() =>
{
if (update.Status == "Ended" || update.Status == "Error")
{
// Remove completed/errored searches from queue
var existing = _searches.FirstOrDefault(s => s.Id == update.Id);
if (existing != null)
{
_searches.Remove(existing);
}
}
else
{
// Update or add the search
var existing = _searches.FirstOrDefault(s => s.Id == update.Id);
if (existing != null)
{
existing.Status = update.Status;
existing.SubmitDt = update.SubmitDt;
existing.StartDt = update.StartDt;
existing.EndDt = update.EndDt;
}
else
{
_searches.Add(new ClientSearchViewModel
{
Id = update.Id,
Name = update.Name,
UserName = update.UserName,
Status = update.Status,
SubmitDt = update.SubmitDt,
StartDt = update.StartDt,
EndDt = update.EndDt
});
}
}
StateHasChanged();
});
}
private void HandleStatusUpdate(StatusUpdate update)
{
InvokeAsync(() =>
{
_statusMessage = update.Message;
_statusUpdateDt = update.Timestamp?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "";
StateHasChanged();
});
}
private static BadgeStyle GetBadgeStyle(string status) => status switch
{
"Error" => BadgeStyle.Danger,
"Ended" => BadgeStyle.Success,
"Running" => BadgeStyle.Info,
"Queued" => BadgeStyle.Warning,
_ => BadgeStyle.Light
};
public void Dispose()
{
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
HubConnection.OnStatusUpdate -= HandleStatusUpdate;
}
}
@@ -0,0 +1,140 @@
@page "/"
@page "/searches"
@attribute [Authorize]
@inject ISearchService SearchService
@inject IHubConnectionService HubConnection
@inject NavigationManager NavigationManager
@implements IDisposable
<PageTitle>Searches - JDE Scoping Tool</PageTitle>
<RadzenStack Orientation="Orientation.Horizontal" AlignItems="AlignItems.Center" JustifyContent="JustifyContent.SpaceBetween" class="rz-mb-4">
<RadzenText TextStyle="TextStyle.H4" class="rz-m-0">Searches</RadzenText>
<RadzenStack Orientation="Orientation.Horizontal" Gap="0.5rem">
<RadzenButton Text="Start New Search" Icon="add" ButtonStyle="ButtonStyle.Primary" Click="@CreateNewSearch" />
<RadzenButton Text="View Search Queue" Icon="queue" ButtonStyle="ButtonStyle.Light" Click="@ViewQueue" />
</RadzenStack>
</RadzenStack>
@if (_isLoading)
{
<LoadingIndicator Message="Loading searches..." />
}
else
{
<RadzenDataGrid @ref="_grid" Data="@_searches" TItem="ClientSearchViewModel" AllowSorting="true" AllowPaging="true" PageSize="10"
PagerHorizontalAlign="HorizontalAlign.Center" AllowColumnResize="true" RowDoubleClick="@OnRowDoubleClick">
<Columns>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="Name" Title="Name" Width="250px" />
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="SubmitDT" Title="Submitted" Width="180px">
<Template Context="search">
@(search.SubmitDt?.ToString("MM/dd/yyyy hh:mm:ss tt") ?? "")
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Property="Status" Title="Status" Width="100px">
<Template Context="search">
<RadzenBadge BadgeStyle="@GetBadgeStyle(search.Status)" Text="@search.Status" />
</Template>
</RadzenDataGridColumn>
<RadzenDataGridColumn TItem="ClientSearchViewModel" Title="Actions" Width="100px" Sortable="false">
<Template Context="search">
<RadzenButton Text="View" Size="ButtonSize.Small" ButtonStyle="ButtonStyle.Info" Click="@(() => ViewSearch(search.Id))" />
</Template>
</RadzenDataGridColumn>
</Columns>
</RadzenDataGrid>
}
@code {
private List<ClientSearchViewModel> _searches = [];
private RadzenDataGrid<ClientSearchViewModel>? _grid;
private bool _isLoading = true;
protected override async Task OnInitializedAsync()
{
await LoadSearchesAsync();
await SetupSignalRAsync();
}
private async Task LoadSearchesAsync()
{
_isLoading = true;
try
{
_searches = await SearchService.GetUserSearchesAsync();
}
finally
{
_isLoading = false;
}
}
private async Task SetupSignalRAsync()
{
HubConnection.OnSearchUpdate += HandleSearchUpdate;
await HubConnection.StartAsync();
}
private void HandleSearchUpdate(SearchUpdate update)
{
InvokeAsync(() =>
{
var existing = _searches.FirstOrDefault(s => s.Id == update.Id);
if (existing != null)
{
existing.Status = update.Status;
existing.SubmitDt = update.SubmitDt;
existing.StartDt = update.StartDt;
existing.EndDt = update.EndDt;
}
else
{
_searches.Insert(0, new ClientSearchViewModel
{
Id = update.Id,
Name = update.Name,
UserName = update.UserName,
Status = update.Status,
SubmitDt = update.SubmitDt,
StartDt = update.StartDt,
EndDt = update.EndDt
});
}
StateHasChanged();
});
}
private void CreateNewSearch()
{
NavigationManager.NavigateTo("/search");
}
private void ViewQueue()
{
NavigationManager.NavigateTo("/search/queue");
}
private void ViewSearch(int id)
{
NavigationManager.NavigateTo($"/search/{id}");
}
private void OnRowDoubleClick(DataGridRowMouseEventArgs<ClientSearchViewModel> args)
{
ViewSearch(args.Data.Id);
}
private static BadgeStyle GetBadgeStyle(string status) => status switch
{
"Error" => BadgeStyle.Danger,
"Ended" => BadgeStyle.Success,
"Running" => BadgeStyle.Info,
"Queued" => BadgeStyle.Warning,
_ => BadgeStyle.Light
};
public void Dispose()
{
HubConnection.OnSearchUpdate -= HandleSearchUpdate;
}
}
+39
View File
@@ -0,0 +1,39 @@
using JdeScoping.Client;
using JdeScoping.Client.Auth;
using JdeScoping.Client.Services;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
using Radzen;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
// Configure HttpClient with base address
// In Blazor WebAssembly, the browser automatically handles cookies for same-origin requests
builder.Services.AddScoped(sp => new HttpClient
{
BaseAddress = new Uri(builder.HostEnvironment.BaseAddress)
});
// Radzen services
builder.Services.AddRadzenComponents();
// Authentication services (cookie-based)
builder.Services.AddAuthorizationCore();
builder.Services.AddScoped<IUserStorageService, UserStorageService>();
builder.Services.AddScoped<AuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
builder.Services.AddScoped<IAuthService, AuthService>();
// SignalR service
builder.Services.AddScoped<IHubConnectionService, HubConnectionService>();
// API client services
builder.Services.AddScoped<ISearchService, SearchService>();
builder.Services.AddScoped<ILookupService, LookupService>();
builder.Services.AddScoped<IFileService, FileService>();
builder.Services.AddScoped<IRefreshStatusService, RefreshStatusService>();
await builder.Build().RunAsync();
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5091",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,89 @@
using System.Net.Http.Json;
using JdeScoping.Client.Auth;
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles authentication via API calls with cookie-based auth.
/// </summary>
public class AuthService : IAuthService
{
private readonly HttpClient _httpClient;
private readonly AuthStateProvider _authStateProvider;
public AuthService(
HttpClient httpClient,
AuthStateProvider authStateProvider)
{
_httpClient = httpClient;
_authStateProvider = authStateProvider;
}
public async Task<AuthResult> LoginAsync(LoginModel model)
{
try
{
var response = await _httpClient.PostAsJsonAsync("api/auth/login", new
{
model.Username,
model.Password
});
if (response.IsSuccessStatusCode)
{
// API returns UserInfo and sets auth cookie
var userInfo = await response.Content.ReadFromJsonAsync<UserInfoViewModel>();
if (userInfo != null)
{
// Notify auth state provider of the login
await _authStateProvider.MarkUserAsAuthenticated(userInfo);
return new AuthResult
{
Success = true,
User = userInfo
};
}
return new AuthResult
{
Success = false,
ErrorMessage = "Invalid response from server"
};
}
var errorContent = await response.Content.ReadAsStringAsync();
return new AuthResult
{
Success = false,
ErrorMessage = string.IsNullOrEmpty(errorContent)
? "Login failed. Please check your credentials."
: errorContent
};
}
catch (Exception ex)
{
return new AuthResult
{
Success = false,
ErrorMessage = $"Login failed: {ex.Message}"
};
}
}
public async Task LogoutAsync()
{
try
{
// Call logout endpoint to clear server-side cookie
await _httpClient.PostAsync("api/auth/logout", null);
}
catch
{
// Even if logout API fails, clear local state
}
await _authStateProvider.LogoutAsync();
}
}
@@ -0,0 +1,115 @@
using System.Net.Http.Json;
using JdeScoping.Client.Models;
using JdeScoping.Core.ViewModels;
using Microsoft.JSInterop;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles file upload/download operations via the api/fileio endpoints.
/// Authentication is handled via cookies (sent automatically by the browser).
/// </summary>
public class FileService : IFileService
{
private readonly HttpClient _httpClient;
private readonly IJSRuntime _jsRuntime;
public FileService(HttpClient httpClient, IJSRuntime jsRuntime)
{
_httpClient = httpClient;
_jsRuntime = jsRuntime;
}
public async Task DownloadTemplateAsync(string templateType, object? existingData = null)
{
try
{
// Map template type to API endpoint
var endpoint = templateType switch
{
"work-orders" or "workorders" => "api/fileio/workorders/download",
"items" or "part-numbers" => "api/fileio/items/download",
"component-lots" or "componentlots" => "api/fileio/componentlots/download",
"part-operations" or "partoperations" => "api/fileio/partoperations/download",
_ => throw new ArgumentException($"Unknown template type: {templateType}")
};
var fileName = templateType switch
{
"work-orders" or "workorders" => "work_order_template.xlsx",
"items" or "part-numbers" => "item_number_template.xlsx",
"component-lots" or "componentlots" => "component_lot_template.xlsx",
"part-operations" or "partoperations" => "item_operations_mis_template.xlsx",
_ => $"{templateType}_template.xlsx"
};
// POST with existing data to get the Excel file
var response = await _httpClient.PostAsJsonAsync(endpoint, existingData);
if (response.IsSuccessStatusCode)
{
var bytes = await response.Content.ReadAsByteArrayAsync();
await _jsRuntime.InvokeVoidAsync("downloadFile", fileName, bytes);
}
else
{
Console.WriteLine($"Failed to download template: {response.StatusCode}");
}
}
catch (Exception ex)
{
Console.WriteLine($"Failed to download template: {ex.Message}");
}
}
public async Task DownloadPartNumberTemplateAsync(List<ItemViewModel>? existingItems = null)
{
await DownloadTemplateAsync("items", existingItems);
}
public async Task<UploadResult<T>> UploadAsync<T>(string uploadType, Stream fileStream, string fileName)
{
try
{
// Map upload type to API endpoint
var endpoint = uploadType switch
{
"work-orders" or "workorders" => "api/fileio/workorders/upload",
"items" or "part-numbers" => "api/fileio/items/upload",
"component-lots" or "componentlots" => "api/fileio/componentlots/upload",
"part-operations" or "partoperations" => "api/fileio/partoperations/upload",
_ => throw new ArgumentException($"Unknown upload type: {uploadType}")
};
using var content = new MultipartFormDataContent();
using var streamContent = new StreamContent(fileStream);
content.Add(streamContent, "file", fileName);
var response = await _httpClient.PostAsync(endpoint, content);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<UploadResult<T>>();
return result ?? new UploadResult<T>
{
WasSuccessful = false,
ErrorMessage = "Invalid response from server"
};
}
return new UploadResult<T>
{
WasSuccessful = false,
ErrorMessage = $"Upload failed: {response.StatusCode}"
};
}
catch (Exception ex)
{
return new UploadResult<T>
{
WasSuccessful = false,
ErrorMessage = $"Upload failed: {ex.Message}"
};
}
}
}
@@ -0,0 +1,117 @@
using JdeScoping.Client.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.SignalR.Client;
namespace JdeScoping.Client.Services;
/// <summary>
/// Manages SignalR connection with auto-reconnect.
/// Uses cookie-based authentication (browser automatically sends cookies with requests).
/// </summary>
public class HubConnectionService : IHubConnectionService, IAsyncDisposable
{
private readonly NavigationManager _navigationManager;
private HubConnection? _hubConnection;
public event Action<SearchUpdate>? OnSearchUpdate;
public event Action<StatusUpdate>? OnStatusUpdate;
public bool IsConnected => _hubConnection?.State == HubConnectionState.Connected;
public HubConnectionService(NavigationManager navigationManager)
{
_navigationManager = navigationManager;
}
public async Task StartAsync()
{
if (_hubConnection != null)
{
return;
}
// In Blazor WebAssembly, the browser automatically sends cookies with requests
// to the same origin, so we don't need to configure any special auth options
_hubConnection = new HubConnectionBuilder()
.WithUrl(_navigationManager.ToAbsoluteUri("/hubs/status"))
.WithAutomaticReconnect([
TimeSpan.Zero,
TimeSpan.FromSeconds(2),
TimeSpan.FromSeconds(5),
TimeSpan.FromSeconds(10),
TimeSpan.FromSeconds(30)
])
.Build();
_hubConnection.On<SearchUpdate>("searchUpdate", update =>
{
OnSearchUpdate?.Invoke(update);
});
_hubConnection.On<StatusUpdate>("statusUpdate", update =>
{
OnStatusUpdate?.Invoke(update);
});
_hubConnection.Reconnecting += error =>
{
Console.WriteLine($"SignalR reconnecting: {error?.Message}");
return Task.CompletedTask;
};
_hubConnection.Reconnected += connectionId =>
{
Console.WriteLine($"SignalR reconnected: {connectionId}");
return Task.CompletedTask;
};
_hubConnection.Closed += error =>
{
Console.WriteLine($"SignalR closed: {error?.Message}");
return Task.CompletedTask;
};
try
{
await _hubConnection.StartAsync();
Console.WriteLine("SignalR connected");
}
catch (Exception ex)
{
Console.WriteLine($"SignalR connection failed: {ex.Message}");
}
}
public async Task StopAsync()
{
if (_hubConnection != null)
{
await _hubConnection.StopAsync();
await _hubConnection.DisposeAsync();
_hubConnection = null;
}
}
public async Task<StatusUpdate?> GetCachedStatusAsync()
{
if (_hubConnection == null || _hubConnection.State != HubConnectionState.Connected)
{
return null;
}
try
{
return await _hubConnection.InvokeAsync<StatusUpdate>("GetCachedStatus");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get cached status: {ex.Message}");
return null;
}
}
public async ValueTask DisposeAsync()
{
await StopAsync();
}
}
@@ -0,0 +1,29 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for authentication operations.
/// </summary>
public interface IAuthService
{
/// <summary>
/// Attempts to log in with the provided credentials.
/// </summary>
Task<AuthResult> LoginAsync(LoginModel model);
/// <summary>
/// Logs out the current user.
/// </summary>
Task LogoutAsync();
}
/// <summary>
/// Result of an authentication attempt.
/// </summary>
public record AuthResult
{
public bool Success { get; init; }
public string? ErrorMessage { get; init; }
public UserInfoViewModel? User { get; init; }
}
@@ -0,0 +1,35 @@
using JdeScoping.Client.Models;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for file upload/download operations.
/// </summary>
public interface IFileService
{
/// <summary>
/// Downloads the work order template file.
/// </summary>
Task DownloadTemplateAsync(string templateType, object? existingData = null);
/// <summary>
/// Downloads the part number template file.
/// </summary>
Task DownloadPartNumberTemplateAsync(List<ItemViewModel>? existingItems = null);
/// <summary>
/// Uploads a file and returns parsed data.
/// </summary>
Task<UploadResult<T>> UploadAsync<T>(string uploadType, Stream fileStream, string fileName);
}
/// <summary>
/// Result of a file upload operation.
/// </summary>
public class UploadResult<T>
{
public bool WasSuccessful { get; set; }
public string? ErrorMessage { get; set; }
public List<T> Data { get; set; } = [];
}
@@ -0,0 +1,39 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for managing SignalR hub connection.
/// </summary>
public interface IHubConnectionService
{
/// <summary>
/// Event fired when a search update is received.
/// </summary>
event Action<SearchUpdate>? OnSearchUpdate;
/// <summary>
/// Event fired when a processor status update is received.
/// </summary>
event Action<StatusUpdate>? OnStatusUpdate;
/// <summary>
/// Starts the SignalR connection.
/// </summary>
Task StartAsync();
/// <summary>
/// Stops the SignalR connection.
/// </summary>
Task StopAsync();
/// <summary>
/// Gets the cached processor status from the server.
/// </summary>
Task<StatusUpdate?> GetCachedStatusAsync();
/// <summary>
/// Gets the current connection state.
/// </summary>
bool IsConnected { get; }
}
@@ -0,0 +1,30 @@
using JdeScoping.Client.Models;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for lookup/autocomplete API operations.
/// </summary>
public interface ILookupService
{
/// <summary>
/// Finds items matching the search term.
/// </summary>
Task<List<ItemViewModel>> FindItemsAsync(string searchTerm);
/// <summary>
/// Finds profit centers matching the search term.
/// </summary>
Task<List<ProfitCenterViewModel>> FindProfitCentersAsync(string searchTerm);
/// <summary>
/// Finds work centers matching the search term.
/// </summary>
Task<List<WorkCenterViewModel>> FindWorkCentersAsync(string searchTerm);
/// <summary>
/// Finds operators matching the search term.
/// </summary>
Task<List<OperatorViewModel>> FindOperatorsAsync(string searchTerm);
}
@@ -0,0 +1,14 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for data refresh status API operations.
/// </summary>
public interface IRefreshStatusService
{
/// <summary>
/// Gets refresh status records within the specified date range.
/// </summary>
Task<List<DataUpdateViewModel>> GetRefreshStatusAsync(DateTime minDt, DateTime maxDt);
}
@@ -0,0 +1,39 @@
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Service for search-related API operations.
/// </summary>
public interface ISearchService
{
/// <summary>
/// Gets all searches for the current user.
/// </summary>
Task<List<SearchViewModel>> GetUserSearchesAsync();
/// <summary>
/// Gets a specific search by ID.
/// </summary>
Task<SearchViewModel?> GetSearchAsync(int id);
/// <summary>
/// Copies an existing search to create a new one.
/// </summary>
Task<SearchViewModel?> CopySearchAsync(int id);
/// <summary>
/// Saves and submits a search.
/// </summary>
Task<int?> SaveSearchAsync(SearchViewModel search);
/// <summary>
/// Gets all searches in the queue.
/// </summary>
Task<List<SearchViewModel>> GetQueueAsync();
/// <summary>
/// Downloads the results for a completed search.
/// </summary>
Task<byte[]?> DownloadResultsAsync(int id);
}
@@ -0,0 +1,99 @@
using System.Net.Http.Json;
using JdeScoping.Client.Models;
using JdeScoping.Core.ViewModels;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles lookup/autocomplete API calls.
/// Authentication is handled via cookies (sent automatically by the browser).
/// </summary>
public class LookupService : ILookupService
{
private readonly HttpClient _httpClient;
public LookupService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<ItemViewModel>> FindItemsAsync(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm) || searchTerm.Length < 3)
{
return [];
}
try
{
var result = await _httpClient.GetFromJsonAsync<List<ItemViewModel>>(
$"api/lookup/items?q={Uri.EscapeDataString(searchTerm)}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to find items: {ex.Message}");
return [];
}
}
public async Task<List<ProfitCenterViewModel>> FindProfitCentersAsync(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm) || searchTerm.Length < 3)
{
return [];
}
try
{
var result = await _httpClient.GetFromJsonAsync<List<ProfitCenterViewModel>>(
$"api/lookup/profit-centers?q={Uri.EscapeDataString(searchTerm)}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to find profit centers: {ex.Message}");
return [];
}
}
public async Task<List<WorkCenterViewModel>> FindWorkCentersAsync(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm) || searchTerm.Length < 3)
{
return [];
}
try
{
var result = await _httpClient.GetFromJsonAsync<List<WorkCenterViewModel>>(
$"api/lookup/work-centers?q={Uri.EscapeDataString(searchTerm)}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to find work centers: {ex.Message}");
return [];
}
}
public async Task<List<OperatorViewModel>> FindOperatorsAsync(string searchTerm)
{
if (string.IsNullOrEmpty(searchTerm) || searchTerm.Length < 3)
{
return [];
}
try
{
var result = await _httpClient.GetFromJsonAsync<List<OperatorViewModel>>(
$"api/lookup/operators?q={Uri.EscapeDataString(searchTerm)}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to find operators: {ex.Message}");
return [];
}
}
}
@@ -0,0 +1,35 @@
using System.Net.Http.Json;
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles refresh status API calls.
/// Authentication is handled via cookies (sent automatically by the browser).
/// </summary>
public class RefreshStatusService : IRefreshStatusService
{
private readonly HttpClient _httpClient;
public RefreshStatusService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<DataUpdateViewModel>> GetRefreshStatusAsync(DateTime minDt, DateTime maxDt)
{
try
{
var minDtStr = minDt.ToString("yyyy-MM-dd");
var maxDtStr = maxDt.ToString("yyyy-MM-dd");
var result = await _httpClient.GetFromJsonAsync<List<DataUpdateViewModel>>(
$"api/refresh-status?minDT={minDtStr}&maxDT={maxDtStr}");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get refresh status: {ex.Message}");
return [];
}
}
}
@@ -0,0 +1,129 @@
using System.Net.Http.Json;
using JdeScoping.Client.Models;
namespace JdeScoping.Client.Services;
/// <summary>
/// Handles search-related API calls.
/// Authentication is handled via cookies (sent automatically by the browser).
/// </summary>
public class SearchService : ISearchService
{
private readonly HttpClient _httpClient;
public SearchService(HttpClient httpClient)
{
_httpClient = httpClient;
}
public async Task<List<SearchViewModel>> GetUserSearchesAsync()
{
try
{
var result = await _httpClient.GetFromJsonAsync<List<SearchViewModel>>("api/search");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get searches: {ex.Message}");
return [];
}
}
public async Task<SearchViewModel?> GetSearchAsync(int id)
{
try
{
return await _httpClient.GetFromJsonAsync<SearchViewModel>($"api/search/{id}");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get search {id}: {ex.Message}");
return null;
}
}
public async Task<SearchViewModel?> CopySearchAsync(int id)
{
try
{
return await _httpClient.GetFromJsonAsync<SearchViewModel>($"api/search/{id}/copy");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to copy search {id}: {ex.Message}");
return null;
}
}
public async Task<int?> SaveSearchAsync(SearchViewModel search)
{
try
{
var response = await _httpClient.PostAsJsonAsync("api/search", new
{
search.Name,
search.UserName,
Criteria = new
{
MinimumDT = search.Criteria.MinimumDt,
MaximumDT = search.Criteria.MaximumDt,
WorkOrders = search.Criteria.WorkOrders.Select(wo => new { wo.WorkOrderNumber }),
Items = search.Criteria.Items.Select(i => new { i.ItemNumber }),
ProfitCenters = search.Criteria.ProfitCenters.Select(pc => new { pc.Code }),
WorkCenters = search.Criteria.WorkCenters.Select(wc => new { wc.Code }),
ComponentLots = search.Criteria.ComponentLots.Select(cl => new { cl.LotNumber, cl.ItemNumber }),
Operators = search.Criteria.Operators.Select(op => new { op.AddressNumber, UserID = op.UserId }),
PartOperations = search.Criteria.PartOperations.Select(po => new
{
po.ItemNumber,
po.OperationNumber,
po.MisNumber,
po.MisRevision
}),
search.Criteria.ExtractMisData
}
});
if (response.IsSuccessStatusCode)
{
return await response.Content.ReadFromJsonAsync<int>();
}
Console.WriteLine($"Failed to save search: {response.StatusCode}");
return null;
}
catch (Exception ex)
{
Console.WriteLine($"Failed to save search: {ex.Message}");
return null;
}
}
public async Task<List<SearchViewModel>> GetQueueAsync()
{
try
{
var result = await _httpClient.GetFromJsonAsync<List<SearchViewModel>>("api/search/queue");
return result ?? [];
}
catch (Exception ex)
{
Console.WriteLine($"Failed to get queue: {ex.Message}");
return [];
}
}
public async Task<byte[]?> DownloadResultsAsync(int id)
{
try
{
return await _httpClient.GetByteArrayAsync($"api/search/{id}/results");
}
catch (Exception ex)
{
Console.WriteLine($"Failed to download results for {id}: {ex.Message}");
return null;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.AspNetCore.SignalR.Client
@using Microsoft.JSInterop
@using Radzen
@using Radzen.Blazor
@using JdeScoping.Client
@using JdeScoping.Client.Auth
@using JdeScoping.Client.Components.FilterPanels
@using JdeScoping.Client.Components.Shared
@using JdeScoping.Client.Layout
@using JdeScoping.Client.Models
@using JdeScoping.Client.Pages
@using JdeScoping.Client.Services
@using JdeScoping.Core.ViewModels
@using ClientSearchViewModel = JdeScoping.Client.Models.SearchViewModel
@@ -0,0 +1,287 @@
/* JDE Scoping Tool - Application Styles */
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
height: 100%;
margin: 0;
}
#app {
height: 100%;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #0071c1;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.loading-progress {
position: absolute;
display: block;
width: 8rem;
height: 8rem;
inset: 20vh 0 auto 0;
margin: 0 auto 0 auto;
}
.loading-progress circle {
fill: none;
stroke: #e0e0e0;
stroke-width: 0.6rem;
transform-origin: 50% 50%;
transform: rotate(-90deg);
}
.loading-progress circle:last-child {
stroke: #1b6ec2;
stroke-dasharray: calc(3.141 * var(--blazor-load-percentage, 0%) * 0.8), 500%;
transition: stroke-dasharray 0.05s ease-in-out;
}
.loading-progress-text {
position: absolute;
text-align: center;
font-weight: bold;
inset: calc(20vh + 3.25rem) 0 auto 0.2rem;
}
.loading-progress-text:after {
content: var(--blazor-load-percentage-text, "Loading");
}
code {
color: #c02d76;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}
/* Custom Application Styles */
/* Status badges */
.status-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge-success {
background-color: #28a745;
color: white;
}
.status-badge-danger {
background-color: #dc3545;
color: white;
}
.status-badge-warning {
background-color: #ffc107;
color: #212529;
}
.status-badge-info {
background-color: #17a2b8;
color: white;
}
/* Card styles */
.rz-card {
margin-bottom: 1rem;
}
/* Loading container */
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
min-height: 200px;
}
/* Filter panel styles */
.filter-panel {
margin-bottom: 1rem;
}
.filter-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
/* Grid content max height */
.rz-data-grid .rz-data-grid-data {
max-height: 300px;
overflow-y: auto;
}
/* Radzen Layout adjustments */
.rz-layout {
height: 100vh;
}
/* Fixed Top Navbar */
.navbar-fixed-top {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 1030;
background-color: #222;
color: white;
height: 56px;
padding: 0;
}
.navbar-container {
display: flex;
align-items: center;
justify-content: space-between;
height: 100%;
padding: 0 15px;
max-width: 100%;
}
.navbar-left {
display: flex;
align-items: center;
gap: 2rem;
}
.navbar-brand {
color: #fff;
font-size: 1.25rem;
font-weight: bold;
text-decoration: none;
}
.navbar-brand:hover {
color: #fff;
text-decoration: none;
}
.navbar-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-link {
color: #9d9d9d;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
transition: color 0.15s ease-in-out;
}
.nav-link:hover {
color: #fff;
text-decoration: none;
}
.nav-link.active {
color: #fff;
background-color: rgba(255, 255, 255, 0.1);
}
.navbar-right {
display: flex;
align-items: center;
gap: 1rem;
}
.navbar-user {
color: #9d9d9d;
}
/* Main body with top padding for fixed navbar */
.main-body {
padding-top: 56px;
}
.body-content {
padding: 20px 15px;
min-height: calc(100vh - 56px - 50px); /* viewport - navbar - footer */
}
/* Footer */
.rz-footer {
background-color: #f8f9fa;
border-top: 1px solid #dee2e6;
padding: 0.5rem 0;
}
/* Container fluid */
.container-fluid {
padding: 0;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>JDE Scoping Tool</title>
<base href="/" />
<link rel="preload" id="webassembly" />
<!-- Radzen Blazor CSS -->
<link rel="stylesheet" href="_content/Radzen.Blazor/css/material-base.css" />
<link rel="stylesheet" href="lib/bootstrap/dist/css/bootstrap.min.css" />
<link rel="stylesheet" href="css/app.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<link href="JdeScoping.Client.styles.css" rel="stylesheet" />
<script type="importmap"></script>
</head>
<body>
<div id="app">
<svg class="loading-progress">
<circle r="40%" cx="50%" cy="50%" />
<circle r="40%" cx="50%" cy="50%" />
</svg>
<div class="loading-progress-text"></div>
</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">X</span>
</div>
<!-- JS Interop -->
<script src="js/interop.js"></script>
<!-- Radzen Blazor JS -->
<script src="_content/Radzen.Blazor/Radzen.Blazor.js"></script>
<script src="_framework/blazor.webassembly#[.{fingerprint}].js"></script>
</body>
</html>
@@ -0,0 +1,70 @@
// JDE Scoping Tool - JavaScript Interop Functions
// Global download function for file byte arrays (called from Blazor)
window.downloadFile = function (fileName, byteArray) {
const blob = new Blob([new Uint8Array(byteArray)]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? 'download';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
};
window.jdeScopingInterop = {
// Download file from a byte array stream
downloadFileFromStream: async function (fileName, contentStreamReference) {
const arrayBuffer = await contentStreamReference.arrayBuffer();
const blob = new Blob([arrayBuffer]);
const url = URL.createObjectURL(blob);
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? 'download';
anchorElement.click();
anchorElement.remove();
URL.revokeObjectURL(url);
},
// Download file from a URL
downloadFileFromUrl: function (url, fileName) {
const anchorElement = document.createElement('a');
anchorElement.href = url;
anchorElement.download = fileName ?? 'download';
anchorElement.target = '_blank';
anchorElement.click();
anchorElement.remove();
},
// Save value to localStorage
setLocalStorage: function (key, value) {
localStorage.setItem(key, value);
},
// Get value from localStorage
getLocalStorage: function (key) {
return localStorage.getItem(key);
},
// Remove value from localStorage
removeLocalStorage: function (key) {
localStorage.removeItem(key);
},
// Save value to sessionStorage (clears when browser closes)
setSessionStorage: function (key, value) {
sessionStorage.setItem(key, value);
},
// Get value from sessionStorage
getSessionStorage: function (key) {
return sessionStorage.getItem(key);
},
// Remove value from sessionStorage
removeSessionStorage: function (key) {
sessionStorage.removeItem(key);
}
};
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

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