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:
@@ -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 -->
|
||||
@@ -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 -->
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
+4085
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+4084
File diff suppressed because it is too large
Load Diff
+1
File diff suppressed because one or more lines are too long
+6
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+597
@@ -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
Reference in New Issue
Block a user