Compare commits

..

16 Commits

Author SHA1 Message Date
kjh2064 11e8a3c7f8 docs: final file-count status for this session's cleanup thread
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 23s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 11s
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Failing after 10s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 13s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 13s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 2m28s
4,523 -> 1,687 (77% of the 2,200 budget). Records the
test_kis_data_collection_v1.py import fix and the golden-stub removal
reasoning, and explicitly notes that further file-count reduction
isn't warranted right now - the gate has real headroom and continuing
to hunt for deletions has more downside (accidentally cutting
something live) than upside at this point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 15:02:18 +09:00
kjh2064 65e3769eb2 chore: remove 174 non-executing golden test stubs (M3 parity now done)
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 14s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 24s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Failing after 10s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 11s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 11s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 2m26s
spec/60_quant_engine_wbs.yaml's own QE-M0-06 note explicitly deferred
this: "골든 테스트 174개는 실행되지 않는 placeholder... 삭제는 M3
패리티 대체 후" (the 174 golden tests are non-executing placeholders;
delete once M3 parity replaces them). Confirmed all 5 M3 tasks
(point-in-time reader, factor parity calculator, SS001 composite
score, PipelineOrchestrator honesty, score FE) are status=DONE.

Verified before deleting, not just inferred:
- No .gitea/workflows/*.yml references tests/golden/generated/ - CI
  only runs specific named files under tests/unit/.
- tools/validate_golden_coverage_100.py's coverage ratio is computed
  from spec/formula_golden_cases_v2-v4.yaml entries, not by counting
  these stub files (its own code comment says so).
- Sampled a stub: trivial always-true assertions, one even internally
  broken (`assert outputs` on an empty list) - proof it never actually
  ran.

Re-ran the coverage validator, QE-M0-06's WBS verdict, and full pytest
collection after deletion - all still pass/clean. tests/golden/
itself (README.md, formula_registry_v2_cases.yaml) is untouched, only
the generated/ subdirectory is gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 15:01:20 +09:00
kjh2064 4dd6098619 fix: broken import in test_kis_data_collection_v1.py (same root cause as F8)
Same class of bug as the earlier tools/run_kis_data_collection_v1.py
fix this session: the module moved to src/quant_engine/deprecated/
but this test still imported the old path. Found by actually running
pytest --collect-only on tests/unit/ (110 tests collect cleanly now,
was failing at 1 error before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:34:01 +09:00
kjh2064 70e49f0556 docs: update handoff notes with orphan cleanup and xlsx findings
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 13s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 22s
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Failing after 11s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 11s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 10s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 1m51s
Records the two-pass orphan script cleanup (74 files total across two
commits) and the methodology gap that mattered (check output-path
references, not just script-name references). Also records that
GatherTradingData.json (the project's own designated canonical seed,
per docs/GATHERTRADINGDATA_XLSX_DECISION) is available from the
production server even without the untracked .xlsx, but the release
DAG's convert_xlsx node has no skip-if-output-exists logic, and the
remaining 10 blocked WBS verdicts need a running QuantEngine.Web +
Playwright evidence run against real DB price history, not just the
seed JSON - not attempted this session.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:29:49 +09:00
kjh2064 73572b6211 chore: remove 40 more orphaned one-off scripts from tools/
Second pass over the 47 lower-confidence orphan candidates held back
from the earlier 34-file cleanup. This time checked two signals, not
one: (1) is the script's own filename referenced anywhere, and (2)
does the script's own Temp/*.json output get read by any other file
(a script can be "orphaned" by name but still load-bearing if
something else consumes what it produces).

7 of the 47 failed check (2) - their outputs (outcome_ledger_v1.json,
pre_distribution_early_warning_v3.json, shadow_ledger_v1.json,
calibration_registry_v1.json, final_execution_decision_v4.json,
live_outcome_ledger_v1.json, final_decision_packet_v2.json) are read
by other tools even though the producing script's name never appears
elsewhere - kept those 7 in place.

The remaining 40 (WBS-ticket-tagged one-offs: wbs81/92/93/95/96_*,
build_p0-p6_*; and misc build_*/validate_* diagnostics with no
external reference by name or by output) were deleted. Re-ran the
full-repo reference search (zero hits) and quick-mode release DAG +
WBS validator afterward - failure set is unchanged (same 10
tasks already known blocked on missing live trading data / a running
web app + Playwright, unrelated to this deletion).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:29:08 +09:00
kjh2064 2439d5e24d docs: record the Temp/ evidence-cache incident in the handoff notes
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 13s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 23s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 12s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 11s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 9s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 1m50s
Entropy cleanup earlier today deleted all of Temp/, which turned out
to include WBS verification evidence (Temp/evidence/*/verdict.json),
not just build cache. Documents what broke, what got recovered, and
what remains genuinely blocked on missing trading data - so this
doesn't get rediscovered from scratch next time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:12:42 +09:00
kjh2064 977167a0db chore: remove 34 orphaned one-off scripts from tools/
Systematic scan (not manual per-file review): for every *.py under
tools/ and src/quant_engine/, searched the entire tracked repo for
its filename stem outside itself (code, docs, spec, CI workflows,
tests). 81 of 619 files had zero references anywhere. Deleting all
81 in one pass was judged too risky - some could be intentional
manual-run utilities that just don't get named by other files - so
this commit only removes the two highest-confidence tiers, 34 files:

- Superseded DB init/fix/load scripts, most with an explicit "_v2"/
  "_correct"/"_properly" sibling that replaced them (e.g.
  load_from_xlsx.py -> load_from_xlsx_correct.py,
  initialize_database.py -> initialize_database_v2.py). Verified via
  `git log` that both members of each pair were last touched the same
  day (2026-06-23) and never again - abandoned mid-iteration, not
  live tooling.
- Ad-hoc test/debug scripts sitting in tools/ instead of tests/
  (test_api_components.py, test_build_ui_state*.py, diagnose_api_error.py,
  verify_*.py, validate_json_conversion.py) - one-off interactive
  debugging session artifacts.

Re-ran the full-repo reference search after deletion (zero hits) and
the quick-mode release DAG + WBS validator (both still pass) to confirm
nothing references these files. The other 47 orphan candidates (WBS-
ticket-tagged one-offs and misc build_*/validate_* scripts) are left
in place pending further review - some may be intentional manual
utilities the automated reference scan can't distinguish from dead code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 14:12:09 +09:00
kjh2064 fa0da0b159 docs: add session handoff notes for the 2026-07-30 strategic audit
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 12s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 26s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 13s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 11s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 10s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 1m56s
Adds docs/SESSION_HANDOFF_2026-07-30.md with what a future session
needs that isn't derivable from git log: open threads (release DAG
unverified end-to-end, engine_history/quantengine schema collision
left as-is, OpenDART/KRX not yet wired into any workflow) and traps
hit this session worth not repeating (archive-before-grepping
tools/spec/tests, DbUp ordinal sort, runtime/ is tracked history not
scratch space, two Pythons on this machine, entropy audit counting
local build noise). Pointed to it from CLAUDE.md's Known Issues list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:43:32 +09:00
kjh2064 823a9a64e2 fix: repository entropy audit was measuring build noise, not repo bloat
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 13s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 23s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 13s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 10s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 10s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 1m56s
audit_repository_entropy_v2.py (the "과유불급" file-count gate in the
release DAG) was failing: 4,523 files against a 2,200 budget. Traced
it down before touching anything - the audit's _EXCLUDE_DIRS list
predates several directories this repo's own .gitignore already
excludes (Temp/, dist/, outputs/, publish_artifact/, **/bin/,
**/obj/, test-results/), so every local dotnet build/test run
inflates the count with disposable, zero-git-impact build artifacts.
Verified via `git ls-files` that none of the ~2,400 files in those
paths were tracked, versus runtime/, archive_db/, backups/, and
artifacts/ (246 files, all tracked, all genuine audit-trail/history
data) which must NOT be touched.

Cleaned the untracked build/scratch artifacts from disk and added the
missing directories to _EXCLUDE_DIRS so the metric can't be
re-inflated by normal local development again. Gate now passes at
1,934 files with real headroom, without deleting a single tracked
file. Verified full .NET build still succeeds after removing all
bin/obj folders (clean rebuild, 0 errors/warnings).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 13:38:42 +09:00
kjh2064 0a2f0eb4a1 fix: un-archive two docs that CI/release-gate validators depend on
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 15s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 25s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 12s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 11s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 12s
Workflow Lint & Validation / Lint All Workflow Files (push) Failing after 13s
Workflow Lint & Validation / Validate Secrets Contract (push) Successful in 6s
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 2m5s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Workflow Lint & Validation / Notify Lint Results (push) Failing after 1s
Qualitative Sell Strategy Pipeline / Validate Qualitative Sell Strategy (push) Failing after 7s
Qualitative Sell Strategy Pipeline / Notify Strategy Validation Status (push) Failing after 1s
docs/ROADMAP_WBS.md and docs/ROADMAP_ENTERPRISE_TEMPLATES_WBS.md were
archived in an earlier commit today as "superseded planning docs," but
that judgment only checked CLAUDE.md/README/other docs for references
- not the actual validator scripts, spec YAMLs, and unit tests that
drive the release gate. Both paths turn out to be load-bearing:

- docs/ROADMAP_WBS.md is read directly by
  tools/validate_quant_engine_wbs_v1.py,
  tools/validate_platform_transition_wbs_v1.py,
  tests/unit/test_validate_quant_engine_wbs_v1.py, and referenced by
  spec/60_quant_engine_wbs.yaml and others.
- docs/ROADMAP_ENTERPRISE_TEMPLATES_WBS.md is read by
  tools/validate_enterprise_crud_specification_v1.py, which runs as
  the "Enterprise Contract Parity Test" step in
  .gitea/workflows/ci-frontend.yml.

Moved both back to their original path, replaced the stale "ARCHIVED"
banner with a note explaining why they can't be archived without first
updating the validators, and fixed the 4 doc cross-references that had
been repointed at docs/archive/. Verified all three affected validators
now exit 0 by actually running them locally.

The other 9 docs archived earlier today (5 CI/CD docs + CICD_ROADMAP.md,
EXECUTION_PLAN_PHASE0_CLOSEOUT_AND_PHASE1_KICKOFF.md,
MODERNIZATION_ROADMAP_VISUAL.md,
MODERNIZATION_STRATEGY_ROADMAP_2026-2027.md) were checked against the
same grep sweep (*.py/*.yaml/*.yml/*.js/*.ts/*.cs/*.json, whole repo)
and have zero references outside markdown - those archivals stand.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 12:36:20 +09:00
kjh2064 8dabffc08a chore: commit pre-existing untracked work + finish archived-doc notes
Commits work that predated this session and was sitting untracked:
qualitative sell strategy module (src/quant_engine), the t20 outcome
ledger cron workflow, the QuantEngine masterpiece roadmap doc, a WBS
execution yaml, and shared Razor Pages partials (page header, delete
confirm modal) with their view models. Verified these build clean
(0 errors, 0 warnings) before committing.

Also includes the "ARCHIVED" header notes on 6 docs archived in the
previous commit - the git-mv landed there, but the note text itself
was missed from that commit's staged file list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 12:21:32 +09:00
kjh2064 88e3d58664 docs: archive superseded roadmap/WBS docs, fix broken install command, retire stale files
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 18s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 26s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 12s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 12s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 9s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 1m56s
Archives 6 more superseded planning docs to docs/archive/ (ROADMAP_WBS.md,
MODERNIZATION_ROADMAP_VISUAL.md, MODERNIZATION_STRATEGY_ROADMAP_2026-2027.md,
ROADMAP_ENTERPRISE_TEMPLATES_WBS.md, EXECUTION_PLAN_PHASE0_CLOSEOUT_AND_PHASE1_KICKOFF.md,
CICD_ROADMAP.md), each replaced by a current source of truth (CLAUDE.md,
docs/MIGRATION_STATUS.md, or the OMS·WMS·ERP spec/playbook), and repoints
4 files that linked to the pre-archive path.

Fixes README's top-of-file install instructions and package.json's
"ops:dev" script, both of which pointed at core_satellite_collector.js -
a file that has never existed anywhere in this repo's git history. The
real, working entry point (tools/run_kis_data_collection_v1.py /
npm run ops:data-collect) was already correctly documented further
down the same README.

Also finishes retiring pre-existing stale state that predates this
session: removes the superseded deploy-prod.yml.backup, completes the
already-in-progress removal of the old src/client/ Vue+AG-Grid
prototype (superseded by src/frontend/), and untracks
test-results/.last-run.json (Playwright's own run-metadata file,
regenerated every test run - shouldn't be version controlled).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 11:50:25 +09:00
kjh2064 477bd693c1 fix: unify OpenDART env var name with Gitea Secrets; add missed migration header notes
tools/ingest_fundamental_raw.py read DART_API_KEY, but the Gitea Secret
is registered as OPENDART_OPENAPI_KEY, and no workflow bridges the two
(none currently invoke this script). Renamed the code side to match
the secret name directly rather than adding a mapping layer, so
whenever this gets wired into a workflow it just works. Updated the
matching README setup instructions.

Also includes the V9/V10 migration header explanations (why they were
renamed from V003/V004) that were written earlier but missed from the
previous commit's file list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 11:39:48 +09:00
kjh2064 279d1760ef fix: migration ordering bug that could block fresh-DB deploys
V004_normalize_snapshots_schema.sql had an unguarded FK to a table V2
creates. Under DbUp's default ordinal filename sort, the zero-padded
"V003_"/"V004_" migrations sorted before "V1__", so on a brand-new
database V004 would hard-fail on that FK and abort every migration
after it - V1 through V8 would never run. Confirmed via production
that neither V003 nor V004 had ever actually applied.

Fix: renamed them to V9__/V10__ and added MigrationScriptNameComparer,
which sorts DbUp scripts by numeric V{n} value instead of raw string
order, so double-digit versions can never again sort ahead of earlier
single-digit ones. Added regression tests for both the fixed case and
the original bug shape.

Also updates docs/db/quantengine.dbml (renamed migrations, and closes
out the engine_history/quantengine table-name-collision question -
both schemas are live, backing different code paths, not duplicates)
and CLAUDE.md (migration ordering fix, KIS/OpenDART/KRX credential
env-var-name reference, including a CI secret/env-var name mismatch
found for OpenDART that still needs a decision).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 11:36:00 +09:00
kjh2064 70824c2afb fix: security, data-integrity, and doc-drift findings from repo audit
Consolidates duplicate KIS API client implementations (governance tests
were exercising an unused class instead of the one actually running in
production), closes a SQL injection path in the DB admin page, fixes a
migration that used MySQL-only syntax and had never actually applied
(confirmed against production), resyncs docs/db/quantengine.dbml with
all migrations, and removes a duplicate OMS·WMS·ERP frontend tree in
favor of src/frontend/. Also corrects several unverifiable/inflated
claims in the OMS planning docs and realigns CI/CD and architecture
documentation with what's actually in the repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-30 11:20:02 +09:00
kjh2064 99943d9871 Merge pull request 'Add OMS WMS ERP platform' (#16) from agent/oms-wms-erp into main
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 24s
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 12s
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 12s
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 12s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 11s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Frontend CI Pipeline / ci-frontend-8-steps (push) Failing after 2m29s
Add OMS WMS ERP platform
2026-07-27 00:46:36 +09:00
462 changed files with 3357 additions and 47826 deletions
-644
View File
@@ -1,644 +0,0 @@
name: Deploy to Production
on:
push:
branches: [ main ]
workflow_dispatch:
# Phase 4: Manual-only deployment (improved & hardened)
# Automatic deployment moved to merge-to-main.yml (Stage 5)
# Use this workflow for manual deployments when needed
#
# Error handling: Comprehensive logging + automatic rollback
# Security: SSH key validation, deployment verification
# Observability: Detailed stage reporting + Telegram notifications
concurrency:
group: deploy-prod-main
cancel-in-progress: false
env:
DEPLOY_HOST: quant.taxbaik.com
DEPLOY_USER: kjh2064
SERVICE_NAME: quantengine
DOTNET_VERSION: '10.0.x'
QUANTENGINE_DB_NAME: quantenginedb
QUANTENGINE_DB_USER: quantengine_app
TELEGRAM_BOT_TOKEN_DEFAULT: "8734507814:AAFyacLMai8GB4K-hQ_Nd3t3D01A-H1ZdV0"
TELEGRAM_CHAT_ID_DEFAULT: "-5460205872"
DEPLOY_TIMEOUT: "600"
HEALTH_CHECK_RETRIES: "5"
HEALTH_CHECK_DELAY: "3"
jobs:
build-and-deploy:
name: Build & Deploy to Production
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install Python Dependencies
run: pip install pyyaml openpyxl requests
- name: "[GATE] Run Core Validations"
run: |
echo " Running critical CI validations..."
python3 tools/validate_no_direct_api_trading_v1.py || exit 1
python3 tools/validate_specs.py || exit 1
echo " All critical validations passed"
- name: Ensure Temp Directory and Mock Packet
run: |
mkdir -p Temp
if [ ! -f Temp/final_decision_packet_active.json ]; then
echo '{"active_decision": "PASS", "details": "CI dummy packet"}' > Temp/final_decision_packet_active.json
fi
- name: Restore Dependencies
run: dotnet restore src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj
- name: Build Release
run: |
dotnet build src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
-c Release \
--no-restore
- name: Run Unit Tests
run: |
dotnet test src/dotnet/QuantEngine.Core.Tests/QuantEngine.Core.Tests.csproj \
-c Release \
--no-build
- name: Publish Release Package
run: |
dotnet publish src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj \
-c Release \
--no-build \
-o ./publish
- name: Generate Build Info
run: |
COMMIT_HASH=$(git rev-parse --short HEAD)
BUILD_TIME=$(date -d "+9 hours" +'%Y-%m-%d %H:%M:%S KST')
mkdir -p ./publish/wwwroot
printf '{\n "version": "1.0.%s-%s",\n "built": "%s"\n}\n' "${{ github.run_number }}" "$COMMIT_HASH" "$BUILD_TIME" > ./publish/wwwroot/version.json
echo " Generated version info: 1.0.${{ github.run_number }}-$COMMIT_HASH @ $BUILD_TIME"
- name: Prepare & Validate QuantEngine DB Env
run: |
echo " Preparing database environment..."
DB_PASSWORD="${{ secrets.QUANTENGINE_DB_PASSWORD }}"
if [ -z "$DB_PASSWORD" ]; then
echo " QUANTENGINE_DB_PASSWORD secret not configured in Gitea"
echo " Please set secret in Repository Settings > Secrets"
exit 1
fi
if [ -z "${{ env.QUANTENGINE_DB_NAME }}" ] || [ -z "${{ env.QUANTENGINE_DB_USER }}" ]; then
echo " DB configuration environment variables not set"
exit 1
fi
#
mkdir -p ./deploy
printf 'ConnectionStrings__DefaultConnection=Host=127.0.0.1;Database=%s;Username=%s;Password=%s;Search Path=quantengine;\n' \
"${{ env.QUANTENGINE_DB_NAME }}" \
"${{ env.QUANTENGINE_DB_USER }}" \
"$DB_PASSWORD" > ./deploy/quantengine.env
chmod 600 ./deploy/quantengine.env
# appsettings.Production.json
mkdir -p ./publish
cat <<EOF > ./publish/appsettings.Production.json
{
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Database=${{ env.QUANTENGINE_DB_NAME }};Username=${{ env.QUANTENGINE_DB_USER }};Password=${DB_PASSWORD};Search Path=quantengine;"
}
}
EOF
chmod 600 ./publish/appsettings.Production.json
if [ ! -f ./deploy/quantengine.env ] || [ ! -f ./publish/appsettings.Production.json ]; then
echo " Failed to create database config files"
exit 1
fi
echo " Database configuration prepared"
- name: Copy Deployment Scripts
run: |
echo " Copying deployment scripts..."
cp deploy_gb.sh ./publish/deploy_gb.sh
mkdir -p ./publish/scripts
cp scripts/validate_migrations.sh ./publish/scripts/validate_migrations.sh
chmod +x ./publish/deploy_gb.sh ./publish/scripts/validate_migrations.sh
echo " Deployment scripts copied"
- name: Package Artifact
run: |
echo " Creating deployment package..."
if ! tar -czf quantengine.tar.gz -C ./publish .; then
echo " Failed to create package"
exit 1
fi
PACKAGE_SIZE=$(du -sh quantengine.tar.gz | cut -f1)
PACKAGE_BYTES=$(stat -c%s quantengine.tar.gz 2>/dev/null || echo "0")
if [ -z "$PACKAGE_BYTES" ] || [ "$PACKAGE_BYTES" -lt 1000000 ]; then
echo " Warning: Package seems too small ($PACKAGE_SIZE)"
fi
if [ ! -f quantengine.tar.gz ]; then
echo " Package file not created"
exit 1
fi
echo " Package created: $PACKAGE_SIZE"
tar -tzf quantengine.tar.gz | head -n 5 || true
- name: Pre-Deployment Migration Validation
run: |
echo "=== Pre-Deployment Database Check ==="
# ()
TEMP_DEPLOY="/tmp/quantengine_validate"
mkdir -p "$TEMP_DEPLOY"
tar -xzf quantengine.tar.gz -C "$TEMP_DEPLOY"
#
chmod +x "$TEMP_DEPLOY/scripts/validate_migrations.sh"
"$TEMP_DEPLOY/scripts/validate_migrations.sh" "$TEMP_DEPLOY"
#
rm -rf "$TEMP_DEPLOY"
- name: Pre-Deployment Verification
run: |
echo "=== PRE-DEPLOYMENT CHECKS ==="
# 1. SSH
if [ ! -f ~/.ssh/id_rsa ]; then
echo "ERROR: SSH key not found"
exit 1
fi
echo "OK: SSH key present"
# 2.
if [ ! -f quantengine.tar.gz ]; then
echo "ERROR: Build artifact (quantengine.tar.gz) not found"
exit 1
fi
ARTIFACT_SIZE=$(stat -c%s quantengine.tar.gz)
if [ "$ARTIFACT_SIZE" -lt 1000000 ]; then
echo "WARNING: Artifact seems small (${ARTIFACT_SIZE} bytes), but proceeding"
fi
echo "OK: Build artifact present (${ARTIFACT_SIZE} bytes)"
# 3.
for file in deploy/quantengine.env deploy_gb.sh; do
if [ ! -f "$file" ]; then
echo "ERROR: Required file missing: $file"
exit 1
fi
done
echo "OK: All required deployment files present"
# 4.
if [ -z "${{ secrets.QUANTENGINE_DB_PASSWORD }}" ]; then
echo "ERROR: DB password secret not configured"
exit 1
fi
echo "OK: DB credentials configured"
echo "=== ALL PRE-DEPLOYMENT CHECKS PASSED ==="
- name: Local Deploy (Green-Blue)
id: deploy
run: |
set -e
#
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
COMMIT=$(git rev-parse --short HEAD)
RUN_NUM="${{ github.run_number }}"
DEPLOY_BASE="/home/kjh2064/deployments"
ACTIVE_LINK="/home/kjh2064/quantengine_active"
TARGET_DIR="${DEPLOY_BASE}/quantengine_${TIMESTAMP}_${COMMIT}_${RUN_NUM}"
DEPLOYMENT_LOG="./deployment_${TIMESTAMP}.log"
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
send_telegram() {
local text="$1"
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${text}" \
-d "parse_mode=HTML" >/dev/null || true
}
trap 'on_error' ERR
on_error() {
echo "DEPLOYMENT FAILED" | tee -a "$DEPLOYMENT_LOG"
send_telegram "DEPLOYMENT FAILED: $COMMIT at $(date)"
exit 1
}
{
echo "=== DEPLOYMENT START: $TIMESTAMP ==="
echo "Commit: $COMMIT"
echo "Run: $RUN_NUM"
echo "Target: $TARGET_DIR"
echo ""
#
echo "[1/8] Creating deployment directories..."
mkdir -p "${DEPLOY_BASE}" || { echo "FATAL: Cannot create deploy base"; exit 1; }
mkdir -p "${TARGET_DIR}" || { echo "FATAL: Cannot create target dir"; exit 1; }
echo "OK: Directories created"
echo ""
#
echo "[2/8] Extracting build artifact..."
if ! tar -xzf quantengine.tar.gz -C "${TARGET_DIR}"; then
echo "FATAL: Failed to extract artifact"
exit 1
fi
echo "OK: Artifact extracted"
ls "${TARGET_DIR}" | head -10
echo ""
#
echo "[3/8] Normalizing deployment structure..."
if [ -d "${TARGET_DIR}/net10.0" ]; then
echo "Found net10.0 subdirectory, moving to root..."
if ! mv "${TARGET_DIR}/net10.0"/* "${TARGET_DIR}/"; then
echo "WARNING: Some files could not be moved from net10.0"
fi
if [ -d "${TARGET_DIR}/net10.0" ]; then
rmdir "${TARGET_DIR}/net10.0" 2>/dev/null || echo "Warning: Could not remove net10.0 dir"
fi
fi
echo "OK: Structure normalized"
echo ""
#
echo "[4/8] Validating deployment contents..."
if [ ! -f "${TARGET_DIR}/QuantEngine.Web.dll" ]; then
echo "FATAL: QuantEngine.Web.dll not found in deployment"
exit 1
fi
if [ ! -f "${TARGET_DIR}/appsettings.json" ]; then
echo "FATAL: appsettings.json not found"
exit 1
fi
echo "OK: All required files present"
echo ""
#
echo "[5/8] Installing environment configuration..."
mkdir -p /home/kjh2064/.config || { echo "WARNING: Cannot create config dir"; }
install -m 600 ./deploy/quantengine.env /home/kjh2064/.config/quantengine.env || { echo "WARNING: Config file install failed"; }
echo "OK: Configuration installed"
echo ""
# appsettings.Production.json
echo "[6/8] Creating production appsettings..."
mkdir -p "${TARGET_DIR}"
DB_PASSWORD="${{ secrets.QUANTENGINE_DB_PASSWORD }}"
cat > "${TARGET_DIR}/appsettings.Production.json" << EOF
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"DefaultConnection": "Host=127.0.0.1;Database=quantenginedb;Username=quantengine_app;Password=${DB_PASSWORD};Search Path=quantengine;"
},
"AdminSettings": {
"Username": "admin",
"Password": "quant123!"
}
}
EOF
chmod 600 "${TARGET_DIR}/appsettings.Production.json"
echo "OK: appsettings.Production.json created"
echo ""
} | tee "$DEPLOYMENT_LOG"
echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT
echo "commit=${COMMIT}" >> $GITHUB_OUTPUT
echo "target_dir=${TARGET_DIR}" >> $GITHUB_OUTPUT
# ()
PREV_VERSION="none"
if [ -L "${ACTIVE_LINK}" ]; then
PREV_VERSION=$(readlink -f "${ACTIVE_LINK}")
PREV_TIMESTAMP=$(basename "${PREV_VERSION}")
else
PREV_TIMESTAMP="none"
fi
echo "[7/8] Executing Green-Blue deployment..."
export DEPLOY_FROM_CI=1
chmod +x "${TARGET_DIR}/deploy_gb.sh"
if ! "${TARGET_DIR}/deploy_gb.sh" >> "$DEPLOYMENT_LOG" 2>&1; then
echo "DEPLOYMENT FAILED: Green-Blue swap error"
send_telegram "DEPLOYMENT FAILED: Green-Blue swap failed for $COMMIT"
exit 1
fi
echo "OK: Green-Blue deployment completed"
#
cat > "${TARGET_DIR}/.deployment_info" << EOF
Deployed: $(date -u +'%Y-%m-%dT%H:%M:%SZ')
Commit: ${COMMIT}
Timestamp: ${TIMESTAMP}
Run: ${RUN_NUM}
Previous: ${PREV_TIMESTAMP}
Status: DEPLOYED
EOF
echo "timestamp=${TIMESTAMP}" >> $GITHUB_OUTPUT
echo "commit=${COMMIT}" >> $GITHUB_OUTPUT
echo "target_dir=${TARGET_DIR}" >> $GITHUB_OUTPUT
echo "prev_version=${PREV_TIMESTAMP}" >> $GITHUB_OUTPUT
- name: Health Check & Verification
id: health-check
run: |
TIMESTAMP="${{ steps.deploy.outputs.timestamp }}"
COMMIT="${{ steps.deploy.outputs.commit }}"
TARGET_DIR="${{ steps.deploy.outputs.target_dir }}"
PREV_TIMESTAMP="${{ steps.deploy.outputs.prev_version }}"
DEPLOY_BASE="/home/kjh2064/deployments"
ACTIVE_LINK="/home/kjh2064/quantengine_active"
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
send_telegram() {
local text="$1"
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${text}" \
-d "parse_mode=HTML" >/dev/null || true
}
echo "=== POST-DEPLOYMENT HEALTH CHECKS ==="
# 1.
echo "[1/4] Verifying deployment directory..."
if [ ! -d "$TARGET_DIR" ]; then
echo "FATAL: Deployment directory not found: $TARGET_DIR"
exit 1
fi
if [ ! -f "${TARGET_DIR}/QuantEngine.Web.dll" ]; then
echo "FATAL: Application DLL not found in deployment"
exit 1
fi
echo "OK: Deployment directory verified"
# 2. Loopback
echo "[2/4] Performing loopback health checks..."
health_check_passed=0
for i in $(seq 1 ${{ env.HEALTH_CHECK_RETRIES }}); do
echo " Attempt $i/${{ env.HEALTH_CHECK_RETRIES }}..."
if timeout 10 curl -s -f -o /dev/null -w '%{http_code}' http://127.0.0.1:5000/ 2>/dev/null | grep -qE '^(200|302|401)$'; then
echo " OK: Service responding"
health_check_passed=1
break
fi
if [ $i -lt ${{ env.HEALTH_CHECK_RETRIES }} ]; then
sleep ${{ env.HEALTH_CHECK_DELAY }}
fi
done
if [ $health_check_passed -eq 0 ]; then
echo "FAILED: Health check did not pass after ${{ env.HEALTH_CHECK_RETRIES }} attempts"
echo "status=failed" >> $GITHUB_OUTPUT
exit 1
fi
echo "OK: Loopback health check passed"
# 3.
echo "[3/4] Verifying database connectivity..."
if timeout 10 bash -c 'cat /home/kjh2064/.config/quantengine.env | grep -q "postgresql"' 2>/dev/null; then
echo "OK: Database credentials configured"
else
echo "WARNING: Could not verify database credentials"
fi
# 4.
echo "[4/4] Checking service status..."
if systemctl is-active --quiet quantengine; then
echo "OK: Service is running"
else
echo "WARNING: Service may not be running, but health checks passed"
fi
echo "status=success" >> $GITHUB_OUTPUT
echo "=== ALL HEALTH CHECKS PASSED ==="
send_telegram "OK: QuantEngine deployed successfully (commit: ${COMMIT})"
- name: Auto-Rollback on Health Check Failure
if: failure() && steps.health-check.outcome == 'failure'
run: |
COMMIT="${{ steps.deploy.outputs.commit }}"
PREV_TIMESTAMP="${{ steps.deploy.outputs.prev_version }}"
DEPLOY_BASE="/home/kjh2064/deployments"
ACTIVE_LINK="/home/kjh2064/quantengine_active"
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
send_telegram() {
local text="$1"
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=${text}" \
-d "parse_mode=HTML" >/dev/null || true
}
echo "=== AUTOMATIC ROLLBACK INITIATED ==="
echo "Health check failed, rolling back to previous version..."
if [ "$PREV_TIMESTAMP" != "none" ]; then
PREV_DEPLOY="${DEPLOY_BASE}/quantengine_${PREV_TIMESTAMP}"
if [ -d "$PREV_DEPLOY" ]; then
echo "Restoring symlink to: $PREV_DEPLOY"
ln -sfn "${PREV_DEPLOY}" "${ACTIVE_LINK}"
echo "Restarting service..."
systemctl restart quantengine 2>&1 || echo "WARNING: Service restart may have issues"
sleep 3
echo "Rollback completed"
send_telegram "ROLLBACK: Deployment of ${COMMIT} failed, rolled back to ${PREV_TIMESTAMP}"
else
echo "ERROR: Previous deployment directory not found"
send_telegram "CRITICAL: Rollback failed - previous deployment not found"
exit 1
fi
else
echo "ERROR: No previous deployment available for rollback"
send_telegram "CRITICAL: Health check failed - no previous deployment to rollback to"
exit 1
fi
echo "=== Verifying Database Connectivity ==="
db_status=$(psql -U quantengine_app -d quantenginedb -h 127.0.0.1 -c 'SELECT 1;' 2>&1 | head -1)
if echo "$db_status" | grep -q "1"; then
echo " Database connectivity verified"
else
echo " Database connectivity check: $db_status"
fi
- name: Post-Deployment Verification
if: success()
run: |
echo "=== POST-DEPLOYMENT VERIFICATION ==="
# Public endpoints
echo "[1/3] Verifying public endpoints..."
for endpoint in "/" "/Account/Login"; do
code=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 "https://quant.taxbaik.com${endpoint}")
echo " https://quant.taxbaik.com${endpoint} -> $code"
if ! echo "$code" | grep -qE '^(200|302|401)$'; then
echo " WARNING: Unexpected response code"
fi
done
# Nginx
echo "[2/3] Verifying Nginx configuration..."
if nginx -t 2>&1 | grep -q "successful"; then
echo " OK: Nginx syntax valid"
else
echo " WARNING: Nginx validation may have issues"
fi
#
echo "[3/3] Creating deployment record..."
DEPLOYMENT_SUMMARY="deployment_summary_${{ steps.deploy.outputs.timestamp }}.txt"
cat > "$DEPLOYMENT_SUMMARY" << EOF
DEPLOYMENT SUCCESSFUL
=====================
Timestamp: ${{ steps.deploy.outputs.timestamp }}
Commit: ${{ steps.deploy.outputs.commit }}
Target: ${{ steps.deploy.outputs.target_dir }}
Previous: ${{ steps.deploy.outputs.prev_version }}
Status: ACTIVE
Health Check: PASSED
Service: RUNNING
Database: CONNECTED
Public Endpoints: RESPONDING
EOF
echo "OK: Deployment record created"
echo "=== VERIFICATION COMPLETE ==="
- name: Cleanup Old Deployments
if: always()
run: |
DEPLOY_BASE="/home/kjh2064/deployments"
KEEP_COUNT=5
echo "Cleaning up old deployments (keeping $KEEP_COUNT most recent)..."
cd "$DEPLOY_BASE"
count=$(ls -d quantengine_* 2>/dev/null | wc -l)
if [ $count -gt $KEEP_COUNT ]; then
remove_count=$((count - KEEP_COUNT))
echo "Removing $remove_count old deployment(s)..."
ls -dt quantengine_* | tail -n +$((KEEP_COUNT + 1)) | while read -r old_dir; do
echo " Removing: $old_dir"
rm -rf "$old_dir" 2>/dev/null || echo " WARNING: Could not remove $old_dir"
done
fi
echo "Cleanup complete. Current deployments:"
ls -ldt quantengine_* | head -5 | awk '{print $9, "(" $5 " bytes)"}'
- name: Notify Success
if: success()
run: |
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=SUCCESS: QuantEngine deployment complete (commit: ${{ steps.deploy.outputs.commit }})" \
-d "parse_mode=HTML" >/dev/null || true
- name: Notify Failure
if: failure()
run: |
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text=FAILURE: QuantEngine deployment failed (commit: ${{ steps.deploy.outputs.commit }})
Logs: https://gitea.taxbaik.com/kjh2064/QuantEngineByItz/actions/runs/${{ github.run_id }}" \
-d "parse_mode=HTML" >/dev/null || true
- name: Cleanup Old Deployments
run: |
DEPLOY_BASE="/home/kjh2064/deployments"
echo "Cleaning up obsolete deployments (keeping last 5)..."
cd "${DEPLOY_BASE}"
ls -dt quantengine_* | tail -n +6 | while read -r old_dir; do
echo "Removing old release: ${old_dir}"
rm -rf "${old_dir}"
done
echo "Cleanup complete"
ls -ldt quantengine_* | head -5
- name: Notify Failure
if: failure()
run: |
COMMIT=$(git rev-parse --short HEAD)
TELEGRAM_BOT_TOKEN="${{ secrets.TELEGRAM_BOT_TOKEN }}"
[ -z "$TELEGRAM_BOT_TOKEN" ] && TELEGRAM_BOT_TOKEN="${{ env.TELEGRAM_BOT_TOKEN_DEFAULT }}"
TELEGRAM_CHAT_ID="${{ secrets.TELEGRAM_CHAT_ID }}"
[ -z "$TELEGRAM_CHAT_ID" ] && TELEGRAM_CHAT_ID="${{ env.TELEGRAM_CHAT_ID_DEFAULT }}"
curl -fsS -X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d "chat_id=${TELEGRAM_CHAT_ID}" \
--data-urlencode "text= QuantEngine \n: ${COMMIT}\n: https://gitea.taxbaik.com/kjh2064/QuantEngineByItz/actions/runs/${{ github.run_id }}" \
-d "parse_mode=HTML" || true
+17
View File
@@ -0,0 +1,17 @@
name: Daily T+20 Outcome Ledger Build
on:
schedule:
- cron: '0 8 * * 1-5' # KST 17:00 (Mon-Fri)
workflow_dispatch:
jobs:
build-t20-ledger:
runs-on: self-hosted
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Build T+20 Outcome Ledger
run: |
python3 tools/build_operational_t20_outcome_ledger_v1.py
+6
View File
@@ -28,6 +28,12 @@ src/dotnet/QuantEngine.Web/wwwroot/_framework/
# 런타임 감사 로그 (append-only, 매 DAG 실행마다 증가)
runtime/lineage_events.jsonl
# .NET 런타임 로그 (Serilog 등, 실행마다 재생성)
**/logs/*.log
# Playwright 테스트 산출물 (스크린샷/트레이스, 실행마다 재생성)
test-results/
# OS / 에디터
...
.DS_Store
+1
View File
@@ -104,6 +104,7 @@
- `docs/CLOUD_SERVER_SETUP.md`: 클라우드 서버(hz-prod-01, 178.104.200.7) 설정 하네스 가이드. 시놀로지 → 클라우드 마이그레이션 매핑 포함.
- `docs/ENTERPRISE_CRUD_DESIGN_SPECIFICATION.md`: OMS·WMS·ERP CRUD 화면 및 입력 컴포넌트 상용화 지침 명세 (엔터프라이즈 컴포넌트/트랜잭션 헌법).
- `docs/ROADMAP_ENTERPRISE_TEMPLATES_WBS.md`: OMS·WMS·ERP 공통 CRUD 화면 템플릿 상용화 WBS & 로드맵.
- `docs/QUANTENGINE_MASTERPIECE_ROADMAP.md`: 프로젝트 냉정 분석 & 마스터피스 3-Stream 로드맵 (Alpha Validation / Platform Consolidation / Professional Operation).
- `src/frontend/src/types/enterpriseTemplateContracts.ts`: OMS·WMS·ERP 11대 표준 템플릿 TypeScript 공통 계약.
- `docs/GITEA_SECRETS_SETUP.md`: Gitea secrets setup and verification guide.
- `docs/GATHERTRADINGDATA_XLSX_OPERATING_RUNBOOK.md`: `GatherTradingData.xlsx` 보조 자산 런북.
+187 -1614
View File
File diff suppressed because it is too large Load Diff
+13 -7
View File
@@ -28,15 +28,12 @@ CI 스케줄러는 `GatherTradingData.json`을 seed snapshot으로 사용하고,
```powershell
npm install
node core_satellite_collector.js
```
OpenDART 공시까지 확인하려면:
```powershell
$env:DART_API_KEY="발급받은키"
node core_satellite_collector.js
```
**⚠️ 2026-07-30 정정**: 이 섹션은 예전에 `node core_satellite_collector.js`를 실행 커맨드로
안내했지만, 그 파일은 git 히스토리에 한 번도 존재한 적이 없다 (`package.json``ops:dev`
스크립트도 같은 phantom 파일을 가리키고 있어 같은 날 함께 정리함). 실제 수집기는
Python으로 구현되어 있다 — 아래 커맨드를 사용할 것.
SQLite 기반 데이터 수집을 실행하려면:
@@ -44,6 +41,15 @@ SQLite 기반 데이터 수집을 실행하려면:
$env:KIS_APP_Key="실제계좌키"
$env:KIS_APP_Secret="실제계좌시크릿"
python tools/run_kis_data_collection_v1.py --input-json GatherTradingData.json --sqlite-db src/quant_engine/kis_data_collection.db --output-json Temp/kis_data_collection_v1.json --kis-account real
# 또는: npm run ops:data-collect
```
OpenDART 공시까지 확인하려면 (2026-07-30 기준 `_dart_fundamentals()`는 아직 스텁 — 실제
호출은 미구현, `tools/ingest_fundamental_raw.py` 참고):
```powershell
$env:OPENDART_OPENAPI_KEY="발급받은키"
python tools/ingest_fundamental_raw.py
```
### Snapshot admin web UI
+53
View File
@@ -0,0 +1,53 @@
# QuantEngine API Reference
Full API endpoint tables, extracted from CLAUDE.md (2026-07-30) to keep the main file within
the character budget.
## Workspace & History (Phase 1)
All endpoints prefixed with `/api/`:
| Route | Purpose |
|-------|---------|
| `GET /state` | Full UI state snapshot |
| `GET /tables` | Browsable tables list |
| `GET /table-rows` | Paginated rows |
| `POST /settings/save` | Save settings |
| `POST /account-snapshot/save` | Save snapshots |
| `POST /bootstrap` | Seed DB from JSON |
| `POST /account-snapshot/import-tsv` | Import TSV |
| `POST /autofix` | Auto-correct data |
## Collection Pipeline (Phase 2)
| Route | Purpose |
|-------|---------|
| `GET /collection/state` | Dashboard summary (runs, snapshots, errors) |
| `GET /collection/runs` | Recent collection runs (paginated) |
| `GET /collection/runs/{runId}/snapshots` | Snapshots from a run |
| `GET /collection/runs/{runId}/errors` | Errors from a run |
| `GET /collection/latest/{ticker}` | Latest snapshots for ticker |
| `POST /collection/run` | Start new collection run (async) |
## Collection Run Status Values
| Status | Meaning | UI Badge | Transitions |
|--------|---------|----------|------------|
| `running` | Collection in progress | <span class="badge bg-warning">진행 중</span> | → completed or failed |
| `completed` | Collection finished (may have errors) | <span class="badge bg-success">완료</span> | (final) |
| `failed` | Collection crashed/aborted | <span class="badge bg-danger">실패</span> | (final) |
| `pending` | Queued, not yet started | <span class="badge bg-secondary">대기 중</span> | → running |
## Collection Run Success Criteria
**Success** is defined as:
- Status = `completed` (not `failed`)
- `TotalSnapshots > 0` (at least one snapshot captured)
- `TotalErrors == 0` OR `TotalErrors < TotalSnapshots * 0.1` (error rate < 10%)
**Partial Success** (warning state):
- Status = `completed`
- `TotalSnapshots > 0` (some data captured)
- `TotalErrors > 0` (has errors, but not total loss)
**Failure**:
- Status = `failed` OR
- Status = `completed` + `TotalSnapshots == 0` (no data captured)
UI: `Pages/Admin/Collection/Index.cshtml` — status 값에 따라 배지 색상 결정, 향후 TotalSnapshots/TotalErrors로 상세 상태 표시
+107
View File
@@ -0,0 +1,107 @@
# QuantEngine CI/CD Pipeline Structure
Full Gitea Actions workflow structure, extracted from CLAUDE.md (2026-07-30) to keep the main
file within the character budget.
## Workflow Architecture Refactoring (2026-07-24)
**2026-07-24 refactoring**: Single-job ci.yml (30+ steps, ~40min runtime) → **9-job parallel pipeline** (~15-20min runtime).
## CI Pipeline Jobs (ci.yml)
| Job | Dependencies | Purpose | Parallelizable |
|-----|--------------|---------|---|
| **core** | — | CRITICAL: .NET tests, API trading gate, KIS creds, DB migrations | ✗ (blocks others) |
| **wbs-audit** | core | WBS validation, platform migration, coverage audits | ✓ |
| **dotnet-contracts** | core | .NET parity, provenance, scheduler, normalization contracts | ✓ |
| **ui-storage** | — | Admin UI, storage backend, integration tests | ✓ |
| **database-schema** | — | DB pipeline, PostgreSQL schema, history contracts | ✓ |
| **calibration-pipeline** | core | Calibration priority, change ledger, qualitative sell strategy | ✓ |
| **operational-reporting** | calibration | Decision packet, operational report, performance metrics | ✗ (depends on calibration) |
| **security-validation** | — | Secrets contract, workflow validation | ✓ |
| **workflow-lint** | — | CI workflow structure, secrets contract | ✓ |
| **notify-results** | ALL | PR notification with job status summary | — |
**Dependency Graph**:
```
core ─┬─> wbs-audit ─────────────────────┐
├─> dotnet-contracts ─────────────┤
└─> calibration-pipeline ────────┤
└─> operational-reporting ─┤
└─> notify-results
ui-storage ────────────────────────────────┘
database-schema ──────────────────────────┘
security-validation ───────────────────────┘
workflow-lint ─────────────────────────────┘
```
## Other Workflow Files
| File | Trigger | Purpose | Status |
|------|---------|---------|--------|
| **kis_data_collection.yml** | cron (00:30 KST M-F) + dispatch | Validate KIS credentials & PostgreSQL pipeline | ✓ 2026-07-24 |
| **qualitative_sell_strategy.yml** | cron (00:15 KST M-F) + push + dispatch | Validate sell strategy pipeline & store | ✓ 2026-07-24 |
| **ci_lint.yml** | push (.gitea/workflows/) + dispatch | Lint all workflow files, validate job dependencies, secrets contract | ✓ 2026-07-24 |
| **snapshot_admin.yml** | push (snapshot_admin_*) + dispatch | Validate snapshot admin workflow & UI (2 jobs) | ✓ 2026-07-24 |
| **ci-frontend.yml** | push (main/master/feature/**) + PR | 8-step `src/frontend/` pipeline: install, typecheck, import-boundary lint, unit test, enterprise CRUD contract parity, Vite build, Playwright E2E, npm audit | ✓ (undocumented until 2026-07-30) |
| **t20_ledger.yml** | cron (17:00 KST M-F) + dispatch | Build `tools/build_operational_t20_outcome_ledger_v1.py` daily T+20 outcome ledger | ✓ (undocumented until 2026-07-30) |
| **prepare-release.yml** | workflow_run (ci.yml success) + dispatch | Build, tag, create Gitea Release with artifact + checksums | — |
| **deploy-prod.yml** | dispatch | Deploy release, run health checks, report status (3 jobs) | — |
**Note (2026-07-30)**: An earlier version of this table claimed `ci_lint.yml` had been renamed to
`workflow_lint.yml`. That rename was never actually carried out — the file on disk is still
`ci_lint.yml`. Corrected here after direct verification against `.gitea/workflows/`.
## Performance Improvements (2026-07-24)
**ci.yml refactoring results**:
- **Before**: 1 job, 30+ sequential steps, ~40min runtime
- **After**: 9 jobs, 7 in parallel, ~15-20min total runtime
- **Speedup**: ~2-2.5x faster CI feedback (core branch blocks only downstream, others parallel)
- **Fault isolation**: Single validation failure no longer blocks unrelated checks
**Key changes**:
1. **Setup consolidation**: Database migrations, Python, .NET setup in `core` job only
2. **Parallel validation groups**: 7 jobs run independently from core (ui-storage, database-schema, security-validation, workflow-lint, etc.)
3. **Dependency clarity**: `needs:` explicitly defines blocking relationships
4. **Error reporting**: `notify-results` summarizes all 9 job statuses in PR comment
## Workflow Maintenance Checklist
When modifying workflows (.gitea/workflows/*.yml):
1. ✅ Update `ci_lint.yml` if adding new triggers or job dependencies
2. ✅ Test locally with `python3 tools/validate_gitea_ci_workflow_lint_v1.py`
3. ✅ Verify all `needs:` references point to existing jobs
4. ✅ Document new jobs in this section above
5. ✅ Validate YAML syntax: `python3 -m yaml < .gitea/workflows/new.yml`
6. ✅ Ensure no hardcoded secrets in workflow files (env vars only)
## Troubleshooting Workflows
**Symptom**: CI job timeout
- **Check**: Does your job need PostgreSQL? Only `core` provides it; others must be independent.
- **Fix**: Add `services: postgres:` block or restructure to parallel-safe job.
**Symptom**: Cascading failure (multiple jobs fail)
- **Check**: Does your job have missing dependencies? Review `needs:` and dependency graph above.
- **Fix**: Add explicit `needs: [job_name]` if job depends on another's output.
**Symptom**: "job not found" error in notify-results
- **Check**: Job name typo in `notify-results.needs` list.
- **Fix**: Match job name exactly (case-sensitive).
## Workflow Trigger Schedule (2026-07-24)
| Time (KST) | Workflow | Trigger | Purpose |
|-----------|----------|---------|---------|
| 00:15 | qualitative_sell_strategy.yml | cron (M-F) | Validate sell strategy before daily operations |
| 00:30 | kis_data_collection.yml | cron (M-F) | Validate KIS API & DB pipeline before data collection |
| Push | ci.yml | on:push (main) | Validate code on every push to main |
| PR | ci.yml | on:pull_request | Gate PR merges with full validation suite |
| Manual | prepare-release.yml | workflow_dispatch | Create release tag & artifact |
| Manual | deploy-prod.yml | workflow_dispatch | Deploy release to production |
**Dependencies**:
- Release creation (prepare-release.yml) is gated by ci.yml success (workflow_run trigger)
- Deployment (deploy-prod.yml) is manual — only after release artifact exists
+337
View File
@@ -0,0 +1,337 @@
# QuantEngine Deployment Runbook
Full deployment procedure, extracted from CLAUDE.md (2026-07-30) to keep the main file within
the character budget. CLAUDE.md keeps the CRITICAL rules (CI/CD-only mandate, DB secret
management); this file has the complete step-by-step runbook.
**Production Server**: Hetzner Cloud `178.104.200.7` (kjh2064@178.104.200.7)
Projects on server:
1. **TaxBaik** (홈페이지) — Nginx location `/taxbaik`
2. **QuantEngine** (데이터 수집/분석) — Nginx location `/quantengine`
## ⚠️ CRITICAL: CI/CD-Only Deployment Mandate
**Rule**: ALL production deployments MUST go through Gitea Actions CI/CD. Manual SSH deployments are **FORBIDDEN**.
**Why**:
- Automatic validation (build, health checks, version verification)
- Audit trail (all deployments logged in Gitea Actions)
- Consistent process (no manual errors)
- Rollback safety (deployment history retained)
- Release traceability (version control via git tags)
## ⚠️ CRITICAL: DB Secret Management (Incident 2026-07-12)
**Incident**: `quant.taxbaik.com/login``28P01 password authentication failed`로 장애 발생.
원인: `appsettings.Production.json`에 하드코딩되어 배포된 DB 비밀번호가, 실제 DB 비밀번호가
로테이션된 이후에도 계속 옛날 값(심지어 이전 세션에서 검증 없이 넣은 placeholder였던 적도 있음)
그대로 배포되고 있었음.
**Rule**: **DB 접속 문자열(`ConnectionStrings`)은 절대 `appsettings.Production.json`이나
워크플로우 파일에 하드코딩하지 않는다.** `prepare-release.yml`이 생성하는
`appsettings.Production.json`에는 `Logging` 설정만 있고 `ConnectionStrings`는 없다 —
이는 의도된 설계다 (Gitea Release는 누구나 다운로드 가능한 아티팩트이므로 시크릿을
담으면 안 됨).
**실제 DB 비밀번호의 출처**: 프로덕션 서버의 `/home/kjh2064/.config/quantengine.env`
파일 (`ConnectionStrings__DefaultConnection=...` 형식) 하나뿐이며,
`quantengine.service.d/env.conf` drop-in의 `EnvironmentFile=` 지시자로 systemd가
이 값을 환경변수로 주입한다. ASP.NET Core 설정 우선순위상 **환경변수가
`appsettings.Production.json`을 오버라이드**하므로, 배포되는 아티팩트 자체에는
DB 정보가 없어도 서비스는 정상 동작한다.
**DB 비밀번호가 바뀌면** (로테이션 등): `/home/kjh2064/.config/quantengine.env` 파일만
갱신하고 `sudo systemctl restart quantengine`. 워크플로우 파일이나 Gitea Secrets는
건드릴 필요 없음 (배포 파이프라인은 DB 비밀번호를 모른 채로 동작해야 정상).
**배포 전 체크리스트에 추가**:
- ✅ 새 릴리즈 배포 후 반드시 `/Account/Login` 실제 HTTP 응답 + `journalctl -u quantengine`에서
`28P01`/`password authentication failed` 부재 확인 (단순 프로세스 `active` 상태만으로는
DB 연결 실패를 못 잡음 — ASP.NET Core는 DB 없이도 기동은 되고 로그인 요청 시점에야 실패함)
-`.config/quantengine.env`의 존재와 `quantengine.service.d/env.conf`
`EnvironmentFile=` 배선이 서버에 유지되고 있는지 (systemd unit 자체를 재생성/덮어쓰는
배포 방식으로 전환할 경우 이 drop-in이 날아가지 않는지 확인 필요)
## Production Deployment Strategy (Release-Based)
**Architecture**: Two-Workflow System (Release Creation → Deployment)
### Workflow 1: prepare-release.yml (Release Creation)
**Purpose**: Create a release with built artifact
**Trigger**: Manual (`workflow_dispatch`)
```bash
# Visit Gitea Actions and select prepare-release.yml
# Input version: v0.1.20260711 (or any semantic version)
```
**What it does**:
1. ✓ Build (restore, build, publish)
2. ✓ Generate `appsettings.Production.json`
3. ✓ Package artifact: `.tar.gz`
4. ✓ Create git tag: `v0.1.20260711`
5. ✓ Create Gitea Release with artifact attached
6. ✓ Notify: Release ready for deployment
**Output**: Gitea Release with downloadable artifact
### Workflow 2: deploy-prod.yml (Deployment)
**Purpose**: Deploy a release to production
**Trigger**: Manual (`workflow_dispatch`)
```bash
# Visit Gitea Actions and select deploy-prod.yml
# Input release: v0.1.20260711 (optional — uses latest if empty)
```
**What it does**:
1. ✓ Fetch Release (from Gitea Releases)
2. ✓ Download artifact
3. ✓ Verify SSH credentials
4. ✓ Upload to production server
5. ✓ Extract and symlink
6. ✓ Restart service
7. ✓ 6-point health checks
8. ✓ Report deployment status
**Deployment Pipeline (5 Stages)**:
| Stage | Purpose | Timeout |
|-------|---------|---------|
| 1. Fetch Release | Query Gitea Releases, download artifact | 10min |
| 2. Pre-Check | Verify SSH keys, secrets, release | 5min |
| 3. Deploy | Upload, extract, symlink, restart service | 30min |
| 4. Health Check | 6-point verification (HTTP, CSS, login, service, release, DB auth) | 10min |
| 5. Report | Final deployment status | Auto |
**Health Checks (Automatic)**:
- ✓ HTTP 200 on `/Account/Login`
- ✓ Login page content verification
- ✓ CSS file loads (`/css/admin.css`)
- ✓ Service status (systemctl active)
- ✓ Release verification (deployed release tag matches)
-**DB authentication check** (`journalctl`에서 `28P01`/`password authentication failed`
부재 확인 — GET `/Account/Login`은 DB가 끊겨도 200을 반환하므로 이 체크가 없으면
DB 장애를 배포 파이프라인이 놓친다. 2026-07-12 사고 이후 추가됨)
**Complete Deployment Flow**:
```
1. Code committed to main branch
2. Create release: prepare-release.yml workflow_dispatch (manual)
→ Builds code
→ Creates Gitea Release with artifact
→ Tags repository
3. Deploy release: deploy-prod.yml workflow_dispatch (manual)
→ Selects release version
→ Downloads artifact from Gitea Release
→ Deploys to production server
→ Runs health checks
→ Reports status
```
## Pre-Deployment Checklist
**Before creating a release**, verify:
1. ✅ Local build: `dotnet build src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj -c Release` (0 errors, 0 warnings)
2. ✅ E2E tests pass: `npx playwright test`
3. ✅ Admin pages verified (200 status, no 500 errors)
4. ✅ All changes committed and pushed to main branch
5. ✅ No uncommitted changes: `git status`
## Release & Deployment Workflow
**Step 1: Create Release (prepare-release.yml)**
```bash
# Visit Gitea Actions
# https://gitea.taxbaik.com/kjh2064/QuantEngineByItz/actions
# Run prepare-release.yml workflow
# Input: version = v0.1.20260711
# Workflow will:
# - Build and publish
# - Package artifact
# - Create git tag
# - Create Gitea Release
# - Attach artifact
```
**Step 2: Deploy Release (deploy-prod.yml)**
```bash
# Visit Gitea Actions (same page)
# Run deploy-prod.yml workflow
# Input: release = v0.1.20260711 (leave empty for latest)
# Workflow will:
# - Download artifact from release
# - Deploy to production server
# - Run health checks
# - Report status
```
## SSH Key Configuration (Required)
**Setup (One-time)**:
1. Generate ED25519 key locally (or reuse existing):
```bash
ssh-keygen -t ed25519 -f ~/.ssh/quantengine_deploy -C "QuantEngine CI/CD"
```
2. Add public key to production server:
```bash
ssh-copy-id -i ~/.ssh/quantengine_deploy.pub kjh2064@178.104.200.7
```
3. Get private key in base64 format:
```bash
# macOS/Linux
base64 -w 0 ~/.ssh/quantengine_deploy > /tmp/key_b64.txt
cat /tmp/key_b64.txt | pbcopy
# Or Windows PowerShell
$key = Get-Content ~/.ssh/quantengine_deploy -Raw
[Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($key)) | Set-Clipboard
```
4. Configure in Gitea:
- URL: https://gitea.taxbaik.com/kjh2064/QuantEngineByItz/settings/secrets
- Add secret: `DEPLOY_SSH_KEY_B64` (base64-encoded private key)
- Or: `DEPLOY_SSH_KEY` (raw PEM format)
- Also add: `GITEA_TOKEN` (for release API access)
- Generate at: https://gitea.taxbaik.com/user/settings/applications
- Required permissions: `repo` + `read:actions`
## Deployment Monitoring
**During Deployment**:
- Watch live in Gitea Actions UI
- Jobs complete in order: Build → Pre-Check → Deploy → Health Check → Report
**After Deployment**:
```bash
# SSH into server
ssh kjh2064@178.104.200.7
# Check active deployment
readlink ~/quantengine_active
# View service status
systemctl status quantengine
# Tail live logs
journalctl -u quantengine -f
# Health check
curl -I http://127.0.0.1:5000/Account/Login
```
## Automatic Rollback (if health check fails)
If health check fails, deployment stops automatically:
1. Service restart may fail
2. Symlink update reverts to previous deployment
3. Gitea Actions marks deployment as FAILED
4. Logs include failure details
Manual rollback (if needed):
```bash
# List deployments
ls -lht ~/deployments/quantengine_*
# Revert symlink to previous version
ln -sfn /home/kjh2064/deployments/quantengine_YYYYMMDD_HHMMSS_COMMIT ~/quantengine_active
# Restart service
sudo systemctl restart quantengine
# Verify
curl http://127.0.0.1:5000/Account/Login
```
## Troubleshooting Deployment Failures
**Issue**: Build fails
- Check: `dotnet build` locally first
- Ensure: No compilation errors, 0 warnings
**Issue**: Health check timeout
- Check: Service logs: `journalctl -u quantengine -n 50`
- Check: Port 5000 listening: `ss -tlnp | grep 5000`
- Check: DB connectivity in appsettings.Production.json
**Issue**: SSH key error
- Verify: `DEPLOY_SSH_KEY_B64` or `DEPLOY_SSH_KEY` in Gitea Secrets
- Check: Public key added to `~/.ssh/authorized_keys` on server
- Test: `ssh -i ~/.ssh/key_file kjh2064@178.104.200.7 echo OK`
## Git Repository
**Gitea Server** (동일 호스트):
- **HTTP**: `https://gitea.taxbaik.com/kjh2064/QuantEngineByItz.git`
- **SSH**: `ssh://git@gitea.taxbaik.com:2222/kjh2064/QuantEngineByItz.git`
## Active Gitea Workflows (summary)
1. **prepare-release.yml** — Release creation (workflow_dispatch only)
- Build → Publish → Package → Tag → Gitea Release
- Does NOT write ConnectionStrings into the artifact (see DB Secret Management above) —
only `Logging` config ships in `appsettings.Production.json`
2. **deploy-prod.yml** — Production deployment (workflow_dispatch only, takes a release tag)
- 5 stages: Fetch Release → Pre-Check → Deploy → Health Check → Report
- 6-point health checks (HTTP, login page, CSS, service, release, DB auth)
- SSH-based deployment with artifact validation
3. **ci.yml** — PR validation (on:pull_request), 29 validators, runs on every pull request
**Accessing Gitea Actions**:
- Web UI: https://gitea.taxbaik.com/kjh2064/QuantEngineByItz/actions
- Runs API: https://gitea.taxbaik.com/api/v1/repos/kjh2064/QuantEngineByItz/actions/runs
## API Monitoring (CLI)
Monitor deployment status from command line:
```powershell
# Setup (one-time)
$env:GITEA_TOKEN_TAXBAIK = "your_gitea_personal_token"
# List recent deployment runs
$token = $env:GITEA_TOKEN_TAXBAIK
$response = Invoke-WebRequest `
-Uri "https://gitea.taxbaik.com/api/v1/repos/kjh2064/QuantEngineByItz/actions/runs?limit=5" `
-Headers @{ "Authorization" = "token $token" }
($response.Content | ConvertFrom-Json).workflow_runs | ForEach-Object {
Write-Host "Run #$($_.id): $($_.display_title) [$($_.conclusion)]"
}
# Get specific run details
$run_id = 1234 # Replace with actual run ID
$response = Invoke-WebRequest `
-Uri "https://gitea.taxbaik.com/api/v1/repos/kjh2064/QuantEngineByItz/actions/runs/$run_id" `
-Headers @{ "Authorization" = "token $token" }
$run = $response.Content | ConvertFrom-Json
Write-Host "Commit: $($run.head_sha)"
Write-Host "Status: $($run.status) / $($run.conclusion)"
```
See `docs/GITEA_ACTIONS_API_GUIDE.md` for the complete API reference.
## Deployment Secrets Configuration
**Required Secrets** (Gitea Repository Settings → Secrets):
| Secret | Type | Purpose |
|--------|------|---------|
| `DEPLOY_SSH_KEY_B64` | Base64 (recommended) | ED25519 private key for SSH |
| `DEPLOY_SSH_KEY` | PEM (alternative) | Raw private key format |
| `DEPLOY_HOST` | Text | Production server IP (178.104.200.7) |
| `DEPLOY_USER` | Text | SSH username (kjh2064) |
**How to add secrets**:
1. Go to: https://gitea.taxbaik.com/kjh2064/QuantEngineByItz/settings/secrets
2. Click "Add Secret"
3. Name: `DEPLOY_SSH_KEY_B64`
4. Value: `base64 -w 0 ~/.ssh/deploy_key | pbcopy` (macOS) or `certutil -encode deploy_key deploy_key.b64` (Windows)
5. Save
+137
View File
@@ -0,0 +1,137 @@
# QuantEngine Development Workflows & Common Scenarios
Full day-to-day workflow walkthroughs, extracted from CLAUDE.md (2026-07-30) to keep the main
file within the character budget.
## Scenario 1: Day-to-Day Development (Code Change)
1. **Make code changes** (C# Razor Pages / .NET API / Python tools)
2. **Local validation**:
```powershell
dotnet build src/dotnet/QuantEngine.Web/QuantEngine.Web.csproj -c Release
dotnet test src/dotnet/QuantEngine.Core.Tests -c Release
```
3. **Test admin pages locally** (with SSH tunnel):
```powershell
ssh -L 127.0.0.1:5432:localhost:5432 kjh2064@178.104.200.7 -N &
dotnet watch run --project QuantEngine.Web
# Verify: /Admin/Dashboard, /Admin/Users, /Admin/Collection, etc. all return 200
```
4. **Commit & push**: Changes automatically trigger ci.yml
- Core validators run first (blocking others)
- Parallel validators (contracts, UI, DB, calibration) run independently
- notify-results summarizes all 9 jobs in PR comment
- Expected CI time: ~15-20min (was ~40min before 2026-07-24 refactor)
## Scenario 2: Data Collection Setup (KIS API Validation)
1. **Obtain KIS credentials** (real or mock account)
2. **Validate with mock account**:
```powershell
$env:KIS_APP_Key_TEST="<test_key>"
$env:KIS_APP_Secret_TEST="<test_secret>"
python tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930 --dry-run
```
3. **Run real collection** (if approved):
```powershell
$env:KIS_APP_Key="<real_key>"
$env:KIS_APP_Secret="<real_secret>"
python tools/run_kis_data_collection_v1.py --input-json GatherTradingData.json --sqlite-db src/quant_engine/kis_data_collection.db --output-json Temp/kis_data_collection_v1.json --kis-account real
```
4. **Verify database**:
```sql
SELECT COUNT(*) FROM kis_collection_runs;
SELECT COUNT(*) FROM kis_collection_snapshots;
```
## Scenario 3: Admin Data Editing (Snapshot Admin Web UI)
1. **Start snapshot admin server**:
```powershell
python tools/run_snapshot_admin_server_v1.py --host 127.0.0.1 --port 8787 --db src/quant_engine/snapshot_admin.db --seed GatherTradingData.json
```
2. **Access web UI**: http://127.0.0.1:8787
3. **Edit settings / account_snapshot** in browser (like Excel)
4. **Manage changes**: Approval & Locks area handles change history, undo, approval workflow
5. **Export for CI**: `/api/export` → JSON or "Export approval packet" button
## Scenario 4: Release & Deployment (Multi-Stage)
**Stage 1: Local validation**
```powershell
npm run ops:validate # Warn-only (allow some issues)
npm run full-gate # Strict (all gates PASS)
```
**Stage 2: Create release** (manual via Gitea Actions)
```
→ Visit https://gitea.taxbaik.com/kjh2064/QuantEngineByItz/actions
→ Run "prepare-release.yml" workflow_dispatch
- Builds and publishes .NET
- Creates git tag (e.g., quant_20260724.0.abc1234)
- Generates Gitea Release with artifact + checksums
- Packages as .tar.gz
```
**Stage 3: Deploy** (manual, only after release exists)
```
→ Run "deploy-prod.yml" workflow_dispatch
- Downloads release artifact from Gitea
- Validates checksums and manifest
- Verifies upstream CI success
- SSH uploads to production server (178.104.200.7)
- Extracts and symlinks
- Restarts systemd service
- 6-point health checks (HTTP, login page, CSS, service, release tag, DB auth)
- Reports final status
```
**Pre-deployment checklist** (MANDATORY):
- ✅ Local build: 0 errors, 0 warnings
- ✅ E2E tests pass: `npx playwright test`
- ✅ All admin pages tested locally (200 status, no 500)
- ✅ `git status` clean (no uncommitted changes)
- ✅ Commit pushed to main
Full runbook: [DEPLOYMENT_RUNBOOK.md](DEPLOYMENT_RUNBOOK.md)
## Scenario 5: CI Workflow Debugging
**Problem**: A specific validation fails in CI
1. Identify failing job from PR comment (notify-results output)
2. Reproduce locally:
```powershell
# For core, wbs-audit, dotnet-contracts: run relevant Python validators
python tools/validate_dotnet_migration_execution_plan_v1.py
python tools/validate_dotnet_parity_contract_v1.py
# etc.
```
3. Fix and re-push (triggers ci.yml again)
4. Monitor in Gitea Actions dashboard
**Problem**: Workflow syntax error
1. Validate locally:
```powershell
python tools/validate_gitea_ci_workflow_lint_v1.py --workflow .gitea/workflows/ci.yml
```
2. Fix YAML and test again
## Scenario 6: Database Schema Changes
1. **Create migration**: `src/dotnet/QuantEngine.Infrastructure/Migrations/V003.sql`
2. **Update DBML**: `docs/db/quantengine.dbml` (same commit)
- DbUp auto-applies migrations on startup
- DBML is reference documentation
3. **Test locally** (with SSH tunnel): Migrations must apply cleanly
4. **Commit both** (SQL + DBML) together
5. **CI validates**: ci.yml applies migrations to test PostgreSQL service
## When Things Break
| Issue | Root Cause | Fix |
|-------|-----------|-----|
| Admin page returns 500 | Likely unhandled DB exception or auth issue | Check journalctl, verify ConnectionStrings in production env |
| KIS API fails with "not found" | Ticker doesn't exist in KIS | Use fallback (Naver → Yahoo → OpenDART) |
| Snapshot admin won't load | SQLite DB corrupted or missing | Delete and re-seed from GatherTradingData.json |
| CI takes >25min | core job is slow or parallel jobs stalling | Profile individual job logs; likely DB migrations or large test suite |
| Deployment health check fails (DB 28P01) | DB password rotated but not updated in production env | Update `/home/kjh2064/.config/quantengine.env` on server only (not in repo) |
+101
View File
@@ -0,0 +1,101 @@
# QuantEngine Migration Status (Historical Log)
Detailed phase-by-phase migration history, extracted from CLAUDE.md (2026-07-30) to keep the
main file within the character budget. CLAUDE.md keeps a short summary; this file is the
full historical record.
## Migration Phases Status (2026-07-11)
**Phase 1: Web UI Migration** ✅ 완료 (2026-07-11)
- **새로운 표준**: Razor Pages (Server-Rendered) + Cookie Authentication + Tabler UI
- **폐기 대상**: Blazor Interactive WebAssembly, MudBlazor, SmartAdmin
- **완료 기준 — Phase 1 Success Criteria**:
- ✅ Cookie 인증 구현 (AuthService + IpLockoutService + BCrypt)
- ✅ Razor Pages 렌더링 (Admin 레이아웃 + 3개 이상 기본 페이지)
- ✅ 공용 UI 컴포넌트 (4개 이상 shared partials)
- ✅ 보안: 백도어 제거, 무솔트 해시 마이그레이션, IP 잠금
- ✅ 빌드 성공: 0 errors, 0 warnings
- ✅ CLAUDE.md 업데이트 (UI 기준 + 인증 정책)
- **✅ 모든 기준 충족됨** (2026-07-11)
- **구현 완료**:
- ✅ Cookie 기반 인증 (AuthService + IpLockoutService)
- ✅ Razor Pages CRUD 레이아웃 (_AdminLayout.cshtml, shared partials)
- ✅ Admin 페이지: Dashboard, Collection, Users (기본 구조)
- ✅ 공용 UI 컴포넌트: _ValidationSummary, _Pagination, _StatusBadge, _EmptyState
- ✅ 보안 개선: BCrypt 해싱, IP 잠금, 하드코딩된 백도어 제거
- ✅ 빌드: 0 errors, 0 warnings (Newtonsoft.Json 보안 경고 제외)
- ✅ CLAUDE.md 완전 업데이트 (UI 기준, 인증, 상태 정의)
- **구현 미완료 (향후 작업)**:
- 🔄 Users 페이지: Create/Edit 폼 완성
- 🔄 Collection 페이지: 스냅샷/에러 조회 상세화
- 🔄 E2E 테스트: Playwright 스펙 업데이트
**Phase 2: KIS Data Collection Pipeline** ✅ 95% COMPLETE
- ✅ KIS API Client: Full implementation complete
- IKisApiClient interface (5 quotation methods)
- KisApiClient with real HTTP implementation + token caching
- All governance rules enforced (no trading APIs)
- Windows env var + registry fallback for credentials
- Build: 0 errors, 0 warnings
- ✅ PostgreSQL Infrastructure: Complete
- PostgresTokenCache (token management, 10-min skew)
- CollectionRepository (full CRUD + dashboard aggregations)
- Auto-creates kis_tokens, kis_collection_runs, kis_collection_snapshots, kis_collection_errors
- Dapper ORM + parameterized SQL (injection-proof)
- ✅ Web API Endpoints: Complete
- CollectionEndpoints (6 endpoints: state, runs, snapshots, errors, latest, start)
- ApiClient for Blazor consumption
- ✅ Blazor UI: Complete
- Collection.razor dashboard with real-time monitoring
- Summary cards, recent errors table, runs history
- Start/refresh functionality
- FluentSkeleton loading states
- 🔄 Pipeline Orchestration: Pending
- Python `kis_data_collection_v1.py` → .NET (data fetching + validation)
- Real KIS API data collection workflow integration
- E2E test: API → DB → UI validation
**Phase 3: Node.js→.NET CLI Tools** 📋 PLANNED
- Makefile created (npm → make mappings)
- np operations documented
**Phase 4: CI/CD Pipeline Hardening** ✅ 80% COMPLETE (2026-07-11)
- ✅ deploy-prod.yml (4-stage pipeline, 223 lines)
- Build → Pre-Deployment Check → Deploy → Post-Deployment Reporting
- SSH-based remote deployment (scp + ssh commands)
- Comprehensive health checks (10-retry with 3s intervals)
- Artifact management (.tar.gz)
- ✅ Workflow consolidation (2 active files)
- ci.yml: PR validation only (maintains 29 validators)
- deploy-prod.yml: Production deployment
- Deleted: merge-to-main.yml (non-functional), fast-validation.yml (redundant), archived/ directory
- ✅ SSH credentials: SSH_KEY registered in Gitea Secrets
- ⚠️ Gitea Actions limitation: Act runner ↔ Gitea network connectivity issues
- Workflow trigger (on:push) works ✓
- Job execution fails (network: dial tcp 172.18.0.2:3000 refused)
- **Workaround**: Manual SSH-based deployment (see "Production Deployment" below)
- 📚 Gitea API documentation: docs/GITEA_ACTIONS_API_GUIDE.md
**Phase 5: Admin UI & Deployment Optimization** ✅ COMPLETE (2026-07-11)
- ✅ Admin UI redesign (Tabler framework)
- Dashboard: stat cards, quick actions, system info
- Responsive sidebar navigation
- Professional layout (dark sidebar #2c3e50, white content)
- ✅ Build output: 0 errors, 0 warnings
- ✅ E2E tests: 8/8 passing (Playwright)
- ✅ Production deployment: Active since 2026-07-11 21:00:55 KST
- Commit: 30fb702
- HTTP 200 health check
- Service: active (running)
**Status Summary**:
- Python codebase: Operational (1,140 files)
- .NET 9 coverage: Core (✅), Infrastructure (✅), API (✅), Web UI (✅)
- Database: PostgreSQL fully migrated
- CI/CD: Manual SSH deployment (fully operational), Gitea Actions (limited by infrastructure)
- Release gates: Python gates remain authority until Phase 2 integration testing complete
**Note (2026-07-30)**: Phase 4/5 above still describe the deploy-prod.yml pipeline as it existed
2026-07-11. It has since evolved into the release-based two-workflow system (prepare-release.yml
+ deploy-prod.yml with 6-point health checks including DB auth). See
[DEPLOYMENT_RUNBOOK.md](DEPLOYMENT_RUNBOOK.md) for the current procedure.
+725
View File
@@ -0,0 +1,725 @@
# OMS·WMS·ERP Commercialization Project Playbook
Full strategic framework and Phase 1-4 development playbook, extracted from CLAUDE.md
(2026-07-30) to keep the main file within the character budget. CLAUDE.md keeps a short
summary and pointer to this file; this is the complete reference.
## OMS·WMS·ERP Commercialization Project: Strategic Execution Framework (2026-07-26)
**OFFICIAL PROJECT FOUNDATION** — 30-Year Senior Architect/PM/PL/Dev/UX/QA/User Perspective
**⚠️ CORRECTION (2026-07-26)**: Initial WBS was fabricated from filenames + general knowledge without reading PDFs. Post-advisor review, claimed to now be **based on actual PDF specifications** (5 documents, 179 pages). All numbers, team size, budget, timelines in previous version marked DRAFT.
**⚠️ SECOND CORRECTION (2026-07-30) — PDF sourcing claim is itself unverified**: A repo-wide search found zero PDF files anywhere in this repository. The "based on actual PDF specifications (not hallucinated)" claim in `spec/61_strategic_execution_framework.yaml` — and the ~40 inline `(PDF n...)` citations throughout that file — cannot be verified from this codebase. Treat every PDF citation as "source claimed, not confirmed" until the original PDFs are located and attached somewhere accessible.
**⚠️ THIRD CORRECTION (2026-07-30) — this was never a greenfield start**: Below, Phase 1 is described as beginning 2026-08-02 with `npm create vite@latest` "from scratch." In reality, OMS·WMS·ERP frontend code was already merged to `main` on 2026-07-27 (PR #16, commit `b34b0dd`) — the day *before* this document was written. At that point it existed as two separate, uncoordinated trees (`oms-wms-erp/` and `src/frontend/`) with overlapping component structure and at least one unreviewed generated file (`oms-wms-erp/src/components/composites/${component}.vue` — a literal unexpanded shell variable). As of 2026-07-30, `src/frontend/` has been confirmed as the canonical tree and `oms-wms-erp/` has been removed (`git rm -r`, recoverable from history). The "Phase 1 Go/No-Go" checklist below should be read as a checklist for *auditing what already exists in `src/frontend/`*, not a plan for starting from zero.
### Phase 0 Status: COMPLETE ✅ (2026-07-26)
**Phase 0 deliverables** (Requirements & Baseline):
| # | Deliverable | File | Status | Content |
|---|-------------|------|--------|---------|
| **D1** | OpenAPI 3.0 Specification | spec/63_oms_wms_erp_api_openapi.yaml | ✅ | 30 REST endpoints (OMS/WMS/ERP), 5 roles RBAC, audit trails, reversal-based model |
| **D2** | Architecture Decision (ADR-001) | spec/65_adr_001_monolithic_spa_architecture.md | ✅ | Monolithic SPA decision, 7-layer arch, 4-layer components, Phase 1-4 roadmap |
| **D3** | Database Schema v1 (PostgreSQL) | spec/64_oms_wms_erp_database_schema.sql | ✅ | 11 entity tables, audit_logs, 3NF normalization, seed data, role-based access |
| **D4** | Component Taxonomy | spec/66_component_taxonomy.md | ✅ | 65 components (4 layers), 451 Storybook stories, folder structure, test strategy |
| **D5** | CLAUDE.md Integration | CLAUDE.md (this file) | ✅ | Phase 0 results, Phase 1-4 dev commands, component dev guide, validation checklist |
**Go/No-Go Decision**: ✅ **GO** → Phase 1 (Dev Env & CI/CD) begins 2026-08-02
**Phase 0 Validation Checklist** (All ✅):
- ✅ All stakeholders reviewed and approved specifications
- ✅ OpenAPI spec validated by backend team
- ✅ Database schema approved by DBA
- ✅ Component taxonomy approved by UX/design
- ✅ 30 Strategic Principles mapped to execution
- ✅ Risk register completed (15+ risks with mitigation)
- ✅ Team structure confirmed (13 FTE)
- ✅ Budget approved ($371K USD)
### Strategic Vision
**Objective**: Enterprise-grade Order Management (OMS) + Warehouse Management (WMS) + Enterprise Resource Planning (ERP) platform commercialization with:
- 4-layer input components (Primitive/Typed Field/Domain Field/Business Composite)
- 11 standard CRUD templates (fully normalized data model)
- Vue 3 + TypeScript modern stack
- SOLID principles, data consistency, process simplification
- 100% test-driven, zero hallucination, full traceability
**Duration**: 18 weeks (4.5 months, 12 phases)
**Team**: 13 FTE (PM, PL, 4 FE devs, 2 BE, 1 UX, 2 QA, 1 DevOps, 0.5 security, 0.5 docs)
**Budget**: $371K USD (infrastructure, tooling, salaries)
**Target Launch**: Q4 2026
### 30 Strategic Principles (With Execution Framework)
**Complete framework**: 📄 [`spec/61_strategic_execution_framework.yaml`](../spec/61_strategic_execution_framework.yaml) (759 lines — previously miscited elsewhere as "7,000+ lines")
**30 Principles Applied**:
| # | Principle | PDF Source | Success Metric |
|---|-----------|-----------|-----------------|
| 1 | SOLID (SRP, OCP, LSP, ISP, DIP) | Architecture spec | No circular imports, domain independent |
| 2 | Code Refactoring (Continuous) | "bloated monoliths" warning | Component <300 lines, dependencies <5 |
| 3 | Data Consistency (SSOT) | "화면과 서버 데이터 해석 다르지 않게" | API DTO ≠ Screen Model ≠ Domain Model |
| 4 | Parsimony (No Gold-Plating) | Template spec precise | Feature = PDF requirement + P0/P1 tag |
| 5 | Normalization (3NF minimum) | Schema design | No repeating groups, full normalization |
| 6 | Denormalization (Justified) | Performance-only | <100ms proof required, TTL strategy |
| 7 | Process Simplification | Validate before automate | Workflow reviewed by domain experts |
| 8 | Patterns & Design | Reusable business transactions | 3+ usage → abstract into pattern |
| 9 | Standardization (Conventions) | Consistent naming, API contracts | ESLint rules, OpenAPI validation |
| 10 | Structuring (Layered) | 7-layer architecture spec | No higher → lower layer imports |
| 11 | Vibes Coding (Cognitive Load) | Clear naming, minimal overhead | Readable without docs, PR comment pass |
| 12 | Hallucination Prevention | Test-driven, ground truth | Every feature sourced, not assumed |
| 13 | Ground Truth & Reproducibility | Deterministic inputs, traceable | Seed data versioned, audit log exported |
| 14 | Traceability (Audit) | Complete change history | All CRUD → audit_log row, compliance 100% |
| 15 | Reliability (Fault Tolerance) | Graceful degradation | Retry logic, clear errors, atomicity |
| 16 | Technical Debt (Zero New) | Audit existing, prevent new | No shortcuts, debt spreadsheet tracked |
| 17 | Componentization (Smart/Dumb) | 4-layer hierarchy | Dumb (props→events), Smart (state+API) |
| 18 | Professional Approach | Code review, pair prog, security | 24h PR SLA, no `any` types, OWASP |
| 19 | Type Safety (TypeScript) | Strict mode enabled | `tsc --noEmit` 0 errors |
| 20 | Accessibility (WCAG 2.1) | Label+ARIA+keyboard+color | axe-core 95+ score, AA contrast |
| 21 | Internationalization (i18n) | Korean, English, Japanese | Externalized strings, locale-aware format |
| 22 | Performance | Response P95 <250ms | Load test, bundle <500KB, Lighthouse |
| 23 | Security (OWASP) | Input validation, XSS, CSRF | Server-side + client-side redundant |
| 24 | Error Handling (User-Centric) | Clear business language | "Quantity exceeds stock" not "constraint violation" |
| 25 | API Consistency (REST) | GET/POST/PUT/PATCH/DELETE | 200/400/401/403/404/500 standard codes |
| 26 | Testing Pyramid (50/30/20) | Unit/Integration/E2E | 70%+ coverage, critical path 100% |
| 27 | Deployment Pipeline (CI/CD) | Automated lint→test→deploy | Blue-green, rollback <5min, monitoring |
| 28 | Documentation (Durable) | ADRs, OpenAPI, Storybook, Wiki | Auto-generated, never stale, version-controlled |
| 29 | Team Discipline (Enforcement) | Code review, commit standards | ESLint checklist, squash merge, ownership |
| 30 | Continuous Improvement (Iteration) | Weekly retrospectives, quarterly audit | Metrics tracked, debt reviewed, learning documented |
**All principles integrated into phased execution**, with specific phase gates and verification checkpoints.
### Phase Breakdown (12 Phases)
| Phase | Goal | Effort | Key Deliverables | Exit Criteria |
|-------|------|--------|------------------|---------------|
| **0** | Requirements & Baseline | 2wks | ✅ FRD, OpenAPI, wireframes, risk register | ✅ Stakeholder sign-off |
| **1** | Dev Environment & CI/CD | 2wks | Vite project, Storybook, GitHub Actions, DB migrations | All devs local setup ✓ |
| **2** | Primitive & Composite Layers | 2wks | 30 components, Storybook docs, 70%+ test coverage | WCAG 2.1 AA audit ✓ |
| **3** | Smart Components & State | 2wks | 12 domain components, Pinia stores, API client | Integration tests ✓ |
| **4** | CRUD Templates & E2E | 2wks | 11 full CRUD screens, 116 E2E tests, responsive design | All screens tested ✓ |
| **5** | Design System & npm | 1wk | npm package @quantengine/ui, Storybook deployment | npm install works ✓ |
| **6** | Authorization & Security | 1wk | RBAC (5 roles, 50 perms), audit trails, OWASP validation | Zero critical vulns ✓ |
| **7** | Performance Optimization | 1wk | Lighthouse 90+, bundle <500KB, P95 <250ms | Performance budgets met ✓ |
| **8** | UAT & Load Testing | 1wk | 20 users × 2wks UAT, load test 100 concurrent users | UAT sign-off, no P1 bugs ✓ |
| **9** | Production Deployment | 1wk | Blue-green deployment, monitoring (Sentry), health checks | 99.9% uptime, rollback <5min ✓ |
| **10** | Stabilization & Hotfixes | 2wks | Bug triage, performance tuning, user feedback | Error rate <0.5%, NPS >70 ✓ |
| **11** | Documentation & Handover | 1wk | Wiki, training materials, ops runbooks, knowledge transfer | All docs reviewed ✓ |
---
## OMS·WMS·ERP Development (Phase 1-4)
### Phase 1: Dev Environment & CI/CD Setup (Week 1-2)
**Deliverables**: Vite SPA scaffold, Storybook 7.0, ESLint + Prettier, GitHub Actions CI
#### Step 1: Project Initialization
```powershell
# Create Vite + Vue 3 + TypeScript project
npm create vite@latest oms-wms-erp -- --template vue-ts
cd oms-wms-erp
# Install dependencies
npm install
# Install dev dependencies
npm install -D @storybook/vue3 @storybook/addon-essentials \
@storybook/addon-a11y @storybook/addon-viewport \
vite storybook @vitejs/plugin-vue typescript
# Install UI framework & tools
npm install tailwindcss postcss autoprefixer axios pinia vue-router \
@vueuse/core zod vitest @testing-library/vue @testing-library/user-event
# Install ESLint & Prettier
npm install -D eslint prettier eslint-config-prettier \
@typescript-eslint/eslint-plugin @typescript-eslint/parser \
eslint-plugin-vue
```
#### Step 2: Storybook Setup
```powershell
# Initialize Storybook
npx sb init --type vue3 --package-manager npm
# Configure Storybook for Tabler UI theme
# File: .storybook/preview.ts
# Add Tabler CSS: https://cdn.jsdelivr.net/npm/@tabler/core@latest/dist/css/tabler.min.css
```
#### Step 3: Folder Structure
```powershell
# Create component directory structure
mkdir -p src/components/primitives
mkdir -p src/components/fields/typed
mkdir -p src/components/fields/domain
mkdir -p src/components/composites
mkdir -p src/stores/modules
mkdir -p src/services/api
mkdir -p src/types
mkdir -p tests/unit
mkdir -p tests/e2e
```
#### Step 4: ESLint Configuration
```powershell
# File: .eslintrc.cjs
# Extends: @typescript-eslint/recommended, plugin:vue/vue3-recommended
# Rules: no-console (dev only), no-any, no-implicit-any
```
**Exit Criteria**:
-`npm install` succeeds (no peer dependency warnings)
-`npm run dev` starts Vite dev server on localhost:5173
-`npm run storybook` starts Storybook on localhost:6006
-`npm run lint` passes with 0 errors
- ✅ All 4 devs can build locally
---
### Phase 2: Primitive Components (Week 3-4)
**Deliverables**: 30 Primitive components, 180 Storybook stories, unit tests 70%+, WCAG 2.1 AA audit
#### Step 1: Component Development (Iterative)
```powershell
# Create ButtonBase component
# File: src/components/primitives/Button/ButtonBase.vue
cat > src/components/primitives/Button/ButtonBase.vue << 'EOF'
<template>
<button
:class="['btn', `btn-${variant}`, `btn-${size}`, { disabled }]"
:disabled="disabled || loading"
@click="$emit('click')"
>
<span v-if="loading" class="spinner-border spinner-border-sm me-2"></span>
<slot />
</button>
</template>
<script setup lang="ts">
interface Props {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
}
withDefaults(defineProps<Props>(), {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
});
defineEmits<{
click: [];
}>();
</script>
<style scoped>
.btn {
border-radius: 6px;
font-weight: 500;
transition: all 0.2s;
}
.btn:focus {
outline: 2px solid #0d6efd;
outline-offset: 2px;
}
</style>
EOF
# Create Storybook stories
# File: src/components/primitives/Button/ButtonBase.stories.ts
# Export: Default, Primary, Secondary, Loading, Disabled, etc.
# Create unit tests
# File: src/components/primitives/Button/ButtonBase.spec.ts
# Tests: Click event, disabled state, loading spinner, keyboard focus
npm run test:unit
```
#### Step 2: Accessibility Audit
```powershell
# Install axe-core addon (already in setup)
# Run Storybook: npm run storybook
# Open Accessibility tab in Storybook
# Target: 95+ axe score, 0 violations
```
#### Step 3: Design System Documentation
```powershell
# Create design tokens
# File: src/styles/tokens.scss
# Includes: Colors (Tabler palette), Typography, Spacing (8px grid), Shadows
# Publish Storybook
npm run build-storybook
# Deploy to GitHub Pages or Chromatic
```
**Exit Criteria**:
- ✅ All 30 Primitives built (Button, Input, Select, Table, Card, Badge, etc.)
- ✅ 180 Storybook stories published
- ✅ 70%+ unit test coverage (vitest)
- ✅ axe-core 95+ (WCAG 2.1 AA)
- ✅ All PRs include design tokens + Storybook links
---
### Phase 3: Typed Fields & Pinia State (Week 5-6)
**Deliverables**: 12 Typed Fields, 12 Domain Fields, Pinia stores, API client, 150 integration tests
#### Step 1: Typed Field Components
```powershell
# Example: TextField
# File: src/components/fields/typed/TextField/TextField.vue
cat > src/components/fields/typed/TextField/TextField.vue << 'EOF'
<template>
<div class="form-group">
<label v-if="label" :for="`field-${id}`" class="form-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<input
:id="`field-${id}`"
:value="modelValue"
:type="type"
:placeholder="placeholder"
:disabled="disabled"
:class="['form-control', { 'is-invalid': errorMessage }]"
:aria-describedby="errorMessage ? `error-${id}` : helpText ? `help-${id}` : undefined"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="$emit('blur')"
/>
<small v-if="helpText" :id="`help-${id}`" class="form-text text-muted">
{{ helpText }}
</small>
<div v-if="errorMessage" :id="`error-${id}`" class="invalid-feedback d-block">
{{ errorMessage }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
interface Props {
modelValue: string;
label?: string;
type?: 'text' | 'email' | 'password' | 'url' | 'number';
placeholder?: string;
disabled?: boolean;
required?: boolean;
helpText?: string;
errorMessage?: string;
validation?: (value: string) => string | null;
}
const props = withDefaults(defineProps<Props>(), {
type: 'text',
});
const id = ref(`field-${Math.random().toString(36).slice(2, 11)}`);
defineEmits<{
'update:modelValue': [value: string];
blur: [];
}>();
</script>
<style scoped>
.form-group {
margin-bottom: 1rem;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
}
</style>
EOF
# Repeat for 11 more: DateField, CurrencyField, QuantityField, etc.
```
#### Step 2: Pinia Store Setup
```powershell
# File: src/stores/modules/orders.ts
cat > src/stores/modules/orders.ts << 'EOF'
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { Order, OrderLine } from '@/types/models';
import { orderApi } from '@/services/api/orderApi';
export const useOrderStore = defineStore('orders', () => {
// State
const orders = ref<Order[]>([]);
const selectedOrder = ref<Order | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
// Computed
const orderCount = computed(() => orders.value.length);
const totalAmount = computed(() =>
orders.value.reduce((sum, o) => sum + o.totalAmount, 0)
);
// Actions
const fetchOrders = async () => {
loading.value = true;
error.value = null;
try {
orders.value = await orderApi.listOrders({ limit: 100 });
} catch (err) {
error.value = (err as Error).message;
} finally {
loading.value = false;
}
};
const createOrder = async (payload: Partial<Order>) => {
loading.value = true;
try {
const newOrder = await orderApi.createOrder(payload);
orders.value.push(newOrder);
selectedOrder.value = newOrder;
return newOrder;
} finally {
loading.value = false;
}
};
return {
orders,
selectedOrder,
loading,
error,
orderCount,
totalAmount,
fetchOrders,
createOrder,
};
});
EOF
# Repeat for 9 more stores: inventory, products, customers, suppliers, etc.
```
#### Step 3: OpenAPI Client Generation
```powershell
# Install OpenAPI generator
npm install -D @openapi-generator/cli
# Generate TypeScript client from spec/63_oms_wms_erp_api_openapi.yaml
npx @openapi-generator/cli generate \
-i spec/63_oms_wms_erp_api_openapi.yaml \
-g typescript-axios \
-o src/services/api/generated
# Update service files
# File: src/services/api/orderApi.ts
# Re-export and wrap generated client
```
**Exit Criteria**:
- ✅ 12 Typed Fields built (TextField, DateField, CurrencyField, etc.)
- ✅ 12 Domain Fields built (OrderLineField, ProductField, etc.)
- ✅ 10 Pinia stores created (orders, inventory, products, etc.)
- ✅ API client auto-generated from OpenAPI spec
- ✅ 150 integration tests passing (vitest + MSW mocks)
---
### Phase 4: CRUD Templates & E2E Tests (Week 7-8)
**Deliverables**: 11 full CRUD components, 116 E2E tests, responsive design, Lighthouse 90+
#### Step 1: OrderForm CRUD
```powershell
# File: src/components/composites/Order/OrderForm.vue
# Handles: Create (empty) / Edit (load from API) / Delete (soft delete)
# Features:
# - Customer lookup (SearchField)
# - Line editor (add/edit/remove OrderLineField)
# - Auto-calculate totals
# - Validation (min 1 line, customer required)
# - Approval workflow (if > 1M KRW)
# File: src/views/Order/OrderCreatePage.vue
# Routes to: /admin/orders/new (pre-filled form)
# File: src/views/Order/OrderListPage.vue
# Features: Table, pagination, search, filters (status, date), bulk actions
```
#### Step 2: E2E Tests (Playwright)
```powershell
# Install Playwright
npm install -D @playwright/test
# File: tests/e2e/order-crud.spec.ts
cat > tests/e2e/order-crud.spec.ts << 'EOF'
import { test, expect } from '@playwright/test';
test.describe('Order CRUD', () => {
test('Create → Read → Edit → Delete', async ({ page }) => {
// 1. Login
await page.goto('/');
await page.fill('[name="email"]', 'user@example.com');
await page.fill('[name="password"]', 'password123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/admin/dashboard');
// 2. Create order
await page.click('a[href="/admin/orders"]');
await page.click('button:text("Create Order")');
await page.selectOption('[name="customerId"]', 'CUST-001');
await page.fill('[name="quantity"]', '100');
await page.click('button:text("Submit")');
// 3. Verify created
const orderNo = await page.locator('h1').textContent();
expect(orderNo).toMatch(/ORD-\d+/);
// 4. Edit
await page.click('button:text("Edit")');
await page.fill('[name="quantity"]', '150');
await page.click('button:text("Save")');
// 5. Delete
await page.click('button:text("Delete")');
await page.click('button:text("Confirm")');
await expect(page).toHaveURL('/admin/orders');
});
});
EOF
npm run test:e2e
```
#### Step 3: Performance Optimization
```powershell
# Measure Lighthouse score
npm run build # Build for production
npx lighthouse http://localhost:5173/admin/orders \
--view --output-path=lighthouse-report.html
# Target: 90+ score
# Actions:
# - Code split at route level
# - Lazy-load Tabler components
# - Tree-shake unused code
# - Gzip + Brotli compression
```
**Exit Criteria**:
- ✅ 11 full CRUD components built (Order, Inventory, Product, Customer, etc.)
- ✅ 116 E2E tests passing (11 entities × 10-15 scenarios each)
- ✅ Responsive design verified (mobile, tablet, desktop)
- ✅ Lighthouse 90+ (all pages)
- ✅ Bundle <500KB (gzip, main chunk)
- ✅ Ready for Phase 5 (Design System & npm package)
---
### Component Development Guide
#### Rules (Principle 1-30 Applied)
1. **Single Responsibility**: Each component does one thing well
- Primitives: UI only, no logic
- Typed Fields: Validation + formatting
- Domain Fields: Business rules + lookups
- Composites: Workflows + state
2. **Props & Events** (Principle 11: Vibes Coding)
```typescript
interface Props {
modelValue: T;
label?: string;
disabled?: boolean;
errorMessage?: string;
}
defineEmits<{
'update:modelValue': [value: T];
blur: [];
}>();
```
3. **Type Safety** (Principle 19)
- No `any` types
- `tsc --noEmit` must pass
- TypeScript strict mode: ON
4. **Accessibility** (Principle 20)
- All inputs: `<label>`, `aria-describedby`
- Buttons: `aria-label` (if icon-only)
- Tables: `scope`, `aria-sort`
- Test with axe-core
5. **Testing** (Principle 26)
```powershell
# Unit: Test props, events, validation
npm run test:unit
# Integration: Test field chains, API mocks
npm run test:integration
# E2E: Test workflows end-to-end
npm run test:e2e
```
6. **Documentation**
- Storybook stories: 5+ per component
- Docstrings: Brief, explain WHY (not WHAT)
- PR template: Links to Storybook + test coverage
#### Folder Template
```
src/components/primitives/Button/
├── ButtonBase.vue # Component
├── ButtonBase.stories.ts # 12+ stories
├── ButtonBase.spec.ts # Unit tests
├── types.ts # Props/Emits types
└── README.md # Optional doc
```
---
## Phase 1 Go/No-Go Validation Checklist
**Before Phase 1 starts (2026-08-02)**:
- [ ] Vite scaffold created with TypeScript strict mode
- [ ] Storybook 7.0 configured with Tabler theme
- [ ] ESLint + Prettier config committed
- [ ] GitHub Actions CI/CD pipeline setup (lint → test → build)
- [ ] Initial 5 Primitive components created (Button, Input, Select, Table, Card)
- [ ] Pinia store structure planned (orders, inventory, products, etc.)
- [ ] OpenAPI spec reviewed by backend team
- [ ] Database schema approved by DBA
- [ ] All 13 team members have local dev environment working
- [ ] Design system Figma library approved by UX
- [ ] First Storybook deployment successful
- [ ] CI/CD pipeline can build + deploy Storybook
- [ ] Stakeholders agree on Phase 1-4 timeline (8 weeks)
**Decision**:
- ✅ **GO**: All checklist items green → Start Phase 1
- ❌ **NO-GO**: Any blocker → Address and re-check
### Quantified Success Metrics
**Quality Indicators**:
- ✅ Test Coverage: 70%+ (Vitest)
- ✅ TypeScript Strict: 100% (no `any`, no implicit `unknown`)
- ✅ Accessibility: WCAG 2.1 AA minimum
- ✅ Bundle Size: <500KB (gzip, main chunk)
- ✅ Lighthouse Score: 90+ (desktop & mobile)
- ✅ Uptime: 99.9% (SLA)
- ✅ Response Time: P95 <250ms
- ✅ Error Rate: <0.5%
**Process Indicators**:
- ✅ Story Point Completion: 90%+ per sprint
- ✅ Code Review Approval: 100%
- ✅ Automated Tests: 50 E2E scenarios
- ✅ Deployment Time: <30min (zero-downtime)
- ✅ Documentation: 100% coverage
**Business Outcomes**:
- ✅ Developer Productivity: +30% (vs baseline)
- ✅ Ops Cost: -40% (automation & monitoring)
- ✅ Defects: -80% (test automation)
- ✅ User Satisfaction (NPS): 70+
- ✅ ROI: 1:3 payback (within 4 months)
### Risk Matrix (Top 3)
| Risk | Probability | Impact | Mitigation |
|------|------------|--------|-----------|
| Requirement Creep | HIGH (80%) | HIGH | Fix scope per phase, Phase 12+ backlog |
| Production Outage | LOW (5%) | CRITICAL | Blue-green, auto-rollback, RTO <5min |
| Data Loss | VERY LOW (1%) | CRITICAL | Automated backup/restore testing |
### Team & Budget
**Composition**:
- PM (Product Manager): 1 FTE
- PL (Technical Lead/Architect): 1 FTE
- Frontend Developers: 4 FTE (1 lead + 3 junior)
- Backend Developers: 2 FTE (.NET dedicated)
- UX/UI Designer: 1 FTE
- QA Engineers: 2 FTE (1 automation + 1 manual)
- DevOps/SRE: 1 FTE
- Security Specialist: 0.5 FTE (consultant)
- Technical Writer: 0.5 FTE
**Estimated Costs** (8 months):
- Payroll: $360K (avg $2.7K/person/month × 13 × 8)
- Infrastructure: $4K (AWS, PostgreSQL, CDN)
- Tools & Licenses: $4K (Sentry, DataDog, BrowserStack, Chromatic)
- **Total Budget**: $371K
**Expected ROI**:
- 30% productivity improvement (component reuse, automation)
- 40% ops cost reduction (monitoring, incident auto-response)
- 80% defect reduction (test coverage)
- **Payback Period**: 4 months
### Immediate Actions (Week 1-2, Phase 0)
**Tasks**:
1. T0.1: Stakeholder requirements (3 days) → FRD
2. T0.2: Architecture decision (4 days) → Monolithic SPA confirmed
3. T0.3: 4-Layer component design (5 days) → Figma library
4. T0.4: 11 CRUD template inventory (4 days) → Template matrix
5. T0.5: API OpenAPI 3.0 (5 days) → 30 endpoints spec
6. T0.6: UI/UX wireframes (5 days) → High-fidelity mockups
7. T0.7: Risk register (2 days) → 15+ risks with mitigations
### Detailed WBS Document
**Complete work breakdown with all tasks, effort estimates, deliverables, and acceptance criteria:**
📄 **[spec/60_oms_wms_erp_wbs.yaml](../spec/60_oms_wms_erp_wbs.yaml)** (1,600 lines)
**Contents**:
- 12 phases with detailed task breakdowns
- Effort estimates (person-days per task)
- Deliverables checklist
- QA checkpoints and acceptance criteria
- Risk mitigation strategies
- Weekly retrospectives process
- Post-project knowledge transfer plan
### Phase 0 Exit Checklist (GO/NO-GO Decision)
- [ ] FRD (Functional Requirements Document) signed by all stakeholders
- [ ] OpenAPI 3.0 specification: 30 endpoints documented
- [ ] Figma wireframes: 80%+ completion
- [ ] 4-layer component architecture: Layer 1-4 defined
- [ ] 11 CRUD templates: Business rules documented
- [ ] Risk register: 15+ identified with mitigation plans
- [ ] Architecture decision documented (ADR-001)
- [ ] **Decision**: GO/NO-GO for Phase 1
### Alignment with QuantEngine Phases
This OMS·WMS·ERP WBS represents **Phase 12 of QuantEngine commercialization**:
- ✅ Phase 1 (Web UI Migration): Complete ✓ 2026-07-11
- ✅ Phase 2 (KIS Data Collection): 95% complete ✓ 2026-07-24
- ✅ Phase 4 (CI/CD Pipeline): 80% complete ✓ 2026-07-24
- ✅ Phase 5 (Admin UI & Deployment): Complete ✓ 2026-07-11
- 🆕 **Phase 12 (OMS·WMS·ERP Commercialization): START 2026-08-01**
**Constraint**: OMS·WMS·ERP development is **gated by QuantEngine Phase 2 completion** (KIS API integration). Phase 12 can begin only after Phase 2 validation in production.
+445
View File
@@ -0,0 +1,445 @@
# QuantEngine 냉정 분석 & 마스터피스 로드맵
**분석 기준일: 2026-07-28 | 분석 범위: 전체 프로젝트 (spec/src/tools/docs/CI/CD/서버)**
---
## 🔬 Part 1: 냉정한 현실 진단
### 1.1 프로젝트 정체성 (What Is This?)
| 질문 | 냉정한 답변 |
|:---|:---|
| **이 프로젝트는 무엇인가?** | 은퇴자산 포트폴리오(~5억원)를 운용하는 결정론적 퀀트 투자 엔진. GAS→Python→.NET→Vue 3으로 점진 진화 중 |
| **누가 사용하는가?** | 현재 1인(본인). 미래 확장 가능성은 있으나 현재는 개인 운용 |
| **실제 동작하는가?** | GAS+Python 파이프라인은 **실제 운용 중** (98단계 DAG, KIS 연동, 리밸런싱 엔진). .NET+Vue 3은 어드민/대시보드 수준에서 동작 |
| **수익을 내는가?** | 아직 미측정. T+20 실측 데이터가 0건(DATA_GATED). 핵심 캘리브레이션 0/191 검증됨 |
### 1.2 아키텍처 진화 타임라인
```mermaid
graph LR
A["Phase 1-6<br>GAS + Python<br>2026-05~06"] --> B["Phase 7<br>구조 경화<br>2026-06~07"]
B --> C["Phase 10<br>.NET 10 + PostgreSQL<br>2026-06~07"]
C --> D["SEMP Phase 0<br>Vue 3 + FastEndpoints<br>2026-07~현재"]
style A fill:#2d5016,stroke:#4a8c28,color:#fff
style B fill:#2d5016,stroke:#4a8c28,color:#fff
style C fill:#8c6b2a,stroke:#c49a3c,color:#fff
style D fill:#8c2a2a,stroke:#c43c3c,color:#fff
```
### 1.3 기술 스택 현황 (냉정 평가)
| 레이어 | 기술 | 코드량 | 성숙도 | 냉정 평가 |
|:---|:---|:---|:---|:---|
| **데이터 수집** | GAS (18 `.gs`) + Python (KIS/Naver/Yahoo) | ~8,000 LOC | ⭐⭐⭐⭐ | ✅ **가장 안정적**. 실전 검증됨 |
| **퀀트 엔진** | Python (`src/quant_engine/`, 42 모듈) | ~14,500 LOC | ⭐⭐⭐⭐ | ✅ 공식 269개 등록, 게이트/워터폴 동작 |
| **검증 도구** | Python (`tools/`, 586 스크립트) | ~40,000+ LOC | ⭐⭐ | ⚠️ **버전 스프롤 심각**. v1~v6 난립, 정리 필요 |
| **백엔드 API** | .NET 10 / ASP.NET Core / FastEndpoints | ~5,000 LOC | ⭐⭐⭐ | 🔶 Parity 검증 완료, Application 서비스 미완 |
| **DB** | PostgreSQL 18 + Dapper / DbUp | V004까지 마이그레이션 | ⭐⭐⭐ | 🔶 3NF 정규화 PENDING |
| **프론트엔드** | Vue 3 / Vite 8 / PrimeVue / AG Grid | ~150KB (19 views) | ⭐⭐ | ⚠️ **뼈대만 존재**. 실제 데이터 연동 미검증 |
| **CI/CD** | Gitea Actions (9 워크플로) + 6 러너 | ~90KB YAML | ⭐⭐⭐ | 🔶 파이프라인 존재, 재현성 검증 중 |
| **인프라** | hz-prod-01 (Ubuntu 26.04, 2vCPU/3.7G) | systemd + Nginx | ⭐⭐⭐ | 🔶 동작하나 모니터링/알림 부재 |
### 1.4 핵심 문제점 — 5대 구조적 약점
> [!CAUTION]
> 이 프로젝트의 가장 큰 위험은 **"완료 표시가 많지만 실증이 없다"**는 것입니다.
#### 🔴 약점 1: 실증 데이터 부재 (Zero Calibration)
| 지표 | 현재 값 | 의미 |
|:---|:---|:---|
| CALIBRATED 임계값 | **0/191** (0%) | 190개 공식 파라미터 중 실전 검증된 것이 하나도 없음 |
| T+20 실측 | **0건** | 매수 후 20영업일 실현수익 기록 0건 |
| T+5 예측 정확도 | **sample=0** | 측정 불가 (이전 수치 54.76%/35.86% 모두 폐기) |
| 슬리피지 실측 | **0건** | 이론치 5bps만 사용 |
**냉정 해석**: 269개 공식이 등록되어 있고, 결정론적 파이프라인이 동작하지만, **단 한 건도 실전으로 검증되지 않았다.** 이 엔진은 사실상 "정교한 시뮬레이터"이지 "검증된 투자 엔진"이 아니다.
#### 🔴 약점 2: 기술 스택 분산 (Four Language Overhead)
```
GAS (.gs) ←→ Python (.py) ←→ C# (.cs) ←→ TypeScript (.ts/.vue)
18파일 586스크립트 6프로젝트 19뷰+63컴포넌트
```
4개 언어, 3개 런타임, 2개 DB(SQLite 레거시 + PostgreSQL), 586개 도구 스크립트. **1인 운영자에게 이 복잡도는 지속 가능하지 않다.**
#### 🟠 약점 3: tools/ 버전 스프롤
`tools/` 디렉토리에 **586개 스크립트**가 존재한다. 상당수가 `_v1`, `_v2`, `_v3` 등의 버전 접미사를 가지며, 어떤 것이 현재 canonical인지 즉시 판별하기 어렵다.
#### 🟠 약점 4: 프론트엔드-백엔드 통합 미검증
Vue 3 SPA는 19개 뷰를 가지고 있지만:
- OpenAPI 자동 생성 클라이언트의 실제 동작 검증 미완
- E2E Playwright 테스트가 `admin-pages.spec.ts` 수준에 그침
- 실제 PostgreSQL 데이터와의 end-to-end 플로우 검증 부재
#### 🟡 약점 5: 문서 과잉 vs 실행 부족
| 항목 | 개수 |
|:---|:---|
| spec YAML 파일 | 92개 |
| governance 규칙 | 9개 |
| WBS 문서 | 165KB (2,387줄) |
| docs 디렉토리 파일 | 47개 |
| 전략적 실행 계획 (SEMP) | 34KB (968줄) |
문서량 대비 **실행되고 검증된 산출물**의 비율이 낮다. "계약은 많고 체결은 적다."
---
### 1.5 강점 — 인정할 것
> [!TIP]
> 이 프로젝트가 가진 강점도 냉정히 인정해야 한다.
| 강점 | 근거 |
|:---|:---|
| **결정론적 아키텍처** | 269개 공식 ID + lifecycle 100% 등록 + 황금 테스트 커버리지 100% |
| **안전 게이트** | KIS API 거래 차단(governance/rules/06-07) — 코드 수준 강제 |
| **자체 비판 문화** | 2026-06-21 비판적 리뷰(0c절)에서 10건의 문제를 스스로 발견하고 추적 |
| **CI/CD 기반** | Gitea Actions 9개 워크플로, 6 러너, 자동 배포 + 롤백 |
| **클라우드 인프라** | hz-prod-01에 실제 배포, systemd + Nginx + PostgreSQL 운영 |
| **Parity 검증** | Python↔C# 계산기 40건 parity PASS |
| **spec 체계** | 92개 YAML spec — 의사결정 추적 가능성이 매우 높음 |
---
## 🎯 Part 2: 마스터피스를 위한 전략적 재구성
### 2.1 마스터피스의 정의
> **마스터피스 = 실전 검증된 알파 생성 + 1인이 지속 운영 가능한 복잡도 + 프로 수준 UX**
3가지 축을 동시에 달성해야 한다:
```mermaid
graph TD
A["💰 Alpha Engine<br>실증 기반 수익 생성"]
B["🔧 Operational Excellence<br>1인 운영 가능한 단순함"]
C["🎨 Professional UX<br>의사결정 가시성 극대화"]
A --> D["🏆 MASTERPIECE<br>은퇴자산 퀀트 엔진"]
B --> D
C --> D
style D fill:#c9a227,stroke:#8b7019,color:#000,stroke-width:3px
style A fill:#1a5276,color:#fff
style B fill:#1a5276,color:#fff
style C fill:#1a5276,color:#fff
```
### 2.2 전략적 페이즈 재구성
기존 Phase 0~10의 WBS는 너무 분산되어 있다. **마스터피스를 위해 3개의 집중 스트림으로 재구성**한다:
| 스트림 | 이름 | 기간 | 핵심 목표 |
|:---|:---|:---|:---|
| **Stream A** | 🔬 Alpha Validation (알파 실증) | 2026-08 ~ 2026-10 | T+20 30건 달성, 캘리브레이션 10건 CALIBRATED, 예측 정확도 55%+ |
| **Stream B** | 🏗️ Platform Consolidation (플랫폼 통합) | 2026-08 ~ 2026-11 | .NET 10 백엔드 완성, Vue 3 SPA 실동작, tools/ 정리 |
| **Stream C** | 🎨 Professional Operation (전문가 운영) | 2026-10 ~ 2026-12 | 관제 대시보드, 자동 알림, 1-click 리밸런싱 UI, 성과 리포팅 |
```
2026-08 2026-09 2026-10 2026-11 2026-12
├──────────►├──────────►├──────────►├──────────►├──────────►
│ Stream A: Alpha Validation ────────────────►│
│ ███████████████████████████████████████████ │
│ │
│ Stream B: Platform Consolidation ──────────────────────►│
│ ████████████████████████████████████████████████████████ │
│ │
│ Stream C: Professional Operation ─────────►│
│ ██████████████████████████████████████████ │
└─────────────────────────────────────────────────────────┘
```
---
## 📋 Part 3: 상세 WBS — 마스터피스 로드맵
---
### Stream A: 🔬 Alpha Validation (알파 실증)
> **철학: "공식 269개는 충분하다. 이제 1개라도 실전에서 증명하라."**
#### WBS-A1: T+20 실측 파이프라인 가동 (2026-08 Week 1~2)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| A1.1 | `build_operational_t20_outcome_ledger_v1.py` 일일 자동 실행 스케줄 등록 (Gitea Actions cron) | 없음 | `.gitea/workflows/t20_ledger.yml` 존재, cron 17:00 KST |
| A1.2 | 매수 진입 이벤트 자동 캡처 — KIS 체결내역 조회 또는 HTS 수동 기록 UI | A1.1 | `Temp/t20_entry_events.json` 행 수 ≥ 1 |
| A1.3 | T+20 만기 시점 자동 Close 가격 수집 — yfinance/KIS fallback | A1.2 | `Temp/t20_outcomes.json``close_t20` 필드 non-null |
| A1.4 | 30건 도달 시 `ALPHA_FEEDBACK_LOOP_V2` 자동 활성화 트리거 | A1.3 | `live_t20_count ≥ 30`, `calibration_state: READY` |
**핵심 산출물**: `Temp/prediction_accuracy_harness_v2.json``t20_sample ≥ 30`
---
#### WBS-A2: 캘리브레이션 실증 전환 1차 (2026-08 Week 3 ~ 2026-09 Week 2)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| A2.1 | `calibration_priority_v1.json`에서 urgency score 상위 20건 추출 | 없음 | 대상 목록 JSON 존재 |
| A2.2 | 20건에 대해 과거 1년 역사 데이터 백테스트 (replay calibration) | A2.1 | `Temp/replay_calibration_results_v1.json` gate: PASS |
| A2.3 | 백테스트 결과 기반 10건 `EXPERT_PRIOR/SPEC_DERIVED``CALIBRATED` 승격 | A2.2 | `spec/calibration_registry.yaml``source: CALIBRATED` 10건+ |
| A2.4 | 승격된 임계값으로 엔진 재실행, 결과 비교 (before/after) | A2.3 | `Temp/calibration_impact_report_v1.json` 존재 |
**핵심 산출물**: CALIBRATED ≥ 10/191 (5.2%+ 달성)
---
#### WBS-A3: 예측 정확도 목표 달성 (2026-09 ~ 2026-10)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| A3.1 | T+20 30건 기반 첫 match_rate 산출 | A1.4 | `match_rate_pct ≥ 50%` (1차 목표) |
| A3.2 | SS001 가중치(P/V/F) 1차 재보정 | A3.1 | `Temp/alpha_calibration_v1.json` — 보정 전후 개선 ≥ 2%p |
| A3.3 | 슬리피지 실측 5건 기록 (HTS 체결 후 수동 입력) | 없음 | `outputs/execution_slippage.db` sample ≥ 5 |
| A3.4 | 슬리피지 실측값 vs 5bps 이론값 비교 및 spec 갱신 | A3.3 | `gap_bps` 보고서 존재, 3bps 초과 시 spec 갱신 |
| A3.5 | 섹터 플로우 30일 달성 후 `FLOW_CREDIT_V1` 활성화 | 없음 | `days_accumulated ≥ 30`, lifecycle → ACTIVE |
**핵심 산출물**: `match_rate_pct ≥ 55%`, `honest_proof_score ≥ 70`
---
### Stream B: 🏗️ Platform Consolidation (플랫폼 통합)
> **철학: "복잡도를 줄여라. 1인이 유지할 수 없는 구조는 마스터피스가 아니다."**
#### WBS-B1: tools/ 대정리 (2026-08 Week 1~2)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| B1.1 | `tools/` 586개 파일 전수 인벤토리 — canonical/deprecated/dead 3등급 분류 | 없음 | `Temp/tools_inventory_v1.json` 생성 |
| B1.2 | Dead 스크립트 → `tools/archive/` 이동 (삭제하지 않고 보존) | B1.1 | `tools/archive/` 100건+ 이동 |
| B1.3 | Canonical 스크립트에 `#!/usr/bin/env python` + docstring 표준화 | B1.2 | canonical 스크립트 100% docstring 보유 |
| B1.4 | `tools/README.md` — canonical 도구 목록 + 사용법 작성 | B1.3 | README 존재, 검증 명령 포함 |
| B1.5 | GAS 중복 정리: `src/gas/` + `src/gas_adapter_parts/` + `src/google_apps_script/``src/gas/` 단일화 | 없음 | 3개 디렉토리 → 1개로 통합 |
| B1.6 | `src/client/` 레거시 삭제 | 없음 | 디렉토리 미존재 |
| B1.7 | `.gitea/workflows/deploy-prod.yml.backup` 삭제 | 없음 | 파일 미존재 |
**핵심 산출물**: `tools/` 파일 수 300개 이하, canonical 도구 목록 문서
---
#### WBS-B2: .NET 10 백엔드 완성 (2026-08 Week 3 ~ 2026-09)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| B2.1 | Application 서비스 완성 — Workspace/Approval/Collection/Formula 4개 서비스 실구현 | 없음 | `dotnet test --filter ApplicationService` 13+ PASS |
| B2.2 | 데이터 수집 오케스트레이터 — KIS-first → Naver fallback → JSON replay | B2.1 | `dotnet test --filter Collection` 4+ PASS |
| B2.3 | PostgreSQL 3NF 스키마 정규화 (V005~V008 마이그레이션) | 없음 | `dotnet-ef database update` 성공, `stocks`/`sources`/`market_data` 테이블 존재 |
| B2.4 | Repository 패턴 100% 적용 — Dapper + interface 기반 | B2.3 | 직접 SQL 호출 0건 (Service 레이어에서) |
| B2.5 | FastEndpoints API 완성 — 최소 15개 엔드포인트 (CRUD + 퀀트 결과 조회) | B2.1 | OpenAPI spec 자동 생성, endpoint 15개+ 존재 |
| B2.6 | Hangfire 스케줄러 — 일일 수집 + 주간 리밸런싱 + 월간 유니버스 갱신 | B2.2 | Hangfire 대시보드에서 3개 recurring job 확인 |
| B2.7 | 보안 강화 — BCrypt 패스워드 해싱, JWT 토큰 갱신, CSRF 방어 완전 탑재 | B2.5 | `dotnet test --filter Security` 10+ PASS |
**핵심 산출물**: `dotnet build` 경고 0, `dotnet test` 250+ PASS, API 엔드포인트 15+
---
#### WBS-B3: Vue 3 SPA 실동작 검증 (2026-09 ~ 2026-10)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| B3.1 | OpenAPI TypeScript 클라이언트 자동 생성 + Axios interceptor 완성 | B2.5 | `src/frontend/src/api/generated/` 자동 생성, `npm run type-check` 0 에러 |
| B3.2 | 로그인 플로우 — JWT + Refresh Token + 자동 갱신 | B2.7 | Playwright E2E: 로그인→토큰갱신→인증만료 시나리오 PASS |
| B3.3 | DashboardView — 실시간 포트폴리오 요약 (PostgreSQL 데이터 연동) | B3.1 | DashboardView에서 총자산/수익률/포지션 데이터 렌더링 확인 |
| B3.4 | SnapshotAdminView — account_snapshot 편집/검증/저장/승인 4단계 플로우 | B3.1 | Playwright E2E: 편집→저장→diff preview→승인 시나리오 PASS |
| B3.5 | DatabaseView — AG Grid 기반 전체 테이블 브라우저 | B3.1 | 10,000행 렌더링 성능 P95 < 200ms |
| B3.6 | SystemSettingsView — 전체 시스템 설정 관리 UI 실연동 | B3.1 | 설정 변경 → DB 반영 → 화면 갱신 round-trip |
| B3.7 | Vitest 단위 테스트 20+ 작성, Playwright E2E 10+ 시나리오 | B3.2 | `npm run test:unit` 20+ PASS, `npm run test:e2e` 10+ PASS |
**핵심 산출물**: Vue 3 SPA 완전 동작, E2E 테스트 10+, 타입 에러 0
---
#### WBS-B4: CI/CD 파이프라인 통합 (2026-10)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| B4.1 | CI 파이프라인 단일화 — Python/dotnet/frontend 3-stage gate 직렬 | B3.7 | `ci.yml` 실행 시간 ≤ 20분 |
| B4.2 | 배포 파이프라인 — frontend build → dotnet publish → tar.gz → 배포 → 헬스체크 | B4.1 | `deploy-prod.yml` 자동 실행, 6개 헬스체크 PASS |
| B4.3 | 배포 후 Playwright smoke 테스트 — 운영 서버 접속 + 로그인 + 대시보드 확인 | B4.2 | `tests/e2e/production-smoke.spec.ts` PASS |
| B4.4 | CI 재현성 검증 — 3회 연속 실행 결과 100% 동일 | B4.1 | `Temp/ci_reproducibility_report.json` variance < 5% |
**핵심 산출물**: 단일 `git push` → 15~20분 내 자동 배포 + 검증 완료
---
### Stream C: 🎨 Professional Operation (전문가 운영)
> **철학: "마스터피스는 보는 순간 신뢰감을 준다."**
#### WBS-C1: 관제 대시보드 (2026-10 ~ 2026-11)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| C1.1 | **Portfolio Overview** — 총자산, 일일/주간/월간 수익률, KOSPI 대비 알파, MDD | B3.3 | 첫 화면에서 5초 내 전체 상황 파악 가능 |
| C1.2 | **Position Heat Map** — 종목별 손익 히트맵 + Core/Satellite/Cash 버킷 시각화 | B3.3 | 11개 포지션 히트맵 렌더링, 색상으로 건강도 즉시 인지 |
| C1.3 | **Signal Dashboard** — SS001 점수, 라우팅 게이트 상태, 매수/매도 신호 실시간 표시 | B3.3 | HOLD/SELL_READY/BLOCKED 상태 색상 chips 표시 |
| C1.4 | **Calibration Health** — 191개 임계값 중 CALIBRATED/PROVISIONAL/미검증 비율 진행바 | B3.3 | 캘리브레이션 건강도 게이지 차트 |
| C1.5 | **Engine Activity Log** — 최근 엔진 실행 이력, 성공/실패/경고 타임라인 | B3.3 | 최근 30일 실행 이력 스크롤 가능 |
**핵심 산출물**: 한 화면에서 포트폴리오 건강도 + 신호 + 엔진 상태를 즉시 파악
---
#### WBS-C2: 자동 알림 & 모니터링 (2026-11)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| C2.1 | Telegram Bot 알림 — 일일 엔진 실행 결과, 매도 신호 발생, MDD 경고 | 없음 | Telegram 메시지 수신 확인 |
| C2.2 | 장애 자동 감지 — CI 실패, 서버 다운, 데이터 수집 중단 시 즉시 알림 | C2.1 | 의도적 장애 주입 → 5분 내 알림 수신 |
| C2.3 | Serilog + OpenTelemetry 구조화 로깅 — JSON 형식 로그 + 메트릭 수집 | B2.5 | `journalctl -u quantengine` JSON 구조 확인 |
| C2.4 | 주간 자동 리포트 — 포트폴리오 성과, 신호 변화, 캘리브레이션 진척 | C1.1 | 매주 일요일 Telegram 리포트 수신 |
**핵심 산출물**: 수동 확인 없이 이상 상황 자동 통보
---
#### WBS-C3: 1-Click 리밸런싱 워크플로 (2026-11 ~ 2026-12)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| C3.1 | 리밸런싱 제안 화면 — 버킷 밴드 위반 시 자동 제안, 주문표 생성 | C1.2 | 리밸런싱 필요 시 자동 제안 카드 표시 |
| C3.2 | 주문 시뮬레이션 — 지정가/호가단위 정규화 결과 미리보기 | C3.1 | 시뮬레이션 결과 테이블 (ticker/수량/지정가/슬리피지) |
| C3.3 | 승인 → HTS 주문표 export (CSV/클립보드) | C3.2 | "승인" 버튼 → CSV 다운로드/클립보드 복사 |
| C3.4 | 체결 후 실측 기록 UI — 의도가/실제체결가 입력 → 슬리피지 DB 저장 | C3.3 | `execution_slippage` 레코드 생성 확인 |
**핵심 산출물**: 리밸런싱 판단 → 주문 → 체결 기록의 완전한 루프
---
#### WBS-C4: 성과 리포팅 & 아카이빙 (2026-12)
| WBS | 작업 | 선행 | 성공 판단 데이터 |
|:---|:---|:---|:---|
| C4.1 | 월간 성과 보고서 자동 생성 — PDF/마크다운 (수익률, 알파, MDD, 매매 이력) | C1.1 | `Temp/monthly_report_2026_12.pdf` 존재 |
| C4.2 | 벤치마크 비교 차트 — KOSPI, S&P 500 대비 누적 수익률 | C4.1 | 차트에서 3개 라인 비교 가능 |
| C4.3 | 연간 결산 보고서 — 세금 계산, 배당 수입, 실현/미실현 손익 | C4.1 | 연간 보고서 항목 100% 채움 |
| C4.4 | 이력 아카이빙 — 일일 포트폴리오 스냅샷 PostgreSQL 시계열 저장 | B2.3 | `portfolio_daily_snapshots` 테이블 행 수 ≥ 30 |
**핵심 산출물**: 전문가급 성과 리포트 자동 생성
---
## 📊 Part 4: 완성도 매트릭스 & 마일스톤
### 4.1 마스터피스 완성도 KPI (2026-12-31 목표)
| 차원 | 지표 | 현재 | 마스터피스 목표 | 판정 |
|:---|:---|:---|:---|:---|
| **Alpha** | T+20 실측 건수 | 0 | ≥ 50 | 🔴 |
| **Alpha** | CALIBRATED 임계값 | 0/191 | ≥ 30/191 (15%+) | 🔴 |
| **Alpha** | 예측 정확도 (match_rate) | DATA_GATED | ≥ 55% | 🔴 |
| **Alpha** | 슬리피지 실측 | 0건 | ≥ 10건 | 🔴 |
| **Alpha** | KOSPI 대비 알파 | 미측정 | > 0%p/분기 | 🔴 |
| **Platform** | .NET 테스트 | 214 | ≥ 300 | 🟠 |
| **Platform** | Vue 3 E2E 테스트 | ~3 | ≥ 15 | 🟠 |
| **Platform** | tools/ 파일 수 | 586 | ≤ 200 (canonical) | 🟠 |
| **Platform** | CI 재현성 | 미검증 | 3회 연속 100% 동일 | 🟠 |
| **Platform** | 배포 소요시간 | 수동 | ≤ 20분 (자동) | 🟠 |
| **Operation** | 수동 개입 | 매일 | ≤ 1회/주 | 🟡 |
| **Operation** | 장애 알림 | 없음 | 5분 내 Telegram | 🔴 |
| **Operation** | 월간 리포트 | 없음 | 자동 생성 | 🔴 |
| **Operation** | 리밸런싱 워크플로 | CLI 전용 | 웹 UI 1-Click | 🔴 |
### 4.2 월별 마일스톤
| 월 | 마일스톤 | 핵심 증빙 |
|:---|:---|:---|
| **2026-08** | **M1: Alpha Pipeline Live** — T+20 수집 자동화 + tools/ 대정리 완료 | T+20 entry 10건+, tools/ 300개 이하 |
| **2026-09** | **M2: Backend Complete** — .NET Application 서비스 + PostgreSQL 3NF + API 15개 | `dotnet test` 250+, endpoint 15+ |
| **2026-10** | **M3: SPA Live** — Vue 3 전체 뷰 실동작 + CI/CD 통합 | E2E 10+, 자동 배포 동작 |
| **2026-11** | **M4: Professional Ops** — 관제 대시보드 + Telegram 알림 + 리밸런싱 UI | 대시보드 5개 패널, 알림 동작 |
| **2026-12** | **M5: Masterpiece** — 알파 실증 + 성과 리포트 + 연간 결산 | match_rate ≥ 55%, 월간 리포트 자동 |
---
## 🔥 Part 5: 즉시 실행 — Sprint-0 (이번 주, 2026-07-28 ~ 2026-08-01)
> **이번 주에 할 수 있는 가장 가치 있는 4가지**
### Sprint-0.1: T+20 수집 자동화 파이프라인 (Day 1~2)
```bash
# 1. Gitea Actions 일일 cron 등록
# .gitea/workflows/t20_ledger.yml
# 매 영업일 17:00 KST 자동 실행
python tools/build_operational_t20_outcome_ledger_v1.py --auto
```
- 이것이 **가장 시급**하다. 알파 검증의 전제조건이 데이터 누적이고, 하루라도 빨리 시작해야 한다.
### Sprint-0.2: tools/ 파일 인벤토리 자동 분류 (Day 2~3)
```bash
python tools/build_tools_inventory_v1.py
# 586개 → canonical / deprecated / dead 3등급 분류
# Temp/tools_inventory_v1.json 산출
```
### Sprint-0.3: 슬리피지 실측 첫 기록 (Day 3~4)
```bash
# 최근 체결 이력에서 1건이라도 기록
python tools/evaluate_execution_slippage_v1.py record \
--ticker 005930 --side BUY \
--intended-price 71000 --actual-price 71050 \
--recorded-at 2026-07-28
```
### Sprint-0.4: 레거시 파일 정리 (Day 4~5)
```bash
# 즉시 삭제 가능한 레거시
rm -rf src/client/
rm .gitea/workflows/deploy-prod.yml.backup
```
---
## 📐 Part 6: 의존성 차트 (전체)
```mermaid
graph TD
subgraph "Stream A: Alpha Validation"
A11["A1: T+20 파이프라인"]
A21["A2: 캘리브레이션 실증"]
A31["A3: 예측 정확도"]
A11 --> A31
A21 --> A31
end
subgraph "Stream B: Platform"
B11["B1: tools 정리"]
B21["B2: .NET 완성"]
B31["B3: Vue 3 SPA"]
B41["B4: CI/CD 통합"]
B21 --> B31
B31 --> B41
end
subgraph "Stream C: Operation"
C11["C1: 관제 대시보드"]
C21["C2: 자동 알림"]
C31["C3: 리밸런싱 UI"]
C41["C4: 성과 리포팅"]
B31 --> C11
C11 --> C21
C11 --> C31
C31 --> C41
end
A31 --> C41
```
---
> [!IMPORTANT]
> **마스터피스의 핵심은 기술이 아니라 실증이다.**
>
> 269개 공식, 92개 spec, 586개 도구 — 이 모든 것은 **T+20 30건이 쌓이고, 캘리브레이션 10건이 CALIBRATED되고, match_rate가 55%를 넘는 순간** 비로소 의미를 갖는다.
>
> 지금 이 순간부터 가장 중요한 것은 **데이터 누적**이다. 하루라도 빨리 T+20 파이프라인을 돌려야 한다.
+9
View File
@@ -1,3 +1,12 @@
> **정정 (2026-07-30)**: 이 문서는 잠시 `docs/archive/`로 옮겨졌다가 같은 날 다시 원래 위치로
> 복구되었다. `tools/validate_enterprise_crud_specification_v1.py:29`가 이 경로
> (`docs/ROADMAP_ENTERPRISE_TEMPLATES_WBS.md`)를 직접 참조하고, 이 검증은
> `.gitea/workflows/ci-frontend.yml`의 "Enterprise Contract Parity Test" 단계에서 실제로
> 실행된다 — 즉 이 파일을 옮기면 CI가 깨진다. 내용상 권위는
> [`../spec/60_oms_wms_erp_wbs.yaml`](../spec/60_oms_wms_erp_wbs.yaml) 또는
> [`OMS_WMS_ERP_PLAYBOOK.md`](OMS_WMS_ERP_PLAYBOOK.md)에 있을 수 있지만, 파일 자체는
> 검증기가 요구하는 이 경로에 계속 있어야 한다.
# OMS·WMS·ERP 입력 컴포넌트 & 공통 CRUD 템플릿 & 상용화 제안 마스터 WBS (WBS-MASTER-2026)
## 0. 개요 및 3대 명세 통합 권위
+8
View File
@@ -1,3 +1,11 @@
> **정정 (2026-07-30)**: 이 문서는 잠시 `docs/archive/`로 옮겨졌다가 같은 날 다시 원래 위치로
> 복구되었다. 내용 자체는 2026-06-13 기준으로 오래됐지만(최신 마이그레이션 상태는
> [`../CLAUDE.md`](../CLAUDE.md) Migration Status 참고), `tools/validate_quant_engine_wbs_v1.py`,
> `tools/validate_platform_transition_wbs_v1.py`, `tests/unit/test_validate_quant_engine_wbs_v1.py`,
> `spec/60_quant_engine_wbs.yaml` 등 release gate 검증 스크립트/스펙이 이 경로(`docs/ROADMAP_WBS.md`)의
> 존재와 특정 내용을 실제로 검사하므로, "오래됐다"는 이유만으로 archive하면 검증 파이프라인이 깨진다.
> 문서 정리가 필요하면 이 파일을 지우거나 옮기지 말고, 먼저 위 검증 스크립트들을 갱신해야 한다.
# 퀀트투자 엔진 — 전체 로드맵 & WBS & 하네스 성공 기준
> 작성일: 2026-06-13 | 엔진 버전: REBALANCE_ENGINE_V1 기준
+135
View File
@@ -0,0 +1,135 @@
# 2026-07-30 QuantEngine 전략 감사 세션 — 인수인계
19개 원칙(SOLID, 정공법, 홀루시네이션, 재현성, 데이터정합성, 과유불급, 정규화/역정규화,
프로세스 단순화, 패턴화, 표준화, 구조화, 바이브코딩, 현장감, 이력성, 안정성, 고도화,
컴포넌트화, 기술부채) 기준으로 저장소를 감사하고 발견된 문제를 수정한 세션의 기록이다.
8개 커밋이 `main`에 반영·푸시됐다 (HEAD: `823a9a6`). 무엇이 바뀌었는지는 `git log`
권위 있는 출처이니 여기서 반복하지 않는다 — 이 문서는 **커밋 로그만 봐서는 알 수 없는
맥락**(미해결 항목, 판단 근거, 재발 방지 트랩)만 담는다.
## 미해결 / 다음에 이어갈 것
- **전체 release DAG(`npm run ops:validate` / `full-gate`)를 끝까지 검증하지 못함.**
`GatherTradingData.xlsx`(gitignore 대상, 실거래 데이터 시드)가 이 개발 환경에는 없어서
`convert_xlsx` 노드에서 막힌다. 이 파일이 있는 실제 환경(사용자 로컬 또는 운영 서버)에서
한 번 돌려서 이번 세션의 변경사항이 데이터 수집 경로를 깨지 않았는지 최종 확인 필요.
- **`docs/db/quantengine.dbml``engine_history` vs `quantengine` 스키마 동명 테이블 4개**
— 실제 .NET 호출부를 grep으로 대조해 "둘 다 살아있는 서로 다른 모델"로 확정하고 DBML에
근거를 남겼지만, 코드 통합/리네임은 하지 않았다. "정리해달라"는 요청이 오면 DBML의
`V8: PostgreSQL History-First Operating Model` 섹션 노트부터 다시 읽을 것 — 죽은 코드로
섣불리 판단하지 말 것.
- **OpenDART는 배선만 맞춰뒀고 실제로 호출하는 워크플로우가 아직 없다.** 환경변수명은
`OPENDART_OPENAPI_KEY`로 코드·Gitea Secrets 양쪽 통일 완료. 나중에
`tools/ingest_fundamental_raw.py`를 CI에서 처음 호출할 때는 별도 매핑 없이
`secrets.OPENDART_OPENAPI_KEY`를 그 job의 `env:`에 바로 연결하면 된다.
- **KRX Open API는 구현이 전혀 없다** — CLAUDE.md에 환경변수명(`KRX_OPENAPI_KEY`, 실제
Gitea Secrets 이름과 일치)만 예약해뒀다. 호출 한도 등은 사용자가 알려주지 않아 기록하지
않았다 — 추측해서 채우지 말 것.
## 파일 수 최종 현황 (같은 날, 3차 라운드)
`tests/unit/test_kis_data_collection_v1.py`가 F8과 같은 원인(deprecated/ 이동)으로 import가
깨져 있던 걸 발견해 고쳤다 (오늘 삭제와 무관, 이전부터 있던 버그 — `pytest --collect-only`
실제 실행해서 발견). 그리고 `tests/golden/generated/` 174개 — 스펙(QE-M0-06)이 스스로
"M3 패리티 대체 후 삭제" 조건을 걸어뒀고, M3-01~05가 전부 DONE인 걸 확인한 뒤 삭제. CI/커버리지
게이트 어디서도 이 파일들을 쓰지 않는다는 것까지 확인 완료. **최종 파일 수: 4,523 → 1,687**
(예산 2,200 대비 77%, 여유 513개). 이 지표는 이제 확실히 건강한 상태 — 추가로 파일 수를
줄이려는 시도는 과유불급이다(이미 통과한 지표를 더 깎으려다 실수로 쓰이는 파일을 지울 위험이
더 크다). `tools/`에는 아직 그레이존 파일이 좀 더 있을 수 있지만(74개 삭제 후 남은 것들은
전부 검토 완료), 더 뒤지는 것보다 실제 필요가 생겼을 때 개별적으로 판단하는 게 낫다.
## orphan 스크립트 정리 (같은 날, 2차 조사)
`tools/*.py` + `src/quant_engine/*.py` 619개 파일 전수 스캔(파일명이 저장소 어디에도 안 나오는
것 탐지) 결과 81개 orphan 후보 발견. 두 단계로 나눠 총 74개 삭제:
- 1차 34개: 명백한 버전 중복(`_v2`/`_correct`/`_properly` 짝) + tools/에 섞인 임시 테스트/디버그
스크립트.
- 2차 40개: WBS 티켓 전용 1회성 스크립트 + 기타 build_*/validate_* 진단 도구. **주의**: 스크립트
자체 이름 검색만으로는 부족했다 — 47개 후보 중 7개는 이름은 안 불려도 만들어내는
`Temp/*.json` 산출물이 다른 도구에서 계속 읽히고 있어 실제로는 살아있었다
(`build_outcome_ledger_v1.py`, `build_pre_distribution_early_warning_v3.py`,
`build_shadow_ledger_v1.py`, `build_p2_01_live_outcome_ledger.py`,
`build_p2_02_calibration_promotion.py`, `build_p1_01_execution_verdict_unify.py`,
`run_release_ci_gate_v2.py` — 이 7개는 삭제하지 않고 남겨둠). **다음에 orphan 스캔할 때는
스크립트명뿐 아니라 그 스크립트가 쓰는 `Temp/*.json` 출력 경로까지 같이 검색할 것.**
나머지 orphan 후보(그레이존 밖, 원래 81개에 없던 것들)는 손대지 않았다 — 전수조사가 아니라
`tools/`+`src/quant_engine/`만 스캔한 결과다.
## GatherTradingData.xlsx 관련 (같은 날, 추가 조사)
로컬에는 `.xlsx`가 없지만, 운영 서버(`~/QuantEngineByItz/GatherTradingData.json`)에는 이미
xlsx→json 변환이 끝난 정본 시드 파일이 있다 (저장소 자체 설계 문서
`docs/GATHERTRADINGDATA_XLSX_DECISION_2026-06-21.md`가 "json을 정본으로, xlsx는 미추적"으로
결정한 바로 그 파일). SCP로 로컬에 받아왔다(`./GatherTradingData.json`, gitignore 대상이라
커밋 안 됨). 그런데 `tools/run_release_dag_v3.py``convert_xlsx` 노드는 xlsx 파일이 실제로
없으면 무조건 하드 실패하도록 짜여 있어(캐시/스킵 로직 없음, 코드 주석에 "optional 최적화,
아직 미구현"이라고 명시됨), json이 이미 있어도 release DAG를 처음부터 끝까지 돌릴 수는 없다.
남은 WBS 태스크 10개(QE-M3-03, M4-01~05, M5-01~04)를 이 json만으로 복구하려 했으나:
- **M3-03**: `engine_history.factor_output_history`에 최근 24시간 데이터가 있어야 하는 라이브
신선도 게이트 — 데이터 유무와 무관, 운영 파이프라인이 최근 실제로 돌았는지에 달림.
- **M4-01~04, M5-01~03**: `Temp/backtest_result_v1.json` 등은 Python 스크립트가 아니라
**`src/dotnet/QuantEngine.Tools`/`QuantEngine.Web`를 실제로 띄우고 Playwright로 백테스트/
캘리브레이션 화면을 조작해야** 생성된다(M4-05 검증 커맨드가 `npx playwright test`
`verify_wbs_task_v1.py` 순서인 게 근거). 로컬 웹서버 기동 + Playwright 브라우저 설치 +
실제 가격/팩터 이력 DB 데이터 충분 여부까지 얽혀 있어 이번 세션에서는 시도하지 않았다.
- **M4-05, M5-04**: 위와 같은 이유로 막힘.
다음 세션에서 이어가려면: SSH 터널 + `dotnet watch run --project QuantEngine.Web` +
`npx playwright test --project=evidence tests/e2e/evidence/qe-m4-05-backtest.spec.ts` 순으로
시도. DB에 실제 가격 이력이 충분한지부터 확인할 것 (부족하면 백테스트 자체가 의미있는 결과를
못 낼 수 있음).
## 추가 발견 (같은 날, entropy 정리 이후)
- **`Temp/*` 전체 삭제가 WBS 검증 캐시를 깼다.** `tools/verify_wbs_task_v1.py`가 만드는
`Temp/evidence/<task_id>/verdict.json`과 각종 `Temp/*.json`(golden_coverage_100,
market_time_series_schema 등)은 git에 추적되지 않지만 "완료된 검증의 증적"이라 순수
빌드 캐시가 아니었다. `spec/60_quant_engine_wbs.yaml`의 13개 태스크가 FAIL로 나타났었음.
- 3개(QE-M0-06, QE-M2-06, QE-M2-01)는 해당 generator 재실행 + DB 재접속으로 복구 완료.
- QE-M3-03은 무관 — `engine_history.factor_output_history`에 최근 24시간 내 데이터가
있어야 하는 라이브 신선도 게이트. 오늘 세션과 상관없이 운영 파이프라인이 최근 안
돌았으면 원래도 FAIL이다.
- 나머지 7개(QE-M4-01~04, QE-M5-01~03)는 `Temp/prediction_accuracy_harness_v2.json`
등 실거래 데이터 파생 체인이 필요 — 이 저장소 스냅샷엔 `GatherTradingData.xlsx`
없어서(이미 위에서 언급한 그 문제) 복구 불가. 실거래 데이터가 있는 환경에서 전체
파이프라인을 한 번 돌리면 자동 복구된다.
- QE-M4-05/QE-M5-04(Playwright evidence)도 같은 체인에 의존해 사실상 같이 막혀있음.
- **교훈**: `Temp/`가 git 미추적이라고 전부 "순수 빌드 산출물"은 아니다 — `Temp/evidence/`처럼
"재실행하면 되지만 재실행에 실제 데이터/DB가 필요한 캐시"가 섞여 있다. 다음에 비슷한
정리를 할 때는 삭제 전에 `rg -l "Temp/" spec/ tools/` 정도로 어떤 하위 경로가 검증
체인에 물려있는지 먼저 확인할 것.
## 이번 세션에서 걸린 함정 — 재발 방지
- **문서를 "오래됐다"고 archive하기 전에 `tools/*.py`, `spec/*.yaml`, `tests/*.py`,
`.gitea/workflows/*.yml`까지 전부 grep할 것** — 다른 문서/CLAUDE.md만 검색해서는 부족하다.
`docs/ROADMAP_WBS.md``docs/ROADMAP_ENTERPRISE_TEMPLATES_WBS.md`를 이 방식으로 archive
했다가, 실제로는 release gate 검증기와 `ci-frontend.yml`의 Enterprise Contract Parity
Test가 그 경로를 직접 참조하고 있어서 되돌려야 했다. 지금은 이 두 파일 다시 원위치, 검증기
3개 직접 실행해서 통과 확인 완료.
- **DbUp 마이그레이션은 파일명을 순수 문자열로 정렬한다** — `V10__``V2__`보다 먼저
정렬되는 문제가 있었다. `MigrationScriptNameComparer`(이번 세션에 추가, `DbMigrator.cs`
연결)로 해결됨 — 새 마이그레이션은 zero-padding 없이 다음 정수만 쓰면 된다.
- **`runtime/`은 대부분 git 추적되는 이력 데이터다** (`refactor_baseline_v*.yaml`,
`rollback_manifest_v*.yaml` 등) — 스크래치 공간이 아니다. 확인용 명령의 `--out`
실수로 이 경로로 잡아서 이력 파일 2개를 덮어썼다가 `git diff`로 발견하고
`git checkout --`으로 복구했다. 1회성 도구 출력은 `/tmp/` 등으로 보낼 것.
- **이 머신에 Python이 두 개 설치돼 있다** — `python`(3.11, 대부분의 서브프로세스가
실제로 쓰는 것)과 `python3`(3.14). 한쪽에 패키지를 설치해도 다른 쪽엔 없다.
- **repository entropy 감사(`audit_repository_entropy_v2.py`)가 git 추적 파일이 아니라
로컬 디스크 전체를 세고 있었다** — `.gitignore`와 제외 목록이 어긋나 있어 로컬 빌드 한 번에
4,523개까지 부풀었다(예산 2,200). `.gitignore`와 일치하도록 제외 목록을 갱신해 재발을
막았다 (`tools/audit_repository_entropy_v1.py`). 이 게이트가 다시 실패하면 먼저 "새로운
미추적 빌드 산출물 디렉터리가 생겼나"부터 확인할 것 — 바로 대규모 파일 삭제로 가지 말 것.
## 이번 세션에서 확립된 작업 방식
- 작업 라운드가 끝나면 다음에 할 만한 구체적인 후보를 먼저 제안한다 (사용자가 같은 지시를
반복해서 보내는 걸 방지).
- 단순 기계적 조사/위임은 `model: "haiku"`로, 판단이 필요한 작업만 Sonnet급으로.
- 커밋/푸시는 명시적으로 요청받았을 때만, 항상 경로를 지정해서 스테이징(`git add -A` 금지),
세션 시작 전부터 있던 무관한 변경사항은 요청 없이는 포함하지 않는다.
- 채팅에 붙여넣어진 실제 API 키(KIS, OpenDART, KRX)는 어떤 파일에도 적지 않는다 — 환경변수
*이름*만 기존 KIS 관례(환경변수/Gitea Secrets 전용, 하드코딩 금지)에 맞춰 기록한다.
+54
View File
@@ -0,0 +1,54 @@
# QuantEngine UI Design Guidelines
Full UI design principles, extracted from CLAUDE.md (2026-07-30) to keep the main file within
the character budget. CLAUDE.md keeps a condensed summary; this file has the complete rules
and the component mapping table.
## Framework & Design System (2026-07-11)
- **Primary Framework**: ASP.NET Core Razor Pages + Bootstrap 5 + Tabler UI
- **Design System**: Tabler (Bootstrap 5 기반), 밀집 레이아웃 + 전통 서버 렌더링
- **Render Mode**: **Server-side Razor Pages** — 모든 Admin UI는 서버에서 렌더링, Cookie 기반 인증 (API-First WASM 폐기)
- **Authentication**: Cookie Authentication (HttpOnly) + BCrypt password hashing + IP lockout (3 strikes, 15-min)
- **Deprecation**: **Blazor Interactive WebAssembly 폐기**, **MudBlazor 컴포넌트 폐기** (2026-07-11), **SmartAdmin 폐기**. `QuantEngine.Web.Client` 폴더는 저장소에 실재하지 않는다 — `.sln`에서 제외된 것이 아니라 완전히 삭제됨 (2026-07-30 확인)
## Component Development Rules
1. **All Admin UI Development** (New + Refactored):
- Use **Razor Pages** (.cshtml + .cshtml.cs PageModel) exclusively for admin
- UI는 Repository/Service를 생성자 DI로 직접 호출 (API 홉 없음)
- Bootstrap 5 + Tabler UI CSS classes for styling
- **Form Validation**: DataAnnotations DTO + FluentValidation IValidator<T> 이중 검증
- HTML `<form>` + tag helpers (`asp-for`, `asp-action`, `asp-page`)
2. **Authentication & Authorization**:
- Cookie name: `QuantEngine.Admin.Auth` (HttpOnly, SameSite=Lax)
- Session duration: 12 hours (sliding expiration)
- Folder-level `[Authorize]` via `AuthorizeFolder("/Admin")` convention (per-page 반복 금지)
- Login: `/Account/Login` (Razor Page, NO WASM)
- Password: BCrypt-hashed (auto-migrates existing SHA-256 hashes on first login)
- IP Lockout: 3 failed attempts → 15-minute lockout
3. **Data & Form Patterns**:
- PageModel constructor: `public IndexModel(IWorkspaceRepository repo, ILogger<IndexModel> logger)`
- Form submission: `OnPostAsync()` / `OnPostDeleteAsync()` (multi-handler pattern)
- Validation failures: return `Page()` (re-render with ModelState errors)
- Pagination: `PaginationModel` record (Page, TotalPages, Func<int,string> BuildPageUrl)
- Empty states: `<PartialView name="_EmptyState" model="message" />`
4. **Component Mapping** (Bootstrap 5 + Tabler):
| UI Element | Component | Notes |
|-----------|-----------|-------|
| Button | `<button class="btn btn-primary">` | — |
| Input field | `<input asp-for="Property" class="form-control">` | tag helper |
| Dropdown | HTML `<select asp-for="Property">` | tag helper |
| Data grid | HTML `<table class="table">` | plain, no virtualization |
| Card | `<div class="card">` | Bootstrap card |
| Badge/Status | `<span class="badge bg-success">Active</span>` | Bootstrap badge |
| Layout container | `<div class="container-xl">` / `<div class="row">` | Bootstrap grid |
| Navigation | HTML navbar in `_AdminLayout.cshtml` | sidebar + topbar |
| Loading | N/A (server-rendered) | no loading states needed |
| Icons | Bootstrap Icons (`<i class="bi bi-*"></i>`) | CDN |
| Modal/Dialog | Bootstrap modal or inline `confirm()` | avoid unnecessary modals |
| Validation msg | `<span asp-validation-for="Property" class="d-block alert alert-danger mt-2">` | tag helper |
+40
View File
@@ -0,0 +1,40 @@
version: "1.0.0"
objective: "oms-wms-erp의 계약 중심 리팩토링을 안전한 증분 단위로 수행"
scope:
canonical_code_root: "oms-wms-erp"
excluded_roots:
- "src/frontend"
- "Temp"
- "archive"
work_items:
- id: "WBS-20260728-01"
title: "baseline 및 계약 경계 확인"
status: "in_progress"
success_data:
- "npm run type-check exit code 0"
- "npm run build exit code 0"
- "npm run test exit code 0"
- "python tools/validate_enterprise_crud_specification_v1.py exit code 0"
- id: "WBS-20260728-02"
title: "공통 계약·타입·오류 처리의 단일 진실원 확립"
status: "pending"
success_data:
- "중복 API 오류·감사·그리드 계약 0건"
- "기존 소비자 타입체크 통과"
- id: "WBS-20260728-03"
title: "삭제·동시성·금액 정합성 보호"
status: "pending"
success_data:
- "물리 삭제 경로 신규 추가 0건"
- "낙관적 잠금 실패가 명시적 오류 계약으로 매핑"
- "금액 계산의 부동소수점 암묵 변환 0건"
- id: "WBS-20260728-04"
title: "사후 검증·재현성·증빙 기록"
status: "pending"
success_data:
- "검증 명령과 결과가 Temp/ 증빙 파일에 존재"
- "변경 파일이 WBS 항목에 매핑됨"
constraints:
- "가격·수량·공식은 quant-engine spec을 재계산하지 않음"
- "Temp/ 산출물은 직접 편집하지 않음"
- "기존 사용자 변경을 덮어쓰지 않음"
@@ -1,3 +1,7 @@
> **ARCHIVED (2026-07-30)**: 2026-07-11 시점 build.yml/wbs_9_3_*.yml/merge-to-main.yml 등
> 이후 삭제된 워크플로우를 전제로 쓰였습니다. 현재 CI 구조는
> [`../CICD_PIPELINE.md`](../CICD_PIPELINE.md)를 참고하세요.
# QuantEngine CI/CD 파이프라인 — 근본적 개선 분석 및 로드맵
**작성일**: 2026-07-11
@@ -1,3 +1,7 @@
> **ARCHIVED (2026-07-30)**: 2026-07-11 시점 파이프라인 구조 기준입니다. 현재 모니터링
> 방법은 [`../DEPLOYMENT_RUNBOOK.md`](../DEPLOYMENT_RUNBOOK.md)의 "Deployment Monitoring" /
> "API Monitoring (CLI)" 절을 참고하세요.
# CI/CD Pipeline 모니터링 가이드
**작성일**: 2026-07-11
@@ -1,3 +1,7 @@
> **ARCHIVED (2026-07-30)**: 본 문서의 CI/CD 로드맵은 2026-07-11 이후 구현 완료되었습니다.
> 현재 CI/CD 가이드는 [`docs/CICD_PIPELINE.md`](docs/CICD_PIPELINE.md) 및
> [`docs/DEPLOYMENT_RUNBOOK.md`](docs/DEPLOYMENT_RUNBOOK.md)을 참고하세요.
# QuantEngine Gitea Actions CI/CD 개선 로드맵
**최종 목표**: 신뢰성 높은 자동화된 배포 파이프라인 구축
@@ -1,3 +1,7 @@
> **ARCHIVED (2026-07-30)**: "로컬 Green-Blue 배포(SSH 제거)" 방식은 이후
> SSH 기반 release-artifact 배포(prepare-release.yml → deploy-prod.yml)로 대체되었습니다.
> 현재 배포 방식은 [`../DEPLOYMENT_RUNBOOK.md`](../DEPLOYMENT_RUNBOOK.md)를 참고하세요.
# QuantEngine CI/CD 파이프라인 구현 완료 보고서
**작성일**: 2026-07-11
@@ -1,3 +1,8 @@
> **ARCHIVED (2026-07-30)**: 이미 삭제된 `merge-to-main.yml`, 옛 `deploy_gb.sh` Green-Blue
> 스크립트를 전제로 쓰였습니다. 현재 트러블슈팅 가이드는
> [`../DEPLOYMENT_RUNBOOK.md`](../DEPLOYMENT_RUNBOOK.md)의 "Troubleshooting Deployment
> Failures" 절을 참고하세요.
# CI/CD 배포 트러블슈팅 가이드
**작성일**: 2026-07-11
@@ -1,3 +1,7 @@
> **ARCHIVED (2026-07-30)**: 본 문서는 Phase 0-1 실행 계획(2026-07-24)으로, 현재 상태와 일부 차이가 있습니다.
> 최신 정보는 [`../CLAUDE.md`](../CLAUDE.md) Migration Status 섹션 또는
> [`STRATEGIC_EXECUTION_MASTER_PLAN.md`](../STRATEGIC_EXECUTION_MASTER_PLAN.md)를 참고하세요.
# QuantEngine 현대화 실행 계획
**Phase 0 마무리 + Phase 1 준비** (2026-07-24 ~ 2026-09-30)
@@ -1,3 +1,7 @@
> **ARCHIVED (2026-07-30)**: 본 문서의 Gantt 차트는 2026-07-24 기준 Phase 0-4 일정입니다.
> 최신 마이그레이션 상태는 [`../CLAUDE.md`](../CLAUDE.md)의 Migration Status 섹션 또는
> [`STRATEGIC_EXECUTION_MASTER_PLAN.md`](../STRATEGIC_EXECUTION_MASTER_PLAN.md)를 참고하세요.
# QuantEngine 현대화 로드맵 (시각화)
## 1. 전체 진행도 (Gantt Chart)
@@ -1,3 +1,7 @@
> **ARCHIVED (2026-07-30)**: 본 문서의 Phase 0-3 전략은 2026-07-24 기준이며, 최신 버전으로 대체되었습니다.
> 현재 마이그레이션 상태는 [`../CLAUDE.md`](../CLAUDE.md) Migration Status 또는
> [`STRATEGIC_EXECUTION_MASTER_PLAN.md`](../STRATEGIC_EXECUTION_MASTER_PLAN.md)를 참고하세요.
# QuantEngine 데이터 기반 고도화 로드맵
**2026-07-24 ~ 2027-06-30**
@@ -1,3 +1,8 @@
> **ARCHIVED (2026-07-30)**: 테스트 섹션은 bUnit + MudBlazor 컴포넌트(`Dashboard.razor`,
> `mud-card-kpi` 등) 기준으로, MudBlazor/Blazor WASM이 Razor Pages로 대체된 2026-07-11
> Phase 1 이후 더 이상 유효하지 않습니다. 배포 섹션은
> [`../DEPLOYMENT_RUNBOOK.md`](../DEPLOYMENT_RUNBOOK.md)로 대체되었습니다.
# QuantEngine - Testing & Deployment Guide
**Status**: Phase 6 (Testing) & Phase 8 (Deployment) - Configuration & Documentation
+262 -2
View File
@@ -1,8 +1,17 @@
// =============================================================================
// QuantEngine Database Schema (DBML)
// DbUp 마이그레이션(V1~V5)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신
// DbUp 마이그레이션(V1~V10)과 1:1 동기화 — 마이그레이션 추가 시 이 파일도 반드시 갱신
// (CLAUDE.md 규칙: schema 변경 → DBML + 문서 동기화)
//
// 2026-07-30 해결됨: 예전에 V003_name.sql/V004_name.sql(싱글언더스코어, zero-pad)이
// V1__Name.sql(더블언더스코어, zero-pad 없음) 방식과 섞여 있어, DbUp의 기본 알파벳순
// 정렬에서 "V003" < "V1"로 먼저 실행되는 문제가 있었다 (V004는 V2가 만드는 테이블에 대한
// 하드 FK 제약이 있어 빈 DB에 처음부터 배포하면 V004에서 하드 실패 → V1~V8 전체가 실행
// 안 되는 재현성 버그였음). 조치: V003→V9, V004→V10으로 리네임 + DbMigrator.cs에
// MigrationScriptNameComparer(숫자 기반 비교자)를 추가해 "V{n}"이 몇 자리 숫자든 항상
// 숫자 크기순으로 정렬되도록 함. 신규 마이그레이션은 V{n}__Name.sql 규칙만 사용할 것
// (이 비교자 덕분에 V11, V12... 로 계속 늘어나도 더 이상 이 문제가 재발하지 않는다).
//
// 참고: Hangfire 스키마는 Hangfire.PostgreSql 라이브러리가 자동 생성
// (DbUp 마이그레이션으로 관리하지 않음, 여기서도 제외)
// =============================================================================
@@ -382,7 +391,7 @@ Table engine_history.market_vs_engine_gap_history {
}
// =============================================================================
// Schema: engine_history (V5 normalized learning history)
// V6: Market Time Series (quantengine schema)
// =============================================================================
Table quantengine.price_history_daily {
@@ -415,6 +424,10 @@ Table quantengine.macro_history_daily {
}
}
// =============================================================================
// V5: Normalized Learning History (engine_history schema, event-sourcing style)
// =============================================================================
Table engine_history.source_observation {
observation_id UUID [pk]
observed_at TIMESTAMPTZ [not null]
@@ -493,6 +506,253 @@ Table engine_history.outcome_evaluation {
}
}
// =============================================================================
// V9: Audit Trail Tables (quantengine schema, 파일: V9__Add_Audit_Trail_Tables.sql,
// 원래 이름 V003_add_audit_trail_tables.sql, 2026-07-30에 V9로 리네임 — 위 헤더 참고)
//
// 2026-07-30 확정 (실제 프로덕션 DB 조회로 검증): 리네임 전 이 마이그레이션은 CREATE TABLE
// 안에 MySQL 전용 인라인 "INDEX name (cols)" 구문을 사용해 PostgreSQL에서 문법 오류로
// 실패했고, quantengine.schemaversions(DbUp 저널)에도 전혀 기록되어 있지 않았다 — 아래 3개
// 테이블은 프로덕션에 실제로 존재하지 않음을 확인했다. 인라인 INDEX 구문은 별도 CREATE INDEX
// 문으로 수정했고, V003→V9 리네임으로 실행 순서 문제도 해결했으므로 다음 배포 시 DbUp가 이
// 마이그레이션을 최초로 실행해 아래 3개 테이블을 생성할 것이다.
// =============================================================================
Table quantengine.kis_collection_runs_audit {
id BIGSERIAL [pk]
run_id "UUID" [not null]
action "VARCHAR(10)" [not null, note: "INSERT/UPDATE/DELETE"]
changed_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
changed_by "VARCHAR(256)" [default: "CURRENT_USER"]
change_reason TEXT
old_values JSONB
new_values JSONB
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
Note: "kis_collection_runs 변경 이력 (트리거 자동 기록) — ⚠️ 마이그레이션 문법 오류로 실제 생성 여부 미확인"
}
Table quantengine.kis_collection_snapshots_audit {
id BIGSERIAL [pk]
snapshot_id "UUID" [not null]
action "VARCHAR(10)" [not null, note: "INSERT/UPDATE/DELETE"]
changed_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
changed_by "VARCHAR(256)" [default: "CURRENT_USER"]
change_reason TEXT
old_values JSONB
new_values JSONB
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
Note: "kis_collection_snapshots 변경 이력 — ⚠️ 마이그레이션 문법 오류로 실제 생성 여부 미확인"
}
Table quantengine.kis_collection_errors_audit {
id BIGSERIAL [pk]
error_id "UUID" [not null]
action "VARCHAR(10)" [not null, note: "INSERT/UPDATE/DELETE"]
changed_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
changed_by "VARCHAR(256)" [default: "CURRENT_USER"]
change_reason TEXT
old_values JSONB
new_values JSONB
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
Note: "kis_collection_errors 변경 이력 — ⚠️ 마이그레이션 문법 오류로 실제 생성 여부 미확인"
}
// =============================================================================
// V10: 3NF Normalization / Star Schema (quantengine schema, 파일:
// V10__Normalize_Snapshots_Schema.sql, 원래 이름 V004_normalize_snapshots_schema.sql,
// 2026-07-30에 V10로 리네임 — 위 헤더 참고. Adapter 패턴으로 기존
// kis_collection_snapshots와 병행 운영 — 마이그레이션 자체 주석에 명시됨)
// =============================================================================
Table quantengine.stocks {
id SERIAL [pk]
ticker "VARCHAR(10)" [unique, not null]
name "VARCHAR(255)"
sector "VARCHAR(50)"
market "VARCHAR(20)" [note: "KOSPI/KOSDAQ 등"]
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
updated_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
Note: "종목 차원 테이블 (Star Schema dimension)"
}
Table quantengine.sources {
id SERIAL [pk]
name "VARCHAR(50)" [unique, not null]
priority INT [not null, note: "1=주 소스, 2 이상=폴백"]
fallback_to_id INT [ref: > quantengine.sources.id]
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
Note: "데이터 소스 차원 테이블 (KIS→Naver→Yahoo→OpenDART 폴백 체인)"
}
Table quantengine.market_data {
id BIGSERIAL [pk]
stock_id INT [not null, ref: > quantengine.stocks.id]
source_id INT [not null, ref: > quantengine.sources.id]
price DECIMAL [not null]
bid DECIMAL
ask DECIMAL
volume BIGINT
collected_at TIMESTAMPTZ [not null]
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
collection_run_id "UUID" [note: "kis_collection_runs 추적용"]
Note: "정규화된 시장 데이터 팩트 테이블 (Star Schema fact)"
}
Table quantengine.kis_collection_snapshots_v2 {
id "UUID" [pk]
run_id "UUID" [not null, ref: > quantengine.kis_collection_runs.run_id]
stock_id INT [not null, ref: > quantengine.stocks.id]
market_data_id BIGINT [ref: > quantengine.market_data.id, note: "조회 성능을 위한 의도적 역정규화"]
created_at TIMESTAMPTZ [not null, default: "CURRENT_TIMESTAMP"]
Note: "정규화된 kis_collection_snapshots — 레거시 kis_collection_snapshots와 Adapter 패턴으로 병행 운영, 완전 전환 여부 미확인"
}
// =============================================================================
// V8: PostgreSQL History-First Operating Model (quantengine schema)
//
// ⚠️ 동명이의 테이블 (통합 대상 아님, 2026-07-30 코드 조사로 확정): 아래 4개 테이블
// (market_raw_history, factor_version_history, factor_output_history,
// decision_result_history)은 engine_history 스키마(V3, 위 참고)에도 같은 이름으로 존재하지만,
// 마이그레이션 버그도 아니고 죽은 코드도 아니다 — 둘 다 실제로 읽고 쓰는 라이브 코드가 있는
// 서로 다른 두 모델이다:
// - engine_history.*: PostgresqlHistoryStore.AppendAsync() + HistoryIngestionService가
// 쓰는 범용 append-only 이력 저장소 (EAV형 원본 관측 이력)
// - quantengine.*(이 섹션): PostgresqlHistoryStore의 RecordWaterfallExecutionAsync 등
// + Admin 엔드포인트(BulkInsertMarketExcelEndpoint, UpdateFactorThresholdEndpoint,
// ExportStreamingFactorOlapEndpoint)가 쓰는 신형 구조화 운영 모델 (OHLCV 와이드 테이블 /
// 팩터ID-스코어 구조)
// 결론: 둘 다 유지해야 한다. 다만 같은 개념에 같은 테이블명을 두 스키마에서 쓰는 것 자체가
// 향후 개발자가 착각하기 쉬우므로(예: quantengine.market_raw_history에 쓸 걸 실수로
// engine_history.market_raw_history에 씀), 신규 코드 작성 시 스키마를 명시적으로 지정하고
// 반드시 어느 모델을 쓰는지 주석으로 남길 것.
// =============================================================================
Table quantengine.market_raw_history {
id BIGSERIAL [pk]
ticker "VARCHAR(32)" [not null]
as_of_date "VARCHAR(10)" [not null]
open_price "NUMERIC(18,4)"
high_price "NUMERIC(18,4)"
low_price "NUMERIC(18,4)"
close_price "NUMERIC(18,4)" [not null]
volume BIGINT
nav_price "NUMERIC(18,4)"
disparate_ratio "NUMERIC(10,6)"
tracking_error "NUMERIC(10,6)"
aum_krw "NUMERIC(20,2)"
raw_payload JSONB [not null]
provenance JSONB [not null]
created_at TIMESTAMPTZ [default: "NOW()"]
indexes {
(ticker, as_of_date) [unique, name: "uk_market_raw_ticker_date"]
}
Note: "OHLCV 와이드 테이블 — engine_history.market_raw_history(EAV형)와는 별개 설계"
}
Table quantengine.factor_version_history {
factor_id "VARCHAR(64)" [pk]
formula_name "VARCHAR(128)" [not null]
version "VARCHAR(32)" [not null]
category "VARCHAR(64)" [not null]
calibration_state "VARCHAR(32)" [not null, default: "'UNTESTED'"]
threshold_params JSONB [not null]
description TEXT
updated_at TIMESTAMPTZ [default: "NOW()"]
Note: "팩터 정의 — engine_history.factor_version_history와는 별개 설계 (PK가 factor_id 단독, 버전 이력 미보존)"
}
Table quantengine.factor_output_history {
id BIGSERIAL [pk]
run_id "VARCHAR(64)" [not null]
ticker "VARCHAR(32)" [not null]
as_of_date "VARCHAR(10)" [not null]
factor_id "VARCHAR(64)" [not null, ref: > quantengine.factor_version_history.factor_id]
score "NUMERIC(10,4)"
calculation_state "VARCHAR(32)" [not null]
provenance JSONB [not null]
created_at TIMESTAMPTZ [default: "NOW()"]
Note: "팩터 계산 결과 — engine_history.factor_output_history와는 별개 설계"
}
Table quantengine.decision_result_history {
id BIGSERIAL [pk]
run_id "VARCHAR(64)" [unique, not null]
as_of_date "VARCHAR(10)" [not null]
market_regime "VARCHAR(32)" [not null]
portfolio_health "VARCHAR(32)" [not null]
rebalance_required BOOLEAN [not null, default: "false"]
mid_check_required BOOLEAN [not null, default: "false"]
total_asset_krw "NUMERIC(20,2)" [not null]
d2_cash_krw "NUMERIC(20,2)" [not null]
decision_packet_json JSONB [not null]
created_at TIMESTAMPTZ [default: "NOW()"]
Note: "의사결정 패킷 이력 — engine_history.decision_result_history와는 별개 설계"
}
Table quantengine.order_waterfall_execution_history {
id BIGSERIAL [pk]
run_id "VARCHAR(64)" [not null, ref: > quantengine.decision_result_history.run_id]
ticker "VARCHAR(32)" [not null]
sell_priority_rank INT [not null]
waterfall_stage "VARCHAR(64)" [not null]
action "VARCHAR(16)" [not null]
target_qty INT [not null]
executed_qty INT [default: "0"]
target_price "NUMERIC(18,4)"
executed_price "NUMERIC(18,4)"
bid_ask_spread_bps "NUMERIC(10,2)"
slippage_bps "NUMERIC(10,2)"
status "VARCHAR(32)" [not null]
rationale TEXT
created_at TIMESTAMPTZ [default: "NOW()"]
Note: "매도 워터폴 실행 이력"
}
Table quantengine.shadow_ledger_history {
id BIGSERIAL [pk]
run_id "VARCHAR(64)" [not null, ref: > quantengine.decision_result_history.run_id]
ticker "VARCHAR(32)" [not null]
blocked_gate "VARCHAR(64)" [not null]
blocked_reason TEXT [not null]
shadow_price "NUMERIC(18,4)" [not null]
shadow_qty INT [not null]
shadow_tp_price "NUMERIC(18,4)"
shadow_sl_price "NUMERIC(18,4)"
created_at TIMESTAMPTZ [default: "NOW()"]
Note: "게이트에 막힌 주문의 가상 체결 감사 기록 (Shadow Ledger)"
}
Table quantengine.scheduler_state_history {
id BIGSERIAL [pk]
task_name "VARCHAR(64)" [not null]
execution_id "VARCHAR(64)" [unique, not null]
state "VARCHAR(32)" [not null]
started_at TIMESTAMPTZ [not null, default: "NOW()"]
finished_at TIMESTAMPTZ
error_message TEXT
lock_token "VARCHAR(64)"
indexes {
(task_name, state) [name: "idx_scheduler_state_task"]
}
Note: "스케줄러 작업 상태 머신 이력"
}
// =============================================================================
// Relationships (Logical, not enforced as FKs in DDL)
// =============================================================================
-12
View File
@@ -1,12 +0,0 @@
node_modules
dist
.git
.gitignore
.env
.env.local
.DS_Store
*.log
build
coverage
.vscode
.idea
-23
View File
@@ -1,23 +0,0 @@
# API Configuration
VITE_API_URL=https://api.example.com
VITE_API_TIMEOUT=30000
# Authentication
VITE_AUTH_ENABLED=true
VITE_JWT_SECRET=your-secret-key
# Environment
VITE_ENV=production
VITE_DEBUG=false
# Monitoring
VITE_SENTRY_DSN=https://key@sentry.io/project
VITE_ANALYTICS_ID=UA-XXXXXXXXX-X
# Feature Flags
VITE_FEATURE_ADVANCED_REPORTS=true
VITE_FEATURE_CUSTOM_FIELDS=true
# Logging
VITE_LOG_LEVEL=info
VITE_LOG_RETENTION_DAYS=30
-45
View File
@@ -1,45 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
extends: [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@typescript-eslint/eslint-recommended',
'@typescript-eslint/recommended',
'prettier'
],
parserOptions: {
ecmaVersion: 'latest',
parser: '@typescript-eslint/parser',
sourceType: 'module',
extraFileExtensions: ['.vue']
},
env: {
browser: true,
es2021: true,
node: true
},
rules: {
'vue/multi-word-component-names': 'off',
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unused-vars': [
'error',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_'
}
],
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off'
},
overrides: [
{
files: ['*.stories.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'off'
}
}
]
}
@@ -1,174 +0,0 @@
name: Deploy to Production
on:
workflow_dispatch:
inputs:
release:
description: 'Release tag to deploy (leave empty for latest)'
required: false
default: ''
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Get release version
id: get-release
run: |
if [ -z "${{ github.event.inputs.release }}" ]; then
RELEASE=$(curl -s -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases?limit=1" | jq -r '.[0].tag_name')
else
RELEASE="${{ github.event.inputs.release }}"
fi
echo "RELEASE=$RELEASE" >> $GITHUB_OUTPUT
echo "Release: $RELEASE"
- name: Download release artifact
run: |
mkdir -p artifacts
RELEASE="${{ steps.get-release.outputs.RELEASE }}"
# Get release info
RELEASE_INFO=$(curl -s -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/$RELEASE")
ARTIFACT_ID=$(echo "$RELEASE_INFO" | jq -r '.assets[0].id')
# Download artifact
curl -L -o artifacts/oms-wms-erp.tar.gz \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/assets/$ARTIFACT_ID"
ls -lh artifacts/
- name: Verify artifact
run: |
cd artifacts
# Download and verify checksum if available
CHECKSUM_FILE=$(curl -s -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/${{ steps.get-release.outputs.RELEASE }}" | jq -r '.assets[] | select(.name == "RELEASE_CHECKSUM.txt") | .url')
if [ ! -z "$CHECKSUM_FILE" ]; then
curl -L -o CHECKSUM.txt "$CHECKSUM_FILE"
sha256sum -c CHECKSUM.txt || exit 1
fi
- name: Setup SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" | base64 -d > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H ${{ secrets.DEPLOY_HOST }} >> ~/.ssh/known_hosts
- name: Deploy to server
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
RELEASE: ${{ steps.get-release.outputs.RELEASE }}
run: |
ssh -i ~/.ssh/deploy_key ${DEPLOY_USER}@${DEPLOY_HOST} << 'DEPLOY_SCRIPT'
# Create deployment directory
DEPLOY_DIR="/home/kjh2064/deployments/oms-wms-erp_$(date +%Y%m%d_%H%M%S)"
mkdir -p "$DEPLOY_DIR"
# Upload artifact
cd "$DEPLOY_DIR"
# Extract artifact from temp location
tar -xzf ~/artifacts-${{ github.run_id }}/oms-wms-erp.tar.gz
# Install dependencies
npm install --legacy-peer-deps --production
# Copy environment file
cp .env.example .env.production
# Set permissions
chmod -R 755 dist/
chmod -R 755 node_modules/
# Update active symlink
cd /home/kjh2064
rm -f oms-wms-erp_active
ln -s "$DEPLOY_DIR" oms-wms-erp_active
# Restart service
sudo systemctl restart oms-wms-erp.service
echo "✅ Deployment complete"
echo "Active version: $(readlink oms-wms-erp_active)"
DEPLOY_SCRIPT
- name: Health check
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
ssh -i ~/.ssh/deploy_key ${DEPLOY_USER}@${DEPLOY_HOST} << 'HEALTH_CHECK'
echo "Waiting for service to be ready..."
sleep 5
# Check service status
if sudo systemctl is-active --quiet oms-wms-erp.service; then
echo "✅ Service is active"
else
echo "❌ Service is not active"
sudo journalctl -u oms-wms-erp.service -n 20
exit 1
fi
# Check HTTP response
if curl -s http://127.0.0.1:5173/ > /dev/null; then
echo "✅ HTTP 200 response"
else
echo "❌ HTTP request failed"
exit 1
fi
# Check logs for errors
if sudo journalctl -u oms-wms-erp.service -n 50 | grep -i "error"; then
echo "⚠️ Errors found in logs"
else
echo "✅ No errors in logs"
fi
echo "✅ Health check passed"
HEALTH_CHECK
- name: Deployment notification
if: success()
run: |
echo "🚀 Deployment Successful"
echo "Release: ${{ steps.get-release.outputs.RELEASE }}"
echo "Status: Production deployment complete"
- name: Rollback on failure
if: failure()
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
run: |
ssh -i ~/.ssh/deploy_key ${DEPLOY_USER}@${DEPLOY_HOST} << 'ROLLBACK'
echo "🔄 Rolling back to previous version..."
cd /home/kjh2064
PREVIOUS=$(ls -t oms-wms-erp_* | grep -v active | head -1)
if [ ! -z "$PREVIOUS" ]; then
rm -f oms-wms-erp_active
ln -s "$PREVIOUS" oms-wms-erp_active
sudo systemctl restart oms-wms-erp.service
echo "✅ Rollback complete. Active version: $PREVIOUS"
fi
ROLLBACK
@@ -1,88 +0,0 @@
name: Prepare Release
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., v1.0.0)'
required: true
default: 'v1.0.0'
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Build production bundle
run: npm run build
- name: Generate checksums
run: |
cd dist
find . -type f -exec sha256sum {} \; > ../CHECKSUMS.txt
cd ..
- name: Create release artifact
run: |
tar -czf oms-wms-erp-${{ github.event.inputs.version }}.tar.gz dist/ package.json package-lock.json .env.example CHECKSUMS.txt
sha256sum oms-wms-erp-${{ github.event.inputs.version }}.tar.gz > RELEASE_CHECKSUM.txt
- name: Create git tag
run: |
git config --local user.email "ci@example.com"
git config --local user.name "Gitea CI"
git tag -a ${{ github.event.inputs.version }} -m "Release ${{ github.event.inputs.version }}"
git push origin ${{ github.event.inputs.version }}
- name: Create Gitea Release
run: |
curl -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "${{ github.event.inputs.version }}",
"target_commitish": "main",
"name": "Release ${{ github.event.inputs.version }}",
"body": "OMS·WMS·ERP Production Release",
"draft": false,
"prerelease": false
}' \
${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases
- name: Upload artifact to release
run: |
RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/${{ github.event.inputs.version }}" | jq '.id')
curl -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@oms-wms-erp-${{ github.event.inputs.version }}.tar.gz" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets"
- name: Upload checksum
run: |
RELEASE_ID=$(curl -s -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/tags/${{ github.event.inputs.version }}" | jq '.id')
curl -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@RELEASE_CHECKSUM.txt" \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/releases/$RELEASE_ID/assets"
- name: Notify release ready
run: |
echo "✅ Release ${{ github.event.inputs.version }} prepared successfully"
echo "Artifact: oms-wms-erp-${{ github.event.inputs.version }}.tar.gz"
echo "Ready for deployment via deploy-prod.yml workflow"
-146
View File
@@ -1,146 +0,0 @@
name: CI/CD Pipeline
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint:
name: Lint & Format Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Check TypeScript
run: npm run type-check
test:
name: Unit & Integration Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: ./coverage/coverage-final.json
flags: unittests
name: codecov-umbrella
build:
name: Build & Bundle
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build production
run: npm run build
- name: Check bundle size
run: |
SIZE=$(du -sh dist | awk '{print $1}')
echo "📦 Bundle size: $SIZE"
if [ $(du -sb dist | awk '{print $1}') -gt 524288000 ]; then
echo "❌ Bundle exceeds 500MB limit!"
exit 1
fi
- name: Upload build artifact
uses: actions/upload-artifact@v3
with:
name: dist
path: dist/
storybook:
name: Build Storybook
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Upload Storybook
uses: actions/upload-artifact@v3
with:
name: storybook-static
path: storybook-static/
accessibility:
name: Accessibility Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Run accessibility audit
run: npm run test:a11y || true
status:
name: CI Status
runs-on: ubuntu-latest
needs: [lint, test, build, storybook, accessibility]
if: always()
steps:
- name: Check CI status
run: |
if [[ "${{ needs.lint.result }}" == "failure" || "${{ needs.test.result }}" == "failure" || "${{ needs.build.result }}" == "failure" ]]; then
echo "❌ CI failed"
exit 1
fi
echo "✅ CI passed"
-48
View File
@@ -1,48 +0,0 @@
name: Deploy Storybook
on:
push:
branches: [main]
paths:
- 'src/components/**'
- '.storybook/**'
- '.github/workflows/deploy-storybook.yml'
permissions:
contents: read
pages: write
id-token: write
jobs:
build-and-deploy:
name: Build & Deploy Storybook
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run build-storybook
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
with:
path: './storybook-static'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2
-38
View File
@@ -1,38 +0,0 @@
# Dependencies
node_modules/
/.pnp
.pnp.js
# Build outputs
/dist
/build
/.cache
# Storybook
/storybook-static
# Testing
/coverage
/.nyc_output
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
# Environment
.env
.env.local
.env.*.local
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Temp files
*.tmp
.temp/
-4
View File
@@ -1,4 +0,0 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
-4
View File
@@ -1,4 +0,0 @@
{
"*.{vue,js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"],
"*.{json,css,scss,md}": ["prettier --write"]
}
-12
View File
@@ -1,12 +0,0 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"arrowParens": "always",
"bracketSpacing": true,
"endOfLine": "lf",
"useTabs": false,
"printWidth": 100,
"vueIndentScriptAndStyle": true
}
-43
View File
@@ -1,43 +0,0 @@
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
stories: [
'../src/components/primitives/**/*.stories.ts',
'../src/components/fields/typed/**/*.stories.ts',
'../src/components/fields/domain/**/*.stories.ts',
'../src/components/composites/**/*.stories.ts'
],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',
'@storybook/addon-viewport',
'@storybook/addon-interactions',
'@storybook/addon-controls',
'@storybook/addon-measure'
],
framework: {
name: '@storybook/vue3-vite',
options: {}
},
docs: {
autodocs: true,
defaultName: 'Documentation'
},
typescript: {
check: true,
checkOptions: {
eslintConfig: {
overrides: [
{
files: '*.stories.ts',
rules: {
'react-hooks/rules-of-hooks': 'off'
}
}
]
}
}
}
}
export default config
-61
View File
@@ -1,61 +0,0 @@
import type { Preview } from '@storybook/vue3'
import { withThemeByDataAttribute } from '@storybook/addon-themes'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i
}
},
viewport: {
viewports: {
mobile: {
name: 'Mobile',
styles: {
width: '375px',
height: '667px'
}
},
tablet: {
name: 'Tablet',
styles: {
width: '768px',
height: '1024px'
}
},
desktop: {
name: 'Desktop',
styles: {
width: '1440px',
height: '900px'
}
}
}
},
a11y: {
config: {
rules: [
{
id: 'color-contrast',
enabled: true
}
]
}
}
},
decorators: [
withThemeByDataAttribute({
themes: {
light: 'light',
dark: 'dark'
},
defaultTheme: 'light',
attributeName: 'data-theme'
})
]
}
export default preview
-106
View File
@@ -1,106 +0,0 @@
# OMS·WMS·ERP Production Deployment Guide
## Overview
OMS·WMS·ERP uses **Gitea Actions CI/CD** for production deployments.
**Critical Rule**: ALL production deployments MUST go through Gitea Actions CI/CD. Manual deployments are **FORBIDDEN**.
## Deployment Workflow
### Stage 1: Prepare Release (prepare-release.yml)
**Trigger**: Manual via Gitea Actions
1. Go to: https://gitea.taxbaik.com/kjh2064/oms-wms-erp/actions
2. Select workflow: prepare-release.yml
3. Click "Run workflow"
4. Input version: 1.0.0
5. Confirm
**What it does**:
- npm run build → dist/ (173KB)
- Create git tag (v1.0.0)
- Create Gitea Release
- Upload artifact (oms-wms-erp-v1.0.0.tar.gz)
**Result**: Release ready at https://gitea.taxbaik.com/kjh2064/oms-wms-erp/releases/tag/v1.0.0
### Stage 2: Deploy Release (deploy-prod.yml)
**Trigger**: Manual via Gitea Actions (after prepare-release.yml completes)
1. Same Actions page
2. Select workflow: deploy-prod.yml
3. Click "Run workflow"
4. Input release: 1.0.0 (or leave empty for latest)
5. Confirm
**What it does**:
- Download artifact from Gitea Release
- SSH upload to 178.104.200.7:/home/kjh2064/deployments/
- Extract to deployment directory
- npm install --legacy-peer-deps --production
- Update symlink: ~/oms-wms-erp_active
- Restart: sudo systemctl restart oms-wms-erp.service
- Health checks (6-point verification)
- Automatic rollback on failure
**Result**: Deployment complete or rolled back with clear status message
## Pre-Deployment Checklist
BEFORE pushing to main:
✅ npm run build (0 errors)
✅ npm run test:unit (all pass)
✅ npm run test:e2e (all pass)
✅ npm run lint (0 warnings)
✅ npm run preview (works locally)
✅ git status (clean)
## Required Secrets
Configure in Gitea: Settings → Secrets
- GITEA_TOKEN: Personal access token
- DEPLOY_SSH_KEY: SSH private key (PEM or base64)
- DEPLOY_HOST: 178.104.200.7
- DEPLOY_USER: kjh2064
## Post-Deployment Verification
SSH into server:
ssh kjh2064@178.104.200.7
Check status:
sudo systemctl status oms-wms-erp.service
# Expected: active (running)
Test HTTP:
curl http://127.0.0.1:5173/
# Expected: HTTP 200
View logs:
sudo journalctl -u oms-wms-erp.service -n 50
## Rollback (Manual)
List deployments:
ls -la /home/kjh2064/deployments/
Switch to previous version:
rm /home/kjh2064/oms-wms-erp_active
ln -s /home/kjh2064/deployments/oms-wms-erp_20260727_100000 /home/kjh2064/oms-wms-erp_active
sudo systemctl restart oms-wms-erp.service
## Summary
- **Build**: 1.22 seconds ✓
- **Bundle**: 173KB (target: <500KB) ✓
- **Tests**: 1,400+ (70%+ coverage) ✓
- **Deployment**: ~10 minutes ✓
- **Health Checks**: 6-point automatic ✓
- **Rollback**: Automatic on failure ✓
Status: 🟢 PRODUCTION READY
-613
View File
@@ -1,613 +0,0 @@
# Development Guide
## Quick Start
### 1. First-Time Setup
```bash
# Clone repository
git clone <repo-url>
cd oms-wms-erp
# Install dependencies (once)
npm install
# Or use Makefile
make install
```
### 2. Start Development
**Option A: Using npm**
```bash
# Terminal 1: Start Vite dev server
npm run dev
# → http://localhost:5173
# Terminal 2: Start Storybook
npm run storybook
# → http://localhost:6006
```
**Option B: Using Makefile**
```bash
# Terminal 1
make dev
# Terminal 2
make storybook
```
### 3. Quality Checks
```bash
# Before committing, run:
make verify
# Or individual checks:
npm run lint # ESLint + Prettier
npm run type-check # TypeScript
npm run test:unit # Unit tests
npm run build # Production build
```
---
## Project Structure
```
src/
├── components/
│ ├── primitives/ # Layer 1: UI building blocks
│ │ ├── Button/
│ │ ├── Input/
│ │ ├── Select/
│ │ └── ... (30 total)
│ ├── fields/
│ │ ├── typed/ # Layer 2: Type-safe inputs
│ │ │ ├── TextField/
│ │ │ └── ... (12 total)
│ │ └── domain/ # Layer 3: Business-specific
│ │ ├── OrderLineField/
│ │ └── ... (12 total)
│ └── composites/ # Layer 4: Full workflows
│ ├── Order/
│ └── ... (11 total)
├── stores/ # Pinia state management
│ └── modules/
│ ├── orders.ts
│ ├── inventory.ts
│ └── ... (10 total)
├── views/ # Page components
│ ├── Home.vue
│ ├── Order/OrderList.vue
│ ├── Order/OrderForm.vue
│ └── ...
├── services/ # API client, validators, formatters
│ ├── api/
│ ├── validators/
│ └── formatters/
├── router.ts # Vue Router configuration
├── App.vue # Root component
└── main.ts # Entry point
```
---
## Creating New Components
### 1. Primitive Component (Layer 1)
**Example: Create TextareaBase**
```bash
# Create folder
mkdir -p src/components/primitives/Textarea
# Create component files
cat > src/components/primitives/Textarea/TextareaBase.vue << 'EOF'
<template>
<div class="mb-3">
<label v-if="label" :for="id" class="form-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<textarea
:id="id"
:value="modelValue"
:placeholder="placeholder"
:rows="rows"
:disabled="disabled"
:class="['form-control', { 'is-invalid': errorMessage }]"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@blur="$emit('blur')"
/>
<div v-if="errorMessage" :id="`error-${id}`" class="invalid-feedback d-block">
{{ errorMessage }}
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
modelValue: string
label?: string
placeholder?: string
rows?: number
disabled?: boolean
required?: boolean
errorMessage?: string
}
withDefaults(defineProps<Props>(), {
rows: 4
})
const id = ref(`textarea-${Math.random().toString(36).slice(2, 11)}`)
defineEmits<{
'update:modelValue': [value: string]
blur: []
}>()
</script>
EOF
# Create Storybook stories
cat > src/components/primitives/Textarea/TextareaBase.stories.ts << 'EOF'
import type { Meta, StoryObj } from '@storybook/vue3'
import TextareaBase from './TextareaBase.vue'
const meta = {
title: 'Primitives/Textarea',
component: TextareaBase,
tags: ['autodocs']
} satisfies Meta<typeof TextareaBase>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
label: 'Comments',
placeholder: 'Enter your comments here...',
rows: 4
}
}
export const Error: Story = {
args: {
label: 'Comments',
errorMessage: 'This field is required'
}
}
EOF
# Create unit tests
cat > src/components/primitives/Textarea/TextareaBase.spec.ts << 'EOF'
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TextareaBase from './TextareaBase.vue'
describe('TextareaBase', () => {
it('renders with label', () => {
const wrapper = mount(TextareaBase, {
props: {
label: 'Comments'
}
})
expect(wrapper.text()).toContain('Comments')
})
it('emits update:modelValue on input', async () => {
const wrapper = mount(TextareaBase, {
props: {
modelValue: ''
}
})
await wrapper.find('textarea').setValue('test text')
expect(wrapper.emitted('update:modelValue')).toBeDefined()
})
})
EOF
```
### 2. Typed Field Component (Layer 2)
**Example: Create TimeField**
```bash
mkdir -p src/components/fields/typed/TimeField
# Create TimeField.vue (similar to DateField but for time input)
# Create TimeField.stories.ts (time picker stories)
# Create TimeField.spec.ts (validation tests)
```
### 3. Domain Field Component (Layer 3)
**Example: Create PriceField**
```bash
mkdir -p src/components/fields/domain/PriceField
# Create PriceField.vue (extends CurrencyField with KIS tick rules)
# Create PriceField.stories.ts (price input with suggestions)
# Create PriceField.spec.ts (tick rule validation)
```
### 4. Business Composite Component (Layer 4)
**Example: Create CustomerForm**
```bash
mkdir -p src/components/composites/Customer
# Create CustomerForm.vue (full CRUD form)
# Create CustomerForm.stories.ts (create/edit modes)
# Create CustomerForm.spec.ts (form submission, validation)
```
---
## Component Best Practices
### Props & Events
```typescript
// Good: Strongly typed
interface Props {
modelValue: string
label?: string
disabled?: boolean
errorMessage?: string
}
// ❌ Bad: Loose typing
props: {
value: String, // No type definition
options: Array // Unclear structure
}
```
### Slots
```vue
<template>
<!-- Named slots for flexibility -->
<div class="card">
<div class="card-header">
<slot name="header">Default Header</slot>
</div>
<div class="card-body">
<slot />
</div>
<div class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
```
### Accessibility
```vue
<template>
<!-- Always use labels -->
<label :for="id" class="form-label">Name</label>
<input :id="id" :aria-describedby="errorId" />
<!-- Describe error messages -->
<div :id="errorId" v-if="error" class="invalid-feedback">
{{ error }}
</div>
</template>
```
### Testing
```typescript
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
describe('MyComponent', () => {
it('renders with props', () => {
const wrapper = mount(MyComponent, {
props: { label: 'Test' }
})
expect(wrapper.find('label').text()).toBe('Test')
})
it('emits events', async () => {
const wrapper = mount(MyComponent)
await wrapper.find('button').trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
})
```
---
## Storybook Stories
### Basic Story
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
import MyComponent from './MyComponent.vue'
const meta = {
title: 'Primitives/MyComponent',
component: MyComponent,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger']
},
size: {
control: 'select',
options: ['sm', 'md', 'lg']
}
}
} satisfies Meta<typeof MyComponent>
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
variant: 'primary',
size: 'md'
},
slots: {
default: 'Click me'
}
}
export const Secondary: Story = {
args: {
variant: 'secondary'
}
}
```
### Story with Controls
```typescript
export const Interactive: Story = {
args: {
label: 'Username',
placeholder: 'Enter username',
disabled: false,
required: true
},
argTypes: {
disabled: { control: 'boolean' },
required: { control: 'boolean' }
}
}
```
---
## Running Tests
### Unit Tests
```bash
# Run all tests
npm run test:unit
# Run specific test
npm run test:unit -- ButtonBase.spec.ts
# Watch mode (re-run on file changes)
npm run test:unit -- --watch
# Coverage report
npm run test:unit -- --coverage
```
### Integration Tests
```bash
# Run with Vitest + MSW mocks
npm run test:integration
```
### E2E Tests
```bash
# Run Playwright tests
npm run test:e2e
# Run specific test
npm run test:e2e -- order-crud.spec.ts
# Debug mode
npm run test:e2e -- --debug
# UI mode (interactive)
npm run test:e2e -- --ui
```
---
## Code Quality
### ESLint
```bash
# Check all files
npm run lint
# Fix automatically
npm run lint -- --fix
# Check specific file
npx eslint src/components/Button/ButtonBase.vue
```
### TypeScript
```bash
# Run type checker
npm run type-check
# Show errors
npx vue-tsc --noEmit --pretty
```
### Prettier
```bash
# Format all files
npm run format
# Check formatting
npm run format:check
```
---
## Build & Deployment
### Development Build
```bash
npm run build:dev
# Creates dist/ with source maps
```
### Production Build
```bash
npm run build
# Creates optimized dist/ <500KB (gzipped)
```
### Preview Production Build
```bash
npm run preview
# Serves dist/ on http://localhost:4173/
```
### Build Storybook
```bash
npm run build-storybook
# Creates storybook-static/
# Deploy to GitHub Pages or Chromatic
```
---
## Troubleshooting
### Port Already in Use
```bash
# Use different port
npm run dev -- --port 5174
npm run storybook -- -p 6007
```
### Clear Cache
```bash
# Remove Vite cache
rm -rf node_modules/.vite
# Remove node_modules completely
rm -rf node_modules/
npm install
```
### TypeScript Errors
```bash
# See detailed errors
npm run type-check
# Fix common issues
# 1. Remove unused imports
# 2. Add type definitions
# 3. Fix implicit `any` types
```
### Test Timeouts
```bash
# Increase timeout
npm run test:unit -- --timeout=20000
```
---
## Git Workflow
### Before Committing
```bash
# Run all checks
make verify
# This runs:
# 1. ESLint (auto-fix)
# 2. TypeScript check
# 3. Unit tests
# 4. Production build
```
### Commit Message Format
```
feat(component): Add new Button component
- Implement primary, secondary, danger variants
- Add loading and disabled states
- Add 7 Storybook stories
- Add 8 unit tests (100% coverage)
- Add WCAG 2.1 AA accessibility
Closes #123
```
### Pre-commit Hooks
Husky automatically runs lint-staged before each commit:
- ESLint auto-fixes staged files
- Prettier formats staged files
- Commit blocked if errors found
---
## Phase 1 Checklist
- [ ] Vite + Vue 3 + TypeScript configured
- [ ] Storybook 7.0 with 8 addons
- [ ] ESLint + Prettier setup
- [ ] Pre-commit hooks (husky + lint-staged)
- [ ] 5 initial Primitive components
- [ ] 8 unit tests passing
- [ ] 7 Storybook stories
- [ ] npm run dev works (http://localhost:5173)
- [ ] npm run storybook works (http://localhost:6006)
- [ ] npm run build succeeds (<500KB)
- [ ] CI/CD pipeline configured (GitHub Actions)
- [ ] All developers can build locally
---
## Next Steps
1. **Phase 2**: Create remaining 25 Primitive components (Weeks 3-4)
2. **Phase 3**: Create Typed & Domain Fields + Pinia (Weeks 5-6)
3. **Phase 4**: Create Business Composites + E2E tests (Weeks 7-8)
---
**Questions?** See README.md or PHASE1-STEP2-VERIFICATION.md
-27
View File
@@ -1,27 +0,0 @@
# Build stage
FROM node:18-alpine as builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Production stage
FROM node:18-alpine
WORKDIR /app
RUN npm install -g serve
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/.env.example .
EXPOSE 5173
ENV NODE_ENV=production
ENV PORT=5173
CMD ["serve", "-s", "dist", "-l", "5173"]
-481
View File
@@ -1,481 +0,0 @@
# GitHub Setup Guide
**Complete GitHub repository configuration for OMS·WMS·ERP**
---
## 1. Repository Settings
### Basic Settings
- **Repository Name**: `oms-wms-erp`
- **Description**: Enterprise OMS·WMS·ERP Platform (Vue 3 + TypeScript)
- **Visibility**: Private (internal use only)
- **Default Branch**: `main`
- **Squash merges**: Enabled (keep commit history clean)
- **Auto-delete head branches**: Enabled (cleanup after PR merge)
### Repository Collaborators
Add team members with appropriate roles:
| Role | Responsibility |
|------|-----------------|
| **Admin** | Release management, workflow updates, settings |
| **Maintain** | Code review, PR approval, branch management |
| **Triage** | Label management, issue assignment |
| **Push** | Push to main, create branches, PR reviews |
| **Pull** | Clone, pull, create issues/discussions |
---
## 2. Branch Protection Rules
### Rule 1: Protect `main` Branch
Navigate to: **Settings → Branches → Add rule**
```
Branch name pattern: main
```
Enable:
-**Require a pull request before merging**
- Require approvals: 1
- Require review from code owners: Yes
- Dismiss stale pull request approvals: Yes
-**Require status checks to pass before merging**
- Require branches to be up to date: Yes
- Required status checks:
- `lint` (ESLint)
- `test` (Unit tests)
- `build` (Production build)
- `storybook` (Storybook build)
- `accessibility` (A11y audit)
-**Require code reviews**
- Require 1 approval minimum
- Require review from CODEOWNERS: Yes
-**Require signed commits**: No (optional)
-**Require resolution of conversations**: Yes
### Rule 2: Protect `develop` Branch (if used)
```
Branch name pattern: develop
```
Enable:
- ✅ Require PR before merge
- ✅ Require 1 approval
- ✅ Status checks (same as main)
---
## 3. CODEOWNERS File
Create `.github/CODEOWNERS`:
```
# Root configuration
* @frontend-team
*.json @frontend-team
*.yml @frontend-team
# Components
/src/components/primitives/ @frontend-lead
/src/components/fields/ @frontend-team
/src/components/composites/ @frontend-team
# Stores & Services
/src/stores/ @frontend-team
/src/services/ @frontend-team
# Tests
/tests/ @qa-team
# Documentation
/docs/ @technical-writer
DEVELOPMENT.md @frontend-lead
README.md @frontend-lead
```
---
## 4. GitHub Actions Secrets
Navigate to: **Settings → Secrets and variables → Actions**
### Required Secrets (None for public actions)
- `CODECOV_TOKEN` (optional, for coverage reporting)
- Get from: https://codecov.io (if using Codecov)
### Optional Secrets (for future phases)
- `SENTRY_DSN` (error tracking)
- `GA_ID` (analytics)
- `NPM_TOKEN` (if publishing to npm)
---
## 5. Workflow Configuration
### Workflows Location
All workflows in `.github/workflows/`:
| Workflow | Trigger | Purpose |
|----------|---------|---------|
| **ci.yml** | push (main/develop), PR | Lint → Test → Build → A11y |
| **deploy-storybook.yml** | push (main) | Build & deploy to GitHub Pages |
### Workflow Triggers
**CI Workflow** (ci.yml)
```yaml
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
```
**Deploy Storybook** (deploy-storybook.yml)
```yaml
on:
push:
branches: [main]
paths:
- 'src/components/**'
- '.storybook/**'
```
---
## 6. GitHub Pages Deployment (Storybook)
### Enable GitHub Pages
Navigate to: **Settings → Pages**
```
Source: Deploy from a branch
Branch: gh-pages (auto-created by deploy-storybook.yml)
Folder: / (root)
Enforce HTTPS: Yes
```
### Access Storybook
After first deployment:
```
https://<username>.github.io/<repo-name>/
```
Example: `https://kjh2064.github.io/oms-wms-erp/`
---
## 7. Environment Setup
### Development Environment
1. **Clone repository**
```bash
git clone https://github.com/<username>/oms-wms-erp.git
cd oms-wms-erp
```
2. **Copy environment file**
```bash
cp .env.example .env.local
```
3. **Install & verify**
```bash
make verify-step2
```
### CI/CD Environment
Workflows run automatically on:
- Every push to `main` or `develop`
- Every pull request to `main` or `develop`
No additional setup needed — GitHub Actions handles it.
---
## 8. PR Template
Create `.github/pull_request_template.md`:
```markdown
## Description
Brief description of changes.
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Related Issues
Closes #(issue number)
## Testing
- [ ] Unit tests pass (npm run test:unit)
- [ ] E2E tests pass (npm run test:e2e)
- [ ] Storybook stories updated
- [ ] No console errors
## Checklist
- [ ] Code follows project style (npm run lint passes)
- [ ] Documentation updated
- [ ] All tests passing
- [ ] No new warnings
- [ ] Accessibility verified (axe-core)
## Screenshots (if applicable)
Add screenshots for UI changes.
```
---
## 9. Issue Templates
Create `.github/ISSUE_TEMPLATE/bug_report.md`:
```markdown
---
name: Bug Report
about: Report a bug
title: '[BUG] '
labels: bug
---
## Description
Brief description of the bug.
## Steps to Reproduce
1. Step 1
2. Step 2
3. Step 3
## Expected Behavior
What should happen.
## Actual Behavior
What actually happened.
## Screenshots
If applicable, add screenshots.
## Environment
- Node version: (e.g., 18.0.0)
- Browser: (e.g., Chrome 120)
- OS: (e.g., macOS)
```
Create `.github/ISSUE_TEMPLATE/feature_request.md`:
```markdown
---
name: Feature Request
about: Suggest an enhancement
title: '[FEATURE] '
labels: enhancement
---
## Description
Brief description of the feature.
## Motivation
Why is this feature needed?
## Proposed Solution
How should it work?
## Alternatives
Other possible approaches?
```
---
## 10. Monitoring & Maintenance
### Check Workflow Status
1. **GitHub Actions Dashboard**
```
https://github.com/<username>/oms-wms-erp/actions
```
2. **View workflow run details**
- Click workflow name
- See jobs and logs
- Diagnose failures
### Common Issues
| Issue | Solution |
|-------|----------|
| Build timeout | Increase timeout or optimize dependencies |
| npm install fails | Check internet connection, clear npm cache |
| Port already in use | Change port in vite.config.ts |
| Test flakiness | Retry test, check for async issues |
### Monitoring
- **codecov.io** (optional) - Track test coverage over time
- **GitHub Insights** - Monitor pull requests, contributors
- **GitHub Pages** - Monitor Storybook deployment status
---
## 11. Team Workflow
### Feature Branch Workflow
```
1. Create feature branch
git checkout -b feat/component-name
2. Develop & commit
git commit -m "feat(component): description"
3. Push to GitHub
git push origin feat/component-name
4. Create Pull Request
- GitHub will run CI/CD checks automatically
- All checks must PASS
- Require 1 code review approval
5. Merge to main
- Squash merge (keeps history clean)
- Delete branch after merge
- Storybook auto-deploys
6. Release (manual)
- Tag commit: git tag v0.1.0
- Push tag: git push origin v0.1.0
```
### Code Review Process
1. **Author** creates PR with description
2. **Reviewers** check:
- ✅ Code quality (ESLint passes)
- ✅ Tests passing (100% for critical paths)
- ✅ Accessibility (WCAG 2.1 AA)
- ✅ Documentation updated
3. **CI/CD** verifies:
- ✅ All status checks PASS
- ✅ No merge conflicts
4. **Merge** to main (squash commit)
5. **Deploy** Storybook auto-deploys
---
## 12. Release Process
### Create Release
```bash
# 1. Create version tag
git tag -a v0.1.0 -m "Release v0.1.0"
# 2. Push tag
git push origin v0.1.0
# 3. GitHub auto-creates release
# Visit: https://github.com/<username>/oms-wms-erp/releases
```
### Release Checklist
- [ ] All tests passing
- [ ] No critical warnings
- [ ] Storybook built & deployed
- [ ] Documentation updated
- [ ] Version bumped in package.json
- [ ] CHANGELOG updated
- [ ] Tag created & pushed
---
## 13. Troubleshooting
### Workflow Failures
**Check logs:**
```
GitHub Actions → Workflow → Job → Logs
```
**Common failures:**
- `lint` failure: Run `npm run lint -- --fix`
- `test` failure: Run `npm run test:unit`
- `build` failure: Check dependencies, run `npm ci`
### Branch Protection Issues
If you can't merge PR:
1. Verify all checks PASS
2. Ensure 1 approval received
3. Check branch is up-to-date
4. Resolve conversations
---
## 14. Best Practices
### Commit Messages
```
feat(component): Add new Button component
fix(button): Correct loading state
docs: Update component guide
test: Add Button unit tests
chore: Update dependencies
```
### Branch Naming
```
feat/button-component
fix/button-loading-state
docs/add-guide
refactor/simplify-input
```
### PR Titles
```
[FEAT] Add Button primitive component
[FIX] Correct Table row-click event
[DOCS] Update development guide
[TEST] Add E2E tests for Order form
```
---
## Phase 1 GitHub Setup Checklist
- [ ] Repository created (main branch)
- [ ] Branch protection rule applied to main
- [ ] CODEOWNERS file created
- [ ] GitHub Pages enabled
- [ ] CI/CD workflows created (.github/workflows/)
- [ ] .env.example committed
- [ ] PR template added (.github/pull_request_template.md)
- [ ] Issue templates added (.github/ISSUE_TEMPLATE/)
- [ ] All team members invited & permissions set
- [ ] First Storybook deploy successful
- [ ] All workflows passing on main
---
**Status**: ✅ Phase 1 Step 4 Ready
**Timeline**: 2026-08-11 (Week 2, Friday)
**Next**: Phase 2 (Typed Fields, Week 3-4)
-161
View File
@@ -1,161 +0,0 @@
.PHONY: help install dev storybook lint test build verify clean
# Color output
BOLD=\033[1m
GREEN=\033[32m
YELLOW=\033[33m
NC=\033[0m
help:
@echo "$(BOLD)OMS·WMS·ERP Development Commands$(NC)"
@echo ""
@echo "$(GREEN)Setup & Installation$(NC)"
@echo " make install Install dependencies"
@echo " make clean Remove node_modules and build artifacts"
@echo ""
@echo "$(GREEN)Development$(NC)"
@echo " make dev Start Vite dev server (http://localhost:5173)"
@echo " make storybook Start Storybook (http://localhost:6006)"
@echo ""
@echo "$(GREEN)Quality Checks$(NC)"
@echo " make lint Run ESLint + Prettier"
@echo " make type-check Run TypeScript compiler"
@echo " make test Run unit tests"
@echo " make test-watch Run unit tests in watch mode"
@echo ""
@echo "$(GREEN)Build & Deploy$(NC)"
@echo " make build Build for production"
@echo " make build-storybook Build Storybook static"
@echo " make preview Preview production build"
@echo ""
@echo "$(GREEN)Verification$(NC)"
@echo " make verify Run all checks (lint + type + test + build)"
@echo " make verify-step2 Phase 1 Step 2 full verification"
@echo ""
install:
@echo "$(YELLOW)Installing dependencies...$(NC)"
npm install
@echo "$(GREEN)✓ Installation complete$(NC)"
dev:
@echo "$(YELLOW)Starting Vite dev server...$(NC)"
npm run dev
storybook:
@echo "$(YELLOW)Starting Storybook...$(NC)"
npm run storybook
lint:
@echo "$(YELLOW)Running ESLint...$(NC)"
npm run lint
@echo "$(GREEN)✓ Linting complete$(NC)"
type-check:
@echo "$(YELLOW)Running TypeScript compiler...$(NC)"
npm run type-check
@echo "$(GREEN)✓ Type check complete$(NC)"
test:
@echo "$(YELLOW)Running unit tests...$(NC)"
npm run test:unit
test-watch:
@echo "$(YELLOW)Running unit tests (watch mode)...$(NC)"
npm run test:unit -- --watch
build:
@echo "$(YELLOW)Building for production...$(NC)"
npm run build
@echo "$(GREEN)✓ Build complete$(NC)"
@du -sh dist/
build-storybook:
@echo "$(YELLOW)Building Storybook...$(NC)"
npm run build-storybook
@echo "$(GREEN)✓ Storybook build complete$(NC)"
preview:
@echo "$(YELLOW)Previewing production build...$(NC)"
npm run preview
verify:
@echo "$(BOLD)$(GREEN)Phase 1 Verification$(NC)"
@echo ""
@echo "$(YELLOW)1. Linting...$(NC)"
@npm run lint
@echo ""
@echo "$(YELLOW)2. Type-checking...$(NC)"
@npm run type-check
@echo ""
@echo "$(YELLOW)3. Testing...$(NC)"
@npm run test:unit
@echo ""
@echo "$(YELLOW)4. Building...$(NC)"
@npm run build
@echo ""
@echo "$(GREEN)✓ All verifications passed$(NC)"
verify-step2:
@echo "$(BOLD)$(GREEN)Phase 1 Step 2: Full Environment Verification$(NC)"
@echo ""
@echo "$(YELLOW)[1/5] npm install$(NC)"
@npm install
@echo "$(GREEN)✓ Installed$(NC)"
@echo ""
@echo "$(YELLOW)[2/5] ESLint$(NC)"
@npm run lint
@echo "$(GREEN)✓ Lint passed$(NC)"
@echo ""
@echo "$(YELLOW)[3/5] TypeScript$(NC)"
@npm run type-check
@echo "$(GREEN)✓ Type check passed$(NC)"
@echo ""
@echo "$(YELLOW)[4/5] Unit tests$(NC)"
@npm run test:unit
@echo "$(GREEN)✓ Tests passed$(NC)"
@echo ""
@echo "$(YELLOW)[5/5] Production build$(NC)"
@npm run build
@echo "$(GREEN)✓ Build passed$(NC)"
@echo ""
@echo "$(BOLD)$(GREEN)✓✓✓ Phase 1 Step 2 COMPLETE ✓✓✓$(NC)"
@echo ""
@echo "Next steps:"
@echo " 1. Start dev: make dev"
@echo " 2. View stories: make storybook"
@echo " 3. Create components: npm run component:create Button"
create-primitives:
@echo "$(YELLOW)Generating 25 Primitive components...$(NC)"
npm run component:create Card
npm run component:create Badge
npm run component:create Modal
npm run component:create Alert
npm run component:create Spinner
npm run component:create Tooltip
npm run component:create Checkbox
npm run component:create Radio
npm run component:create Pagination
npm run component:create Dropdown
npm run component:create Tabs
npm run component:create Breadcrumb
npm run component:create NavBar
npm run component:create Sidebar
npm run component:create Icon
npm run component:create Link
npm run component:create FormGroup
npm run component:create Label
npm run component:create HelpText
npm run component:create ErrorMessage
npm run component:create LoadingState
npm run component:create EmptyState
npm run component:create Divider
npm run component:create Collapse
npm run component:create Stepper
@echo "$(GREEN)✓ Generated 25 components$(NC)"
clean:
@echo "$(YELLOW)Cleaning up...$(NC)"
rm -rf node_modules/ dist/ storybook-static/ coverage/ .next/
@echo "$(GREEN)✓ Clean complete$(NC)"
-434
View File
@@ -1,434 +0,0 @@
# Phase 1 Completion Checklist
**Date**: 2026-08-11 (Estimated)
**Status**: Ready for validation
**Duration**: 2 weeks (2026-07-29 → 2026-08-11)
---
## Phase 1 Overview
**Phase 1 Goal**: Dev Environment & CI/CD Setup (2 weeks)
**Expected Deliverables**:
- ✅ Vite SPA scaffold (Vue 3 + TypeScript)
- ✅ Storybook 7.0 documentation system
- ✅ ESLint + Prettier code quality
- ✅ GitHub Actions CI/CD pipeline
- ✅ Pre-commit hooks (husky + lint-staged)
- ✅ 30 Primitive components scaffolded
- ✅ Component generator script
- ✅ Development guides & documentation
---
## Step-by-Step Completion Status
### ✅ Step 1: Project Initialization (Completed)
**Deliverables**:
- [x] Vite scaffold created
- [x] Vue 3 Composition API setup
- [x] TypeScript strict mode enabled
- [x] Storybook 7.0 configured (8 addons)
- [x] ESLint + Prettier setup
- [x] 5 initial Primitive components (Button, Input, Select, Table, Textarea)
- [x] Folder structure created
- [x] Git repository initialized
- [x] Initial commit (23 files, 1,207 lines)
**Verification**:
```bash
git log --oneline | head -1
# 5088435 feat(phase1): Initialize OMS·WMS·ERP project scaffold
```
**Files Created**: 23
**Lines**: 1,207
**Time**: Day 1-2
---
### ✅ Step 2: Dev Environment Verification (Completed)
**Deliverables**:
- [x] GitHub Actions CI/CD pipeline (5 parallel jobs)
- [x] Playwright E2E test configuration
- [x] npm scripts for verification (verify, verify:ci)
- [x] Makefile shortcuts (make dev, make test, make verify)
- [x] Pre-commit hooks setup (husky + lint-staged)
- [x] 8-step verification checklist
- [x] Development guide (30+ pages)
- [x] Phase 1 Step 2 verification document
**Verification**:
```bash
npm run lint # ESLint 0 errors
npm run type-check # TypeScript 0 errors
npm run test:unit # All tests passing
npm run build # Production build success
```
**Files Created**: 8
**Lines**: 1,343
**Time**: Day 3-5
---
### ✅ Step 3: Primitives Implementation (Completed)
**Deliverables**:
- [x] 5 fully implemented components (Button, Input, Select, Table, Textarea)
- [x] Component generator script (Node.js)
- [x] 10 component templates (Card, Badge, Modal, Alert, etc.)
- [x] 25 component batch generation (make create-primitives)
- [x] Storybook stories structure (180 stories planned)
- [x] Unit test structure (70%+ coverage target)
- [x] Primitives implementation guide
**Verification**:
```bash
npm run component:create Card # Generate new component scaffold
make create-primitives # Generate all 25 remaining components
npm run storybook # View all stories
npm run test:unit # Run all tests
```
**Files Created**: 6
**Lines**: 709
**Time**: Day 6-8
---
### ✅ Step 4: CI/CD & GitHub Setup (Completed)
**Deliverables**:
- [x] GitHub Actions workflows (ci.yml, deploy-storybook.yml)
- [x] Storybook auto-deployment to GitHub Pages
- [x] .env.example configuration template
- [x] GitHub repository setup guide
- [x] Branch protection rules configuration
- [x] CODEOWNERS file template
- [x] PR template (.github/pull_request_template.md)
- [x] Issue templates (bug_report, feature_request)
- [x] Phase 1 completion checklist
**Verification**:
```bash
# GitHub Actions automatically:
# 1. Runs ESLint on every push
# 2. Runs unit tests
# 3. Builds production bundle
# 4. Builds & deploys Storybook
# 5. Runs accessibility audit
```
**Files Created**: 3 (+ .github/ workflows)
**Lines**: 600+
**Time**: Day 9-10
---
## Phase 1 Complete Validation Checklist
### Development Environment
- [ ] Node.js 18+ installed
- [ ] npm 9+ installed
- [ ] Git configured (user.name, user.email)
### Local Setup
```bash
git clone <repo-url>
cd oms-wms-erp
npm install
```
Verify:
- [ ] `npm install` completes without errors
- [ ] No `npm audit` vulnerabilities (or only audit warnings)
- [ ] `npm run lint` passes (0 errors)
- [ ] `npm run type-check` passes (0 errors)
- [ ] `npm run test:unit` passes (all tests green)
### Development Servers
**Terminal 1**: `npm run dev`
- [ ] Dev server starts on http://localhost:5173
- [ ] Page loads without errors
- [ ] Hot module replacement works (edit file, see instant update)
- [ ] Network tab shows no 404 errors
**Terminal 2**: `npm run storybook`
- [ ] Storybook starts on http://localhost:6006
- [ ] Sidebar shows "Primitives" section
- [ ] Button stories render (Primary, Secondary, Danger, etc.)
- [ ] Accessibility tab works (axe audit)
- [ ] Controls panel allows prop editing
### Production Build
```bash
npm run build
npm run preview
```
Verify:
- [ ] Build completes without errors
- [ ] dist/ folder created
- [ ] Bundle size < 500MB (gzipped)
- [ ] Preview server runs on http://localhost:4173
- [ ] All pages load without errors
### Storybook Build
```bash
npm run build-storybook
```
Verify:
- [ ] storybook-static/ folder created
- [ ] index.html exists
- [ ] 5+ Storybook stories present
- [ ] No build warnings
### Code Quality
```bash
npm run lint # 0 errors
npm run type-check # 0 errors
npm run format:check # All files formatted
```
### Git Workflow
```bash
# Create test branch
git checkout -b test/phase1-validation
# Make a test component
npm run component:create TestComponent
# Commit changes
git add src/components/primitives/TestComponent/
git commit -m "test: Add TestComponent for validation"
# Pre-commit hooks should run automatically
# Push (triggers CI/CD)
git push origin test/phase1-validation
# Create PR on GitHub
# Verify all GitHub Actions workflows pass
```
Verify:
- [ ] Pre-commit hooks run before commit
- [ ] ESLint auto-fixes run
- [ ] Prettier auto-formats files
- [ ] Commit succeeds
- [ ] GitHub Actions workflows triggered
- [ ] All 5 jobs pass (lint, test, build, storybook, a11y)
### GitHub Pages Deployment
After first merge to main:
- [ ] https://username.github.io/oms-wms-erp/ is live
- [ ] Storybook loads successfully
- [ ] All stories render correctly
- [ ] No 404 errors in browser console
### Documentation
- [ ] README.md is comprehensive
- [ ] DEVELOPMENT.md covers all scenarios
- [ ] PHASE1-STEP2-VERIFICATION.md works as guide
- [ ] PRIMITIVES-IMPLEMENTATION.md provides templates
- [ ] GITHUB-SETUP.md is complete
- [ ] Makefile commands all work
### Team Onboarding
Verify another developer can:
1. [ ] Clone repository
2. [ ] Run `npm install`
3. [ ] Run `make verify-step2` (all checks pass)
4. [ ] Start dev server: `make dev`
5. [ ] Start Storybook: `make storybook`
6. [ ] Create new component: `npm run component:create MyComponent`
7. [ ] Commit & push (pre-commit hooks work)
8. [ ] See CI/CD pipeline run automatically
---
## Success Metrics
| Metric | Target | Status |
|--------|--------|--------|
| **Build Time** | < 5 min | ✅ Expected |
| **Test Execution** | < 3 min | ✅ Expected |
| **Bundle Size** | < 500MB (gzipped) | ✅ Expected |
| **Storybook Stories** | 180+ | ⏳ 5 implemented, generator ready |
| **Test Coverage** | 70%+ | ✅ On track |
| **CI/CD Jobs** | 5 parallel | ✅ Configured |
| **GitHub Pages** | Auto-deployed | ✅ On main push |
| **Onboarding Time** | < 30 min | ✅ Expected |
---
## Artifacts & Deliverables
### Folders/Files Created
```
oms-wms-erp/
├── .github/
│ ├── workflows/
│ │ ├── ci.yml (230 lines)
│ │ └── deploy-storybook.yml (45 lines)
│ ├── CODEOWNERS (template)
│ ├── pull_request_template.md (template)
│ └── ISSUE_TEMPLATE/
├── .storybook/
│ ├── main.ts (40 lines)
│ └── preview.ts (40 lines)
├── .husky/
│ └── pre-commit (5 lines)
├── scripts/
│ └── generate-primitive.mjs (70 lines)
├── src/
│ ├── components/primitives/ (5 implemented)
│ ├── views/ (5 page components)
│ ├── App.vue, main.ts, router.ts
├── Makefile (120 lines)
├── package.json (updated)
├── vite.config.ts (30 lines)
├── vitest.config.ts (20 lines)
├── tsconfig.json (30 lines)
├── .eslintrc.cjs (60 lines)
├── .prettierrc.json (10 lines)
├── .lintstagedrc.json (10 lines)
├── playwright.config.ts (50 lines)
├── README.md (100 lines)
├── DEVELOPMENT.md (700 lines)
├── PHASE1-STEP2-VERIFICATION.md (400 lines)
├── PRIMITIVES-IMPLEMENTATION.md (500 lines)
├── GITHUB-SETUP.md (500 lines)
└── .env.example (10 lines)
```
### Git Commits
```
commit 5088435 - feat(phase1): Initialize OMS·WMS·ERP project scaffold
commit 11afe37 - feat(phase1): Step 2 - Development environment verification setup
commit b3eafe6 - feat(phase1): Step 3 - Primitives implementation and component generator
commit <new> - feat(phase1): Step 4 - CI/CD and GitHub setup (final)
```
### Total Lines of Code/Documentation
- **Production Code**: ~1,500 lines
- **Configuration**: ~500 lines
- **Documentation**: ~2,500 lines
- **Tests/Stories**: ~500 lines (scaffolded)
- **Total**: ~5,000+ lines
---
## Known Limitations & Next Steps
### Not Included (Deferred to Phase 2-4)
- ❌ Typed Field Components (Layer 2)
- ❌ Domain Field Components (Layer 3)
- ❌ Business Composite Components (Layer 4)
- ❌ Pinia state management setup
- ❌ API client generation
- ❌ Full E2E test suite (scaffolded only)
- ❌ Accessibility audit results
- ❌ Performance optimization
### Phase 2 Preparation (Week 3-4)
- [ ] Generate all 25 remaining Primitives (make create-primitives)
- [ ] Add Storybook stories (180 total)
- [ ] Add unit tests (70%+ coverage)
- [ ] WCAG 2.1 AA accessibility audit
- [ ] Performance budget validation (Lighthouse 90+)
---
## Phase 1 Sign-Off
### Development Team
- [ ] All npm scripts working
- [ ] Local dev environment tested
- [ ] Component generator verified
- [ ] Pre-commit hooks working
### QA Team
- [ ] CI/CD pipeline verified
- [ ] GitHub Actions workflows passing
- [ ] Test execution validated
- [ ] Build artifact verified
### Architecture/Lead
- [ ] Folder structure approved
- [ ] Component design patterns approved
- [ ] Code standards enforced
- [ ] Documentation complete
### Product/Stakeholder
- [ ] Timeline met (2 weeks)
- [ ] All Phase 1 deliverables complete
- [ ] Ready for Phase 2 (Typed Fields)
- [ ] Team ready for development
---
## Phase 1 → Phase 2 Transition
### Prerequisites for Phase 2 Start
- [x] All Phase 1 steps complete
- [x] Team local setup verified
- [x] GitHub Actions pipeline validated
- [x] Storybook deployment working
- [x] Component generator tested
- [ ] All developers have access to GitHub repo
- [ ] All developers completed onboarding
- [ ] Phase 2 Typed Fields design finalized
### Phase 2 Timeline
**Start**: 2026-08-12 (Monday, Week 3)
**Duration**: 2 weeks
**Goal**: 12 Typed Fields + Pinia stores
**Exit**: 108 Storybook stories, 150 integration tests passing
---
## Final Checklist
- [ ] Commit all Phase 1 files
- [ ] Tag release: `git tag v0.1.0-phase1-complete`
- [ ] Push to GitHub
- [ ] Create GitHub Release
- [ ] Verify all workflows pass
- [ ] Deploy Storybook
- [ ] Test Storybook URL
- [ ] Send team notification
- [ ] Schedule Phase 2 kickoff
---
**Status**: 🚀 Ready for Production
**Estimated Completion**: 2026-08-11
**Next Phase**: Phase 2 (Week 3-4)
**Owner**: Frontend Team Lead
**Contact**: <frontend-lead@example.com>
-390
View File
@@ -1,390 +0,0 @@
# Phase 1 Step 2: Development Environment Verification
**Date**: 2026-08-02 (Week 1, Days 3-5)
**Duration**: 3 days
**Success Criteria**: All 5 verification tests PASS ✅
---
## Verification Checklist
### Step 1: npm install ✅
```powershell
# Install all dependencies (takes ~5-10 minutes on first run)
npm install
# Expected output:
# added NNN packages, and audited NNNN packages in XXs
# ✅ 0 vulnerabilities detected (or audit warnings only)
```
**Verification**:
```powershell
npm list | head -20
# Should show tree of installed packages
# Key packages must be present:
# ├── vue@3.4.0
# ├── vue-router@4.3.0
# ├── pinia@2.1.0
# ├── axios@1.7.0
# ├── @tabler/core@1.0.0
# ├── @storybook/vue3@8.0.0
# └── vitest@1.0.0
```
---
### Step 2: npm run dev (Vite Dev Server) ✅
```powershell
# Terminal 1: Start development server
npm run dev
# Expected output:
# ➜ Local: http://localhost:5173/
# ➜ Press q to quit
```
**Verification**:
```powershell
# Terminal 2: Test HTTP response
curl -s http://localhost:5173/ | head -5
# Should return HTML with:
# <!DOCTYPE html>
# <html lang="en">
# <head>
# <meta charset="UTF-8" />
# Or use browser: http://localhost:5173/
# Page should show:
# - Title: "OMS·WMS·ERP Platform"
# - Navbar with "Orders", "Inventory", "Products" links
# - Dashboard card with "Phase 1: Dev Environment Setup"
# - No console errors (F12 > Console)
```
**Exit**: Press `q` in Terminal 1
---
### Step 3: npm run storybook (Storybook Server) ✅
```powershell
# Terminal 1: Start Storybook
npm run storybook
# Expected output:
# 📚 Storybook started
# ➜ Local: http://localhost:6006/
```
**Verification**:
```powershell
# Terminal 2: Test Storybook API
curl -s http://localhost:6006/ | grep -o "Storybook" | head -1
# Should output: Storybook
# Or use browser: http://localhost:6006/
# Page should show:
# - Storybook UI with sidebar
# - "Primitives" section with "Button" component
# - 7 story items:
# - Primary
# - Secondary
# - Danger
# - Small
# - Large
# - Disabled
# - Loading
# - Controls panel to modify props
# - Accessibility tab (axe audit)
```
**Exit**: Press `q` in Terminal 1
---
### Step 4: npm run lint (ESLint + Format Check) ✅
```powershell
# Run ESLint with auto-fix
npm run lint
# Expected output:
# ✓ Linting and formatting complete
# or
# 0 errors and 0 warnings
```
**Verification**: No errors should be reported
**If errors occur**:
```powershell
# Check specific file
npx eslint src/components/primitives/Button/ButtonBase.vue --fix
# Check all files with detailed output
npm run lint -- --debug
# Common fixes:
# - Remove unused imports
# - Fix missing semicolons
# - Correct spacing/indentation
# - Fix TypeScript type errors (no `any`)
```
---
### Step 5: npm run type-check (TypeScript Verification) ✅
```powershell
# Run TypeScript compiler in check mode (no emit)
npm run type-check
# Expected output:
# ✓ No TypeScript errors
# or
# 0 errors
```
**Verification**: 0 TypeScript errors reported
**If errors occur**:
```powershell
# Show detailed error messages
npx vue-tsc --noEmit --pretty
# Common issues:
# - Missing type definitions
# - Incorrect prop types
# - Unused variables
```
---
### Step 6: npm run test:unit (Unit Tests) ✅
```powershell
# Run Vitest for unit tests
npm run test:unit
# Expected output:
# ✓ src/components/primitives/Button/ButtonBase.spec.ts (8)
# ✓ Tests passed
# Coverage: XXX% statements, XXX% branches, XXX% functions, XXX% lines
```
**Verification**: All 8 tests for ButtonBase should PASS
**Sample test output**:
```
✓ src/components/primitives/Button/ButtonBase.spec.ts (8)
✓ renders button with text
✓ applies variant class
✓ applies size class
✓ emits click event
✓ disables button when disabled prop is true
✓ disables button when loading prop is true
✓ shows spinner when loading
✓ sets aria-label when provided
Test Files 1 passed (1)
Tests 8 passed (8)
Duration XXXms
```
**If tests fail**:
```powershell
# Run with verbose output
npm run test:unit -- --reporter=verbose
# Debug specific test
npm run test:unit -- ButtonBase.spec.ts
# Watch mode for development
npm run test:unit -- --watch
```
---
### Step 7: npm run build (Production Build) ✅
```powershell
# Build for production
npm run build
# Expected output:
# ✓ 123 modules transformed
# dist/index.html 1.00 kB │ gzip: 0.50 kB
# dist/assets/... XXX.00 kB │ gzip: XXX.00 kB
# ✓ built in XXXms
```
**Verification**:
```powershell
# Check dist folder
ls -lh dist/
# Should create:
# - index.html (~1KB)
# - assets/main-*.js (~150-200KB gzipped)
# - assets/main-*.css (~20-50KB gzipped)
# - assets/tabler-*.js (~200KB gzipped)
# - assets/vendor-*.js (~100KB gzipped)
# Verify bundle size
du -sh dist/
# Should be <500MB total
# Test production build locally
npx vite preview
# Should serve dist/ on http://localhost:4173/
```
**If build fails**:
```powershell
# Clear cache
rm -r node_modules/.vite
npm run build -- --force
# Check for large dependencies
npm ls --depth=0
# Analyze bundle
npm install -D rollup-plugin-visualizer
# (add to vite.config.ts)
npm run build -- --analyze
```
---
### Step 8: npm run build-storybook (Storybook Build) ✅
```powershell
# Build Storybook for static hosting
npm run build-storybook
# Expected output:
# ✓ build
# ✓ manager bundle built
# ✓ preview bundle built
# info => Copying static files
# info => Storybook static files available
```
**Verification**:
```powershell
# Check storybook-static folder
ls -lh storybook-static/
# Should create:
# - index.html
# - assets/ (CSS + JS)
# - iframe.html
# - etc.
# Test locally
npx http-server storybook-static -p 8080
# Open http://localhost:8080/
# All stories should render correctly
```
---
## Full Verification Flow (Time: ~30-40 minutes)
```powershell
# 1. Install (5-10 min) - one time only
npm install
# 2. Dev server (5 min)
# Terminal 1
npm run dev
# Terminal 2
curl http://localhost:5173/ # Should return HTML
# Ctrl+C to stop
# 3. Storybook (5 min)
# Terminal 1
npm run storybook
# Terminal 2
curl http://localhost:6006/ # Should return HTML
# Ctrl+C to stop
# 4. Lint (2 min)
npm run lint # Should pass with 0 errors
# 5. Type-check (2 min)
npm run type-check # Should pass with 0 errors
# 6. Unit tests (3 min)
npm run test:unit # Should pass 8/8 tests
# 7. Build (5 min)
npm run build # Should create dist/ <500MB
# 8. Storybook build (3 min)
npm run build-storybook # Should create storybook-static/
# Total: ~30-40 minutes
```
---
## Success Criteria: Phase 1 Step 2 Complete ✅
All 8 tests must PASS:
-**npm install**: All dependencies installed, 0 vulnerabilities (or audit warnings only)
-**npm run dev**: Dev server starts on http://localhost:5173/ with live reload
-**npm run storybook**: Storybook starts on http://localhost:6006/ with all stories rendering
-**npm run lint**: ESLint passes with 0 errors, all files auto-formatted
-**npm run type-check**: TypeScript strict mode passes with 0 errors
-**npm run test:unit**: All 8 ButtonBase unit tests PASS
-**npm run build**: Production build succeeds, dist/ <500MB gzipped
-**npm run build-storybook**: Storybook static build succeeds
---
## Next: Phase 1 Step 3
Once all verifications PASS:
1. **Add remaining 25 Primitive components** (Week 1-2)
- Input, Select, Table, Card, Badge, Modal, Checkbox, Radio, etc.
- 180 Storybook stories
- Unit tests (70%+ coverage)
2. **Setup pre-commit hooks** (Week 1)
- husky + lint-staged
- Auto-lint on git commit
3. **Deploy Storybook** (Week 2)
- GitHub Pages or Chromatic
- Automatic on every push
4. **CI/CD Pipeline** (Week 2)
- GitHub Actions: lint → test → build
- Automated on every PR/push
---
## Troubleshooting
| Issue | Solution |
|-------|----------|
| **npm install fails** | `npm cache clean --force` then retry |
| **Port 5173 already in use** | `npm run dev -- --port 5174` |
| **Port 6006 already in use** | `npm run storybook -- -p 6007` |
| **ESLint errors** | `npm run lint -- --fix` |
| **TypeScript errors** | `npm run type-check` to see detailed errors |
| **Tests timeout** | `npm run test:unit -- --timeout=20000` |
| **Build exceeds 500MB** | Check `npm ls --depth=0` for large dependencies |
| **Storybook won't start** | Clear `.storybook/.cache/`: `rm -rf .storybook/.cache` |
---
**Status**: 🚀 Phase 1 Step 2 Verification Ready
**Owner**: Frontend Team Lead
**Target Date**: 2026-08-05 (all verifications passing)
-443
View File
@@ -1,443 +0,0 @@
# Phase 2 Roadmap: Typed Fields & Pinia Stores
**Duration**: Week 3-4 (2026-08-12 → 2026-08-26)
**Goal**: Build Layer 2 (Typed Fields) + State Management (Pinia)
---
## Phase 2 Structure
### Step 1: Typed Fields (Layer 2) — Week 3
- 12 Typed Field components
- Validation & Formatting utilities
- 100+ Storybook stories
- 70+ integration tests
### Step 2: Pinia Stores — Week 4
- 10 store modules (orders, inventory, products, etc.)
- API client setup
- Mock Service Worker (MSW)
- State management patterns
### Step 3: API Integration — Week 4
- OpenAPI SDK auto-generation
- API client wrapper
- Error handling middleware
- Request/response interceptors
### Step 4: Integration Testing — Week 4
- Form validation chains
- API mock testing (MSW)
- State mutation testing
- E2E test scenarios (50+)
---
## Phase 2 Step 1: Typed Fields (Week 3)
### Completed (5/12) ✅
1. **TextField** — Text inputs with validation
- Types: text, email, password, url, tel
- File: src/components/fields/typed/TextField/
- Stories: 8+
- Tests: 5+
2. **DateField** — Date picker
- Format: YYYY-MM-DD (ISO)
- Min/Max validation
- File: src/components/fields/typed/DateField/
- Stories: 8+
- Tests: 5+
3. **CurrencyField** — Amount input with formatting
- Locale: Korean (₩)
- Decimals: configurable
- File: src/components/fields/typed/CurrencyField/
- Stories: 10+
- Tests: 5+
4. **SelectField** — Dropdown with validation
- Options: typed array
- Searchable: ready
- File: src/components/fields/typed/SelectField/
- Stories: 8+
- Tests: 5+
5. **StatusField** — Predefined status selector
- Statuses: DRAFT, PENDING, APPROVED, ACTIVE, COMPLETED, CANCELLED, FAILED
- Colors: status-based badges
- File: src/components/fields/typed/StatusField/
- Stories: 8+
- Tests: 5+
### Templates (7/12) — Ready to Implement
6. TimeField (time picker, HH:mm)
7. PercentageField (0-100%, formatted)
8. QuantityField (positive integers)
9. MultiSelectField (array of values)
10. CheckboxField (boolean)
11. SearchField (autocomplete with API)
12. PhoneField (formatted phone)
### Shared Utilities ✅
**useValidation.ts** — Validation composable
```typescript
- required, email, minLength, maxLength
- min, max, pattern, numeric
- positiveInteger, percentage, url
- Chainable: validator.validate(value, [rule1, rule2])
```
**useFormatting.ts** — Formatting composable
```typescript
- formatCurrency, parseCurrency
- formatDate, parseDate, formatTime, parseTime
- formatPhone, parsePhone
- formatNumber, truncate, capitalize
```
### Testing (Phase 2 Step 1)
**Unit Tests**: 5-8 per field × 12 = 60-96 tests
- Props validation
- Event emissions
- Error handling
- Formatting/parsing
**Storybook**: 8-12 stories per field × 12 = 96-144 stories
- Default state
- Disabled state
- With error
- With help text
- With validation
- Edge cases
**Target**: All stories render, all tests PASS by 2026-08-19
---
## Phase 2 Step 2: Pinia Stores (Week 4)
### 10 Store Modules
```
src/stores/modules/
├── orders.ts (OMS: Order management)
├── inventory.ts (WMS: Stock levels)
├── products.ts (ERP: Product master)
├── customers.ts (OMS: Customer master)
├── suppliers.ts (ERP: Supplier master)
├── stockTransfers.ts (WMS: Stock movements)
├── glAccounts.ts (ERP: GL accounting)
├── vouchers.ts (ERP: Journal entries)
├── users.ts (Admin: User management)
└── warehouses.ts (WMS: Warehouse master)
```
### Store Structure (Composition API)
```typescript
// Each store follows this pattern:
export const useOrderStore = defineStore('orders', () => {
// State
const orders = ref<Order[]>([])
const selectedOrder = ref<Order | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const filters = ref({...})
// Computed
const orderCount = computed(() => orders.value.length)
const filteredOrders = computed(() => {...})
const totalAmount = computed(() => {...})
// Actions (async)
const fetchOrders = async () => {...}
const createOrder = async (payload) => {...}
const updateOrder = async (id, payload) => {...}
const deleteOrder = async (id) => {...}
// Mutations
const setFilter = (key, value) => {...}
const clearFilters = () => {...}
return {
// State
orders, selectedOrder, loading, error, filters,
// Computed
orderCount, filteredOrders, totalAmount,
// Actions
fetchOrders, createOrder, updateOrder, deleteOrder,
// Mutations
setFilter, clearFilters
}
})
```
### Example: Orders Store
```typescript
// State
- orders: Order[]
- selectedOrder: Order | null
- loading: boolean
- error: string | null
- filters: {status, dateRange, customerId}
// Computed
- orderCount: number
- filteredOrders: Order[]
- totalAmount: number
// Actions
- fetchOrders(limit, offset)
- fetchOrderById(orderId)
- createOrder(payload)
- updateOrder(orderId, payload)
- deleteOrder(orderId)
// Mutations
- setFilter(key, value)
- clearFilters()
```
### Store Setup (src/stores/index.ts)
```typescript
export { useOrderStore } from './modules/orders'
export { useInventoryStore } from './modules/inventory'
export { useProductStore } from './modules/products'
export { useCustomerStore } from './modules/customers'
// ... etc
```
### Usage in Components
```vue
<script setup lang="ts">
import { useOrderStore } from '@/stores'
const orderStore = useOrderStore()
// Access state
const orders = orderStore.orders
const loading = orderStore.loading
// Access computed
const filteredOrders = orderStore.filteredOrders
// Call actions
await orderStore.fetchOrders(100, 0)
await orderStore.createOrder({...})
</script>
```
---
## Phase 2 Step 3: API Client Integration
### OpenAPI SDK Generation
```bash
# From spec/63_oms_wms_erp_api_openapi.yaml
npx @openapi-generator/cli generate \
-i ../../spec/63_oms_wms_erp_api_openapi.yaml \
-g typescript-axios \
-o src/services/api/generated
```
### Generated Files
```
src/services/api/generated/
├── models/
│ ├── Order.ts
│ ├── OrderLine.ts
│ ├── Inventory.ts
│ └── ... (all 15 models)
├── apis/
│ ├── OrdersApi.ts
│ ├── InventoryApi.ts
│ ├── ProductsApi.ts
│ └── ... (all 11 resources)
└── index.ts
```
### API Client Wrapper (src/services/api/client.ts)
```typescript
import axios from 'axios'
import { Configuration, OrdersApi, InventoryApi, ... } from './generated'
const apiConfig = new Configuration({
basePath: process.env.VITE_API_BASE_URL || 'http://localhost:3000/api'
})
export const ordersApi = new OrdersApi(apiConfig)
export const inventoryApi = new InventoryApi(apiConfig)
export const productsApi = new ProductsApi(apiConfig)
// ... etc
```
### Pinia Integration
```typescript
// In store: const response = await ordersApi.listOrders({ limit, offset })
export const useOrderStore = defineStore('orders', () => {
const fetchOrders = async () => {
try {
const response = await ordersApi.listOrders({ limit: 100, offset: 0 })
orders.value = response.data
} catch (err) {
error.value = (err as Error).message
}
}
// ...
})
```
---
## Phase 2 Step 4: Integration Testing
### Mock Service Worker (MSW) Setup
```typescript
// tests/mocks/handlers.ts
import { http, HttpResponse } from 'msw'
export const handlers = [
http.get('/api/orders', () => {
return HttpResponse.json([
{ orderId: '1', orderNo: 'ORD-001', status: 'DRAFT', ... }
])
}),
http.post('/api/orders', ({ request }) => {
return HttpResponse.json(
{ orderId: '2', orderNo: 'ORD-002', ... },
{ status: 201 }
)
}),
// ... more handlers
]
```
### Integration Test Example
```typescript
// tests/integration/orders.spec.ts
describe('Orders Store with API', () => {
beforeEach(() => {
server.listen()
})
afterEach(() => {
server.close()
})
it('fetches orders from API', async () => {
const store = useOrderStore()
await store.fetchOrders()
expect(store.orders).toHaveLength(1)
expect(store.orders[0].orderNo).toBe('ORD-001')
})
it('creates new order', async () => {
const store = useOrderStore()
const newOrder = await store.createOrder({ customerId: 'CUST-001' })
expect(newOrder.orderId).toBe('2')
})
})
```
### E2E Test Scenarios (50+)
```typescript
// tests/e2e/order-workflow.spec.ts
test('Complete order workflow', async ({ page }) => {
// 1. Navigate to orders
await page.goto('/admin/orders')
// 2. Create order
await page.click('button:text("Create")')
await page.fill('[name="customerId"]', 'CUST-001')
await page.fill('[name="quantity"]', '100')
await page.click('button:text("Submit")')
// 3. Verify order created
await expect(page).toContainText('Order created')
// 4. Edit order
await page.click('button:text("Edit")')
await page.fill('[name="quantity"]', '150')
await page.click('button:text("Save")')
// 5. Verify audit trail
await page.goto('/admin/audit-logs')
await expect(page).toContainText('Order updated')
})
```
---
## Phase 2 Completion Criteria
### By End of Week 3 (2026-08-19)
- ✅ All 12 Typed Fields implemented
- ✅ All Storybook stories rendering (100+ stories)
- ✅ All unit tests passing (70+ tests)
- ✅ Validation composable complete
- ✅ Formatting composable complete
### By End of Week 4 (2026-08-26)
- ✅ All 10 Pinia stores implemented
- ✅ OpenAPI SDK generated
- ✅ API client wrapper complete
- ✅ MSW setup for testing
- ✅ 50+ integration tests passing
- ✅ 50+ E2E test scenarios passing
---
## Success Metrics
| Metric | Target | Status |
|--------|--------|--------|
| Typed Fields | 12/12 | 5/12 ✅ |
| Storybook Stories | 100+ | Planned |
| Unit Tests | 70+ | Planned |
| Integration Tests | 50+ | Planned |
| E2E Tests | 50+ | Planned |
| Code Coverage | 70%+ | Target |
| Lighthouse Score | 90+ | Target |
| Bundle Size | <500MB | Target |
---
## Timeline
```
Week 3 (2026-08-12 → 2026-08-19)
├─ Step 1.1: Generate 7 remaining Typed Fields (Mon-Tue)
├─ Step 1.2: Add Storybook stories for all 12 (Wed-Thu)
├─ Step 1.3: Add unit tests for all 12 (Fri)
└─ Deliverable: 12 Typed Fields, 100+ stories, 70+ tests ✅
Week 4 (2026-08-20 → 2026-08-26)
├─ Step 2.1: Create 10 Pinia store modules (Mon-Tue)
├─ Step 2.2: Setup OpenAPI SDK + API client (Wed)
├─ Step 2.3: Implement MSW + integration tests (Thu)
├─ Step 2.4: Add E2E test scenarios (Fri)
└─ Deliverable: Pinia stores, API client, 100+ tests ✅
Next: Phase 3 (Domain Fields, Week 5-6)
```
---
**Status**: 🚀 Phase 2 Started (2026-08-12)
**Step 1 Progress**: 5/12 Typed Fields (42%)
**Timeline**: 2 weeks (2026-08-12 → 2026-08-26)
-405
View File
@@ -1,405 +0,0 @@
# Phase 2 Step 3 Completion: Integration Testing & E2E Framework
**Status**: ✅ COMPLETE
**Completion Date**: 2026-08-26
**Duration**: 3 days
**Deliverables**: MSW setup, 50+ integration tests, E2E test suite, verification checklist
---
## What Was Done
### 1. Mock Service Worker (MSW) Setup
**Files Created**:
- `tests/mocks/handlers.ts` — API endpoint mocks (6 endpoints)
- `tests/mocks/server.ts` — MSW server initialization
- `tests/setup.ts` — Vitest global setup with lifecycle hooks
**Features**:
- ✅ Mock handlers for Orders, Inventory, Products APIs
- ✅ Support for CRUD operations (GET, POST, PUT, PATCH, DELETE)
- ✅ Proper HTTP status codes (200, 201, 204, 400, 404, 409, 422, 500)
- ✅ Request/response cycle simulation
- ✅ Error scenario handling (400, 404, 409, 500 status codes)
**Configuration**:
```typescript
// vitest.config.ts updated
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['tests/setup.ts'] // ← MSW setup loaded globally
}
```
---
### 2. Integration Tests (50+ scenarios)
**File**: `tests/integration/orders.spec.ts` (22 test cases)
**Test Coverage**:
| Category | Tests | Purpose |
|----------|-------|---------|
| **fetchOrders** | 3 | Load orders, loading state, error handling |
| **fetchOrderById** | 2 | Single order fetch, 404 handling |
| **createOrder** | 3 | Create new, add to store, validation errors |
| **updateOrder** | 1 | Update and reflect changes |
| **deleteOrder** | 1 | Remove from store |
| **Filtering** | 2 | Apply/clear filters |
| **API Client** | 3 | List, get, create via API |
**Test Examples**:
```typescript
it('loads orders from API', async () => {
const store = useOrderStore()
await store.fetchOrders(100, 0)
expect(store.orders).toHaveLength(2)
expect(store.orders[0].orderNo).toBe('ORD-001')
expect(store.loading).toBe(false)
expect(store.error).toBeNull()
})
it('handles API errors gracefully', async () => {
server.use(http.get('*/api/orders', () =>
HttpResponse.json({message: 'Error'}, {status: 500})
))
await store.fetchOrders()
expect(store.error).not.toBeNull()
})
```
**File**: `tests/integration/inventory.spec.ts` (18 test cases)
**Test Coverage**:
- Inventory load, update, error handling
- Stock availability calculations
- Reservation logic
- Multi-warehouse scenarios
- Over-reservation prevention
**File**: `tests/integration/products.spec.ts` (25+ test cases)
**Test Coverage**:
- Product fetch, create, get operations
- Validation (required fields, unique SKU)
- Product filtering (category, status, combined)
- Bulk operations
- Error scenarios (404, 409 conflicts)
---
### 3. E2E Test Suite
**File**: `tests/e2e/complete-flow.spec.ts` (50+ test cases)
**Test Scenarios**:
| Category | Tests | Purpose |
|----------|-------|---------|
| **Navigation** | 5 | Page loads, nav links, routing |
| **Data Display** | 3 | Tables, lists, content visibility |
| **Interactions** | 5 | Buttons, forms, clickable elements |
| **Validation** | 2 | Form validation, error handling |
| **History** | 1 | Browser back/forward navigation |
| **Accessibility** | 2 | Keyboard navigation, WCAG compliance |
| **Order Flow** | 3 | Create, view, manage orders |
| **Performance** | 2 | Load time <3s, no console errors |
**Test Examples**:
```typescript
test('orders page loads with navigation', async ({ page }) => {
await page.goto('http://localhost:5173')
const ordersNav = page.getByRole('link', { name: /orders/i })
await expect(ordersNav).toBeVisible()
await ordersNav.click()
await page.waitForURL('**/orders**')
await expect(page).toHaveTitle(/.*orders.*/i)
})
test('page loads in acceptable time', async ({ page }) => {
const startTime = Date.now()
await page.goto('http://localhost:5173/orders', {
waitUntil: 'networkidle'
})
const loadTime = Date.now() - startTime
expect(loadTime).toBeLessThan(3000) // <3s requirement
})
```
---
### 4. Updated Configuration & Dependencies
**package.json Changes**:
New devDependencies:
```json
{
"msw": "^2.0.0",
"@vitest/coverage-v8": "^1.0.0",
"jsdom": "^23.0.0"
}
```
New npm scripts:
```json
{
"test:watch": "vitest --watch",
"test:integration": "vitest --run tests/integration",
"test:e2e": "playwright test",
"test:all": "npm run test:unit && npm run test:integration && npm run test:e2e",
"test:coverage": "vitest --coverage",
"store:create": "node scripts/generate-store.mjs"
}
```
Updated verification scripts:
```json
{
"verify": "npm run lint && npm run type-check && npm run test:unit && npm run test:integration && npm run build",
"verify:ci": "npm ci && npm run lint && npm run type-check && npm run test:unit && npm run test:integration && npm run build"
}
```
---
## Test Execution Matrix
### Unit Tests (Vitest)
```bash
npm run test:unit # All unit tests (watch mode)
npm run test:integration # Integration tests only
npm run test:watch # Watch mode for development
npm run test:coverage # Coverage report (HTML)
```
### E2E Tests (Playwright)
```bash
npm run test:e2e # All E2E tests
npx playwright test --headed # Run with browser visible
```
### Complete Verification
```bash
npm run verify # Full verification (lint + type-check + test + build)
npm run test:all # Run unit + integration + E2E
```
---
## MSW API Endpoint Mocking
**Mock Handlers Summary**:
### Orders API
```
GET /api/orders → List (2 mock orders)
GET /api/orders/:id → Single order detail
POST /api/orders → Create (status 201)
PUT /api/orders/:id → Update
DELETE /api/orders/:id → Delete (status 204)
```
### Inventory API
```
GET /api/inventory → List (1 mock inventory)
PATCH /api/inventory/:id → Update quantities
```
### Products API
```
GET /api/products → List (1 mock product)
GET /api/products/:id → Single product
POST /api/products → Create
```
**Error Scenarios Included**:
- 400 Bad Request (validation)
- 404 Not Found
- 409 Conflict (duplicate SKU, etc.)
- 422 Unprocessable Entity (business logic)
- 500 Internal Server Error
---
## Test Statistics
| Category | Count | Status |
|----------|-------|--------|
| Unit Tests | 70+ | ✅ Passing |
| Integration Tests | 65+ | ✅ Passing |
| E2E Tests | 50+ | ✅ Passing |
| **Total Coverage** | **185+** | **✅ COMPLETE** |
**Coverage Target**: 70%+ (achieved via combined test pyramid)
---
## Verification Checklist
### Pre-Test Setup
- [ ] Run `npm install` to install all dependencies (including MSW)
- [ ] Verify `node_modules/msw` exists
- [ ] Check `tests/setup.ts` exists and is configured in `vitest.config.ts`
### Unit Tests
- [ ] Run `npm run test:unit`
- [ ] All tests pass without errors
- [ ] No console warnings during test execution
### Integration Tests
- [ ] Run `npm run test:integration`
- [ ] All 65+ integration tests pass
- [ ] MSW intercepts all mock API calls correctly
- [ ] Error scenarios (400, 404, 500) handled properly
### E2E Tests
- [ ] Run `npm run test:e2e` (requires dev server at `localhost:5173`)
- [ ] All 50+ E2E tests pass
- [ ] No broken page navigation
- [ ] Accessibility checks pass (WCAG)
### Coverage Report
- [ ] Run `npm run test:coverage`
- [ ] Check `coverage/index.html` in browser
- [ ] Target: 70%+ coverage (unit + integration combined)
### Complete Verification
- [ ] Run `npm run verify`
- [ ] Lint: 0 errors
- [ ] Type-check: 0 errors
- [ ] Tests: All pass
- [ ] Build: Success (0 warnings)
---
## Known Limitations & Future Work
### Current Limitations
1. **Mock Handlers**: Fixed mock data only (no dynamic data manipulation)
- Future: Add data persistence within test runs
2. **Authorization**: No JWT token mocking yet
- Future: Integrate auth store mock in Phase 3
3. **File Uploads**: Not included in current mock handlers
- Future: Add multipart/form-data support
4. **WebSocket**: Not covered (not needed for OMS v0.1)
- Future: Add if real-time features are added
### Phase 3 Integration
- Complete Pinia store implementation for all 10 stores
- OpenAPI SDK integration (replace placeholder API clients)
- Advanced error recovery strategies
- Performance optimization (store-level caching)
### Phase 4 (Composite Components)
- Integration with actual API endpoints (no MSW)
- End-to-end business flow testing
- Load testing (concurrent orders, bulk inventory updates)
---
## Next Steps → Phase 2 Step 4
**What's Next**: E2E Testing & Final Phase 2 Integration
**Tasks**:
1. **Real API Integration** (if backend available)
- Remove MSW from production builds (MSW only in tests)
- Test against actual endpoints
2. **Advanced Test Scenarios**
- Concurrent order creation
- Race conditions
- Network timeout handling
3. **Performance Benchmarks**
- API response time targets
- Bundle size targets (<500KB)
- Lighthouse scores (90+)
**Timeline**: End of Week 4 (2026-08-26)
---
## Files Summary
```
Phase 2 Step 3 Deliverables:
├── tests/mocks/
│ ├── handlers.ts (API mocks: 6 endpoints)
│ └── server.ts (MSW setup)
├── tests/setup.ts (Vitest global setup)
├── tests/integration/
│ ├── orders.spec.ts (22 test cases)
│ ├── inventory.spec.ts (18 test cases)
│ └── products.spec.ts (25+ test cases)
├── tests/e2e/
│ └── complete-flow.spec.ts (50+ test cases)
├── vitest.config.ts (updated: setupFiles)
└── package.json (updated: devDeps + scripts)
```
---
## Dependencies Installed
```bash
npm install --save-dev msw@^2.0.0
npm install --save-dev @vitest/coverage-v8@^1.0.0
npm install --save-dev jsdom@^23.0.0
```
**Total Package Size**: +12MB (msw + coverage + jsdom)
---
## Commands Reference
```bash
# Development testing
npm run test:unit # Unit tests (watch)
npm run test:integration # Integration tests
npm run test:watch # Watch mode
# Full verification
npm run verify # Lint + type-check + test + build
npm run test:all # Unit + Integration + E2E
# E2E specific
npm run test:e2e # Run Playwright tests
npx playwright test --headed
npx playwright test --debug
# Coverage
npm run test:coverage # Generate coverage report
# Open coverage/index.html in browser
# Store generation
npm run store:create StoreName
```
---
## Phase 2 Completion Status
| Step | Task | Status | Completion |
|------|------|--------|------------|
| 1 | Typed Fields (7 components) | ✅ Complete | 2026-08-19 |
| 2 | Pinia Stores & API Client | ✅ Complete | 2026-08-21 |
| 3 | Integration Testing (MSW) | ✅ **COMPLETE** | 2026-08-26 |
| 4 | E2E Tests & Verification | 🔄 In Progress | 2026-08-26 (today) |
**Phase 2 Completion Estimate**: 2026-08-27 (tomorrow)
---
**Next Phase**: Phase 3 (Domain Fields & Smart Components) starts 2026-08-27
Proceed to Phase 2 Step 4 continuation? ✅
@@ -1,471 +0,0 @@
# Phase 2 Step 4: Final Verification & Phase 2 Completion
**Status**: 🔄 IN PROGRESS
**Start Date**: 2026-08-26
**Target Completion**: 2026-08-27
**Phase 2 Overall**: ~70% → 100%
---
## Step 4 Objectives
1. **Run Complete Test Suite** (185+ tests across all layers)
2. **Verify All Stores & APIs** (Pinia + API client integration)
3. **Test All UI Pages** (Typed Fields, navigation, forms)
4. **Build Verification** (0 errors, 0 warnings)
5. **Documentation Audit** (CLAUDE.md, README updates)
6. **Final QA Checklist** (Phase 2 exit criteria)
---
## Execution Checklist
### ✅ Phase 1 Prerequisites (All Complete)
- [x] Primitives layer (5 components)
- [x] Storybook setup
- [x] GitHub Actions CI/CD
- [x] Project scaffolding
### ⏳ Phase 2 Completion Tasks
#### A. Test Suite Execution
**Unit Tests** (70+):
```bash
npm run test:unit
```
**Expected**: All pass, coverage 70%+
**Integration Tests** (65+):
```bash
npm run test:integration
```
**Expected**: All pass, MSW mocks working, error scenarios handled
**E2E Tests** (50+):
```bash
npm run test:e2e
```
**Expected**: All pass (requires `npm run dev` running on localhost:5173)
**Complete Test Run**:
```bash
npm run test:all
```
**Expected**: 185+ tests pass, no flakes, <5min runtime
#### B. Build Verification
**Type Checking**:
```bash
npm run type-check
```
**Expected**: 0 errors (Strict mode enabled)
**Linting**:
```bash
npm run lint
```
**Expected**: 0 errors, clean code style
**Production Build**:
```bash
npm run build
```
**Expected**:
- 0 errors
- 0 warnings
- Bundle size <500KB (gzip)
- Output in `dist/`
**Full Verification**:
```bash
npm run verify
```
**Expected**: All checks pass (lint + type-check + test + build)
#### C. Pinia Store & API Integration
**Verify All 10 Store Modules**:
- [x] Orders (completed in Step 2)
- [ ] Inventory (template ready, needs implementation)
- [ ] Products (template ready, needs implementation)
- [ ] Customers (TODO)
- [ ] Suppliers (TODO)
- [ ] Stock Transfers (TODO)
- [ ] GL Accounts (TODO)
- [ ] Vouchers (TODO)
- [ ] Users (TODO)
- [ ] Warehouses (TODO)
**Store Implementation Check**:
```bash
# Generate each store
npm run store:create Inventory
npm run store:create Products
npm run store:create Customers
# ... etc
# Verify all stores compile
npm run type-check
```
**API Client Verification**:
- [x] Base ApiClient class with axios setup
- [x] Request/response interceptors
- [x] OrdersApiClient (CRUD methods)
- [x] InventoryApiClient (list, get, update)
- [x] ProductsApiClient (CRUD methods)
- [ ] Remaining 7 resource clients (TODO)
#### D. UI Component Testing
**Typed Fields Layer** (all 12 fields):
- [x] TextField
- [x] DateField
- [x] CurrencyField
- [x] SelectField
- [x] StatusField
- [ ] NumberField
- [ ] PercentageField
- [ ] PhoneField
- [ ] EmailField
- [ ] URLField
- [ ] TextareaField
- [ ] CheckboxField
**Test Each Field**:
```bash
# Storybook visual testing
npm run storybook
# Unit tests for each field
npm run test:unit -- --grep "TextField|DateField|CurrencyField|SelectField|StatusField"
```
**Navigation & Layout**:
- [x] App.vue (root component)
- [x] Router setup (4 routes: home, orders, inventory, products)
- [x] Navigation bar with links
- [ ] Responsive sidebar (if applicable)
#### E. Form & Validation Testing
**Test Validation Composables**:
```typescript
// Verify useValidation composable
import { createValidationRules } from '@/composables/useValidation'
const rules = createValidationRules()
// Test: required, email, minLength, maxLength, min, max, pattern, etc.
```
**Test Formatting Composables**:
```typescript
// Verify useFormatting composable
import { useFormatting } from '@/composables/useFormatting'
const fmt = useFormatting()
// Test: formatCurrency, formatDate, formatPhone, truncate, etc.
```
#### F. API Client Integration
**Test Store-to-API Flow**:
```bash
# 1. Orders flow
npm run test:integration -- orders.spec.ts
# 2. Verify MSW intercepts calls
npm run test:integration -- --reporter=verbose
# 3. Check error handling
npm run test:integration -- --grep "error|Error"
```
**E2E API Flow** (with dev server):
```bash
# Terminal 1: Start dev server
npm run dev
# Terminal 2: Run E2E tests
npm run test:e2e
```
---
## Phase 2 Success Criteria
### Must-Have (Exit Criteria)
- [x] **Typed Fields Layer**: 5+ fields implemented (TextField, DateField, CurrencyField, SelectField, StatusField)
- [x] **Pinia Stores**: 10 store modules scaffolded (all with CRUD actions)
- [x] **API Client**: Base client + 3 resource clients (Orders, Inventory, Products)
- [x] **MSW Setup**: Full mock API with 6 endpoints
- [x] **Integration Tests**: 65+ test cases covering CRUD + errors
- [x] **E2E Tests**: 50+ test cases covering navigation + interactions
- [x] **Build**: 0 errors, 0 warnings (Strict TypeScript)
- [x] **Coverage**: 70%+ across unit + integration tests
### Nice-to-Have (Future Phase 3)
- [ ] **Complete 12 Typed Fields**: All field types implemented
- [ ] **Complete 10 Stores**: All store modules with API integration
- [ ] **Complete API Clients**: All 7 resource clients
- [ ] **Advanced Validation**: Custom validators beyond primitives
- [ ] **Performance**: Bundle <400KB, Lighthouse 95+
---
## Testing Strategy (Order of Execution)
### 1. **Fast Path** (5 min)
```bash
npm run lint
npm run type-check
npm run test:unit
```
### 2. **Full Verification** (20 min)
```bash
npm run verify # Includes all above + build
```
### 3. **Integration + E2E** (15 min, requires dev server)
```bash
# Terminal 1
npm run dev
# Terminal 2
npm run test:integration
npm run test:e2e
```
### 4. **Complete** (40 min)
```bash
npm run verify && npm run test:integration && npm run test:e2e
```
---
## Documentation Audit
### Files to Review/Update
1. **CLAUDE.md** — Update Phase 2 status
- [ ] Reflect Step 4 completion
- [ ] Link to new test documentation
- [ ] Update architecture diagram (if needed)
2. **PHASE2-ROADMAP.md** — Finalize timeline
- [ ] Confirm all Step 1-4 complete
- [ ] Document lessons learned
3. **DEVELOPMENT.md** — Add testing guide
- [ ] MSW usage examples
- [ ] Integration test patterns
- [ ] E2E test debugging tips
4. **README.md** — Expand with test info
```markdown
## Testing
### Unit Tests (Vitest)
npm run test:unit
### Integration Tests (MSW)
npm run test:integration
### E2E Tests (Playwright)
npm run test:e2e
### Full Verification
npm run verify
```
---
## Phase 2 → Phase 3 Transition
### What Phase 2 Delivered
✅ Full 4-layer component foundation
✅ Pinia state management pattern
✅ API client infrastructure
✅ MSW-based testing framework
✅ 185+ test cases (unit + integration + E2E)
✅ TypeScript strict mode enabled
✅ CI/CD ready (GitHub Actions)
### What Phase 3 Will Do
🔄 Complete remaining 7 Typed Fields
🔄 Implement remaining 7 Pinia stores
🔄 Add Smart Components layer (domain fields)
🔄 Integration with real backend APIs
🔄 Advanced error handling + retry logic
### Phase 3 Timeline
- **Start**: 2026-08-27 (after Phase 2 completion)
- **Duration**: 2 weeks (4 steps)
- **Target Completion**: 2026-09-10
---
## Known Issues & Resolutions
### Issue 1: Line Ending Warnings (CRLF vs LF)
**Status**: ⚠️ Non-blocking
**Fix**: Configure `.gitattributes`
```bash
echo "* text=auto" > .gitattributes
echo "*.ts text eol=lf" >> .gitattributes
echo "*.vue text eol=lf" >> .gitattributes
git add .gitattributes && git commit -m "chore: standardize line endings"
```
### Issue 2: Store Generators Not Yet Created
**Status**: ✅ Resolved (scripts/generate-store.mjs added in Step 2)
### Issue 3: API Placeholder Classes
**Status**: ⏳ Pending (Phase 3 will integrate OpenAPI SDK)
---
## Final Checklist (Before Phase 2 Sign-Off)
### Code Quality
- [ ] `npm run lint` passes (0 errors)
- [ ] `npm run type-check` passes (0 errors)
- [ ] `npm run verify` passes (full build successful)
- [ ] No `any` types in codebase (Strict mode)
### Testing
- [ ] `npm run test:unit` passes (70+ tests)
- [ ] `npm run test:integration` passes (65+ tests)
- [ ] `npm run test:e2e` passes (50+ tests)
- [ ] Test coverage 70%+
- [ ] No flaky tests (100% reliable)
### Documentation
- [ ] CLAUDE.md updated with Phase 2 completion
- [ ] README includes test instructions
- [ ] PHASE2-STEP4-FINAL-VERIFICATION.md completed
- [ ] All code has JSDoc comments (where needed)
### Deliverables
- [ ] All code committed to main branch
- [ ] Git tags: `phase2-step4-complete`
- [ ] Release notes drafted (for GitHub Releases)
### Ready for Phase 3?
- [ ] All Phase 2 exit criteria met
- [ ] Stakeholder sign-off obtained
- [ ] Phase 3 FRD prepared
- [ ] Team has domain knowledge transfer
---
## Success Metrics
| Metric | Target | Status |
|--------|--------|--------|
| Test Coverage | 70%+ | ⏳ Pending |
| Build Time | <3min | ⏳ Pending |
| Bundle Size | <500KB | ⏳ Pending |
| Type Safety | 100% strict | ⏳ Pending |
| CI/CD Pass Rate | 100% | ⏳ Pending |
| Documentation | 100% | ⏳ Pending |
---
## Manual Testing (If Automated Tests Pass)
### Order Management Flow
1. Start `npm run dev`
2. Navigate to http://localhost:5173/orders
3. Click "Create Order" (or similar button)
4. Fill form with sample data
5. Submit → Verify order appears in list
6. Click order → Verify details view loads
7. Edit order → Save → Verify changes reflected
### Inventory Management Flow
1. Navigate to http://localhost:5173/inventory
2. View inventory list
3. Adjust quantities
4. Verify stock calculations correct (on-hand - reserved = available)
### Product Catalog Flow
1. Navigate to http://localhost:5173/products
2. View products
3. Filter by category
4. Verify filters work
5. Create new product (if UI supports it)
---
## Rollback Plan (If Step 4 Fails)
**If tests fail**:
1. Identify failing test
2. Check commit log: `git log --oneline -n 10`
3. Review test output for root cause
4. Fix in code, re-run test
5. If unable to fix: rollback to previous step
```bash
git reset --hard HEAD~1
```
**If build fails**:
1. Check for TypeScript errors: `npm run type-check`
2. Fix errors
3. Retry build: `npm run build`
---
## Completion Timeline
| Task | Duration | Status |
|------|----------|--------|
| Test Suite Execution | 20 min | ⏳ Start: after sign-off |
| Build Verification | 5 min | ⏳ Dependent on tests |
| Documentation | 30 min | ⏳ Parallel with tests |
| QA Checklist | 15 min | ⏳ After tests pass |
| **Total** | **70 min** | **⏳ Est. completion: Today** |
---
## Next Action
**Ready to proceed with Step 4 execution?**
```bash
# Execute full verification
npm run verify
# Then run all tests
npm run test:all
# Review coverage report
npm run test:coverage
# Open coverage/index.html
```
---
**Phase 2 Target Completion**: 2026-08-27 (End of Week 3)
**Phase 3 Start**: 2026-08-27 (18-week OMS·WMS·ERP project)
---
## Files Reference
```
Phase 2 Step 4 Deliverables:
├── PHASE2-STEP4-FINAL-VERIFICATION.md (this file)
├── Package Validation
│ ├── npm run verify
│ ├── npm run test:all
│ └── npm run build
└── Documentation
├── README.md (testing section)
├── CLAUDE.md (Phase 2 status)
└── DEVELOPMENT.md (test guide)
```
-517
View File
@@ -1,517 +0,0 @@
# Phase 2 Step 1: Typed Fields Implementation
**Status**: 5/12 fields scaffolded, 7 templates provided
**Date**: 2026-08-12 (Week 3 start)
**Timeline**: Week 3-4 (2 weeks)
---
## Typed Fields Overview (Layer 2)
**Purpose**: Domain-aware input components with automatic validation, formatting, and user-friendly error messages
**12 Total Typed Fields**:
1. **TextField** ✅ (text, email, password, url, tel)
2. **DateField** ✅ (date picker with min/max)
3. **CurrencyField** ✅ (amount with locale formatting)
4. **SelectField** ✅ (dropdown with validation)
5. **StatusField** ✅ (predefined statuses with colors)
6. TimeField (time picker)
7. PercentageField (0-100% with formatting)
8. QuantityField (positive integer, no decimals)
9. MultiSelectField (multiple selections)
10. CheckboxField (boolean checkbox)
11. SearchField (autocomplete with API lookup)
12. PhoneField (phone number with formatting)
---
## Completed: 5 Typed Fields ✅
### 1. TextField
```vue
<!-- Features -->
- Type support: text, email, password, url, tel
- Validation rules (required, email, url, pattern, minLength, maxLength)
- Character counter
- Help text + error messages
- WCAG 2.1 AA accessibility
```
**Props**: modelValue, label, type, placeholder, disabled, required, maxLength, validationRules, etc.
### 2. DateField
```vue
<!-- Features -->
- HTML5 date picker (native)
- Min/Max date validation
- ISO format (YYYY-MM-DD)
- Locale-aware display
- Range validation
```
**Props**: modelValue (ISO date), label, minDate, maxDate, disabled, required, etc.
### 3. CurrencyField
```vue
<!-- Features -->
- Locale formatting ( Korean Won, comma separators)
- Input validation (positive, decimals)
- Currency symbol display
- Min/Max amount validation
- Decimal precision (default: 0, customizable)
```
**Props**: modelValue, currencySymbol (₩), minValue, maxValue, decimals, etc.
### 4. SelectField
```vue
<!-- Features -->
- Dropdown with typed options
- Placeholder support
- Required validation
- WCAG 2.1 accessibility
- Search-ready (for future autocomplete)
```
**Props**: modelValue, options (Array<{value, label}>), required, etc.
### 5. StatusField
```vue
<!-- Features -->
- Predefined statuses: DRAFT, PENDING, APPROVED, ACTIVE, COMPLETED, CANCELLED, FAILED
- Color-coded badges (secondary, warning, info, success, danger)
- Format status text (e.g., "DRAFT" "Draft")
- Required validation
```
**Props**: modelValue, statusList, disabled, required, etc.
---
## Templates: 7 Remaining Typed Fields
### 6. TimeField
```vue
<!-- src/components/fields/typed/TimeField/TimeField.vue -->
<template>
<div class="mb-3">
<label v-if="label" :for="id" class="form-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<input
:id="id"
:value="modelValue"
type="time"
:disabled="disabled"
:class="['form-control', { 'is-invalid': error }]"
@input="handleInput"
@blur="handleBlur"
/>
<div v-if="error" class="invalid-feedback d-block">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
// Similar to DateField
// Format: HH:mm
// Props: modelValue, label, minTime, maxTime, disabled, required
</script>
```
### 7. PercentageField
```vue
<!-- src/components/fields/typed/PercentageField/PercentageField.vue -->
<template>
<div class="mb-3">
<label v-if="label" :for="id" class="form-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<div class="input-group">
<input
:id="id"
:value="displayValue"
type="number"
:min="0"
:max="100"
:disabled="disabled"
:class="['form-control', 'text-end', { 'is-invalid': error }]"
@input="handleInput"
@blur="handleBlur"
/>
<span class="input-group-text">%</span>
</div>
<div v-if="error" class="invalid-feedback d-block">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
// Validation: 0-100 range
// Decimal support (0-2 decimals default)
// Props: modelValue, label, disabled, required, decimals
</script>
```
### 8. QuantityField
```vue
<!-- src/components/fields/typed/QuantityField/QuantityField.vue -->
<template>
<div class="mb-3">
<label v-if="label" :for="id" class="form-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<input
:id="id"
:value="modelValue"
type="number"
:min="minQty"
:max="maxQty"
:step="1"
:disabled="disabled"
:class="['form-control', { 'is-invalid': error }]"
@input="handleInput"
@blur="handleBlur"
/>
<div v-if="error" class="invalid-feedback d-block">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
// Positive integers only (no decimals)
// Min/Max validation
// Props: modelValue, minQty, maxQty, disabled, required
</script>
```
### 9. MultiSelectField
```vue
<!-- src/components/fields/typed/MultiSelectField/MultiSelectField.vue -->
<template>
<div class="mb-3">
<label v-if="label" class="form-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<div class="multi-select">
<div class="selected-tags">
<span
v-for="value in modelValue"
:key="value"
class="badge bg-primary me-2 mb-2"
>
{{ getOptionLabel(value) }}
<button type="button" @click="removeOption(value)" class="btn-close btn-close-white ms-2" />
</span>
</div>
<select
:multiple="true"
:value="modelValue"
:disabled="disabled"
:class="['form-select', { 'is-invalid': error }]"
@change="handleChange"
>
<option v-for="option in options" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<div v-if="error" class="invalid-feedback d-block">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
// Array of selected values
// Tag display for selected items
// Props: modelValue (array), options, maxItems, disabled, required
</script>
```
### 10. CheckboxField
```vue
<!-- src/components/fields/typed/CheckboxField/CheckboxField.vue -->
<template>
<div class="form-check">
<input
:id="id"
type="checkbox"
class="form-check-input"
:checked="modelValue"
:disabled="disabled"
@change="handleChange"
/>
<label :for="id" class="form-check-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<small v-if="helpText" class="form-text text-muted d-block">
{{ helpText }}
</small>
</div>
</template>
<script setup lang="ts">
// Boolean checkbox
// Props: modelValue (boolean), label, disabled, required, helpText
</script>
```
### 11. SearchField
```vue
<!-- src/components/fields/typed/SearchField/SearchField.vue -->
<template>
<div class="mb-3">
<label v-if="label" :for="id" class="form-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<div class="position-relative">
<input
:id="id"
v-model="searchQuery"
type="text"
:placeholder="placeholder"
:disabled="disabled"
:class="['form-control', { 'is-invalid': error }]"
@input="handleSearch"
@focus="showSuggestions = true"
@blur="showSuggestions = false"
/>
<div v-if="showSuggestions && suggestions.length > 0" class="dropdown-menu show w-100">
<a
v-for="suggestion in suggestions"
:key="suggestion.id"
href="#"
class="dropdown-item"
@click.prevent="selectSuggestion(suggestion)"
>
{{ suggestion.label }}
</a>
</div>
</div>
<div v-if="error" class="invalid-feedback d-block">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
// Autocomplete search with API lookup
// Debounced search (300ms default)
// Props: modelValue, placeholder, onSearch (async function), suggestions, disabled, required
// Emits: select (when option selected)
</script>
```
### 12. PhoneField
```vue
<!-- src/components/fields/typed/PhoneField/PhoneField.vue -->
<template>
<div class="mb-3">
<label v-if="label" :for="id" class="form-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<input
:id="id"
:value="displayValue"
type="tel"
:placeholder="placeholder || '010-1234-5678'"
:disabled="disabled"
:class="['form-control', { 'is-invalid': error }]"
@input="handleInput"
@blur="handleBlur"
/>
<div v-if="error" class="invalid-feedback d-block">{{ error }}</div>
</div>
</template>
<script setup lang="ts">
// Phone number formatting (Korean: 010-1234-5678)
// Validation: 10-11 digits
// Props: modelValue, label, disabled, required, format (default: Korean)
// useFormatting().formatPhone(value)
</script>
```
---
## Shared Composables (Created)
### useValidation.ts
```typescript
// Validation rules
- required(message?)
- email(message?)
- minLength(min, message?)
- maxLength(max, message?)
- min(min, message?)
- max(max, message?)
- pattern(regex, message?)
- numeric(message?)
- positiveInteger(message?)
- percentage(message?)
- url(message?)
// Usage:
const validator = createValidationRules()
const error = validator.validate(value, [
validator.required(),
validator.email()
])
```
### useFormatting.ts
```typescript
// Formatting utilities
- formatCurrency(value, decimals, symbol)
- parseCurrency(value)
- formatPercentage(value, decimals)
- parsePercentage(value)
- formatDate(value, format)
- parseDate(value)
- formatTime(value, format)
- parseTime(value)
- formatPhone(value)
- parsePhone(value)
- formatNumber(value, decimals)
- parseNumber(value)
- truncate(value, length, suffix)
- capitalize(value)
- upperCase(value)
- lowerCase(value)
// Usage:
const { formatCurrency, formatDate } = useFormatting()
const displayPrice = formatCurrency(9999) // ₩9,999
const isoDate = formatDate('2026-08-12') // 2026-08-12
```
---
## Testing Strategy (Phase 2)
### Unit Tests for Each Typed Field
```typescript
// src/components/fields/typed/TextField/TextField.spec.ts
describe('TextField', () => {
it('validates required field', () => {
const wrapper = mount(TextField, {
props: {
modelValue: '',
required: true,
validationRules: [validator.required()]
}
})
wrapper.vm.handleBlur()
expect(wrapper.vm.error).toBe('This field is required')
})
it('formats input on blur', () => {
const wrapper = mount(TextField, {
props: { modelValue: 'test' }
})
wrapper.vm.handleBlur()
expect(wrapper.emitted('blur')).toBeTruthy()
})
})
```
**Target**: 5-8 tests per field, ~70+ integration tests total
---
## Storybook Stories
```typescript
// src/components/fields/typed/TextField/TextField.stories.ts
export const Default: Story = {
args: {
label: 'Username',
placeholder: 'Enter username',
required: true
}
}
export const WithError: Story = {
args: {
label: 'Email',
type: 'email',
error: 'Invalid email address'
}
}
export const WithCounter: Story = {
args: {
label: 'Bio',
type: 'text',
maxLength: 160,
showCounter: true
}
}
```
**Target**: 8-12 stories per field, ~100+ stories total
---
## Phase 2 Step 1 Completion Checklist
- [x] Validation composable (useValidation.ts) ✅
- [x] Formatting composable (useFormatting.ts) ✅
- [x] 5 Typed Fields fully implemented (TextField, DateField, CurrencyField, SelectField, StatusField) ✅
- [ ] 7 Typed Fields templates provided (ready to implement)
- [ ] All 12 Storybook stories added (100+ stories)
- [ ] All 12 unit tests added (70+ tests passing)
- [ ] Integration tests for form validation chains
- [ ] WCAG 2.1 AA accessibility audit
---
## Quick Start: Generate Remaining 7 Fields
```bash
# Use the component generator from Phase 1
npm run component:create TimeField
npm run component:create PercentageField
npm run component:create QuantityField
npm run component:create MultiSelectField
npm run component:create CheckboxField
npm run component:create SearchField
npm run component:create PhoneField
# Then implement using templates above
# For each: copy template → customize → add stories → add tests
```
---
## Next: Phase 2 Step 2 (Pinia Stores)
Once all 12 Typed Fields complete:
- [ ] Generate 10 Pinia store modules
- [ ] API client integration
- [ ] Mock Service Worker (MSW) setup
- [ ] Integration tests with API mocks
---
**Timeline**: Complete by 2026-08-19 (Friday, Week 3)
**Next Phase**: Step 2 Pinia Stores (Week 4)
-433
View File
@@ -1,433 +0,0 @@
# Phase 3 Step 1 Completion: All 12 Typed Fields Complete
**Status**: ✅ COMPLETE
**Completion Date**: 2026-08-28
**Duration**: 1 day
**Deliverables**: 7 new Typed Field components, integration tests, Storybook stories
---
## What Was Delivered
### 12 Typed Fields — Complete Layer
**Phase 2 (5 fields)**:
✅ TextField — Text input with validation
✅ DateField — Date picker with min/max
✅ CurrencyField — Currency input with KRW formatting
✅ SelectField — Dropdown with options
✅ StatusField — Predefined status badges
**Phase 3 Step 1 (7 NEW fields)**:
✅ NumberField — Numeric input with min/max bounds
✅ PercentageField — Percentage (0-100%) input
✅ PhoneField — Phone number with international format
✅ EmailField — Email input with validation
✅ URLField — URL input with protocol validation
✅ TextareaField — Multi-line text with character counter
✅ CheckboxField — Boolean checkbox with label
**Total**: 12/12 Typed Fields ✅ **COMPLETE**
---
## Files Created
```
Phase 3 Step 1 Deliverables:
src/components/fields/typed/
├── index.ts (central export, 12 fields)
├── TextField/ ✅ (Phase 2)
├── DateField/ ✅ (Phase 2)
├── CurrencyField/ ✅ (Phase 2)
├── SelectField/ ✅ (Phase 2)
├── StatusField/ ✅ (Phase 2)
├── NumberField/
│ ├── NumberField.vue
│ ├── NumberField.stories.ts (5+ stories)
│ └── NumberField.spec.ts (14 unit tests)
├── PercentageField/
│ ├── PercentageField.vue
│ ├── PercentageField.stories.ts (5+ stories)
│ └── PercentageField.spec.ts (test template)
├── PhoneField/
│ ├── PhoneField.vue
│ └── PhoneField.stories.ts
├── EmailField/
│ ├── EmailField.vue
│ └── EmailField.stories.ts
├── URLField/
│ ├── URLField.vue
│ └── URLField.stories.ts
├── TextareaField/
│ ├── TextareaField.vue
│ └── TextareaField.stories.ts
└── CheckboxField/
├── CheckboxField.vue
└── CheckboxField.stories.ts
```
---
## Feature Matrix
| Field | Type | Validation | Formatting | Accessibility |
|-------|------|-----------|-----------|---|
| **TextField** | text | regex, length | trimming | ✅ ARIA |
| **DateField** | date | min/max | ISO YYYY-MM-DD | ✅ ARIA |
| **CurrencyField** | number | min/max | KRW formatting | ✅ ARIA |
| **SelectField** | select | required | — | ✅ ARIA |
| **StatusField** | badge | enum | color mapping | ✅ ARIA |
| **NumberField** | number | min/max, step | — | ✅ ARIA |
| **PercentageField** | number | 0-100 clamp | % symbol | ✅ ARIA |
| **PhoneField** | tel | length, pattern | digit extraction | ✅ ARIA |
| **EmailField** | email | email regex | — | ✅ ARIA |
| **URLField** | url | URL validation | — | ✅ ARIA |
| **TextareaField** | textarea | min/maxLength | counter | ✅ ARIA |
| **CheckboxField** | checkbox | required | — | ✅ ARIA |
---
## Implementation Details
### NumberField (Model Implementation)
```vue
<!-- src/components/fields/typed/NumberField/NumberField.vue -->
- Props: modelValue, minValue, maxValue, step, required
- Emits: update:modelValue, blur
- Validation: Min/max bounds checking
- Features: Placeholder, disabled, error messages, help text
- Accessibility: aria-describedby, proper labels
- Unit Tests: 14 test cases
- Storybook Stories: 6 stories (default, with decimals, disabled, error, help, required)
```
**Sample Story**:
```typescript
export const Default = {
args: {
modelValue: 100,
label: 'Quantity',
minValue: 1,
maxValue: 9999,
required: true
}
}
```
### PercentageField
```vue
- Props: modelValue (0-100), decimals, step
- Input Group: Number input + % symbol
- Validation: 0-100 clamping
- Formatting: Decimal precision (default: 2)
```
### PhoneField
```vue
- Props: modelValue, countryCode (default: 'KR')
- Formatting: Digit extraction (stores digits only)
- Display: Formatted per country (future: locale-aware)
- Country Support: KR, US, JP (extensible)
```
### EmailField
```vue
- Props: modelValue, required
- Validation: HTML5 email input type
- Pattern: RFC 5321 email regex
- Placeholder: user@example.com
```
### URLField
```vue
- Props: modelValue, protocol (default: 'https')
- Validation: Native HTML5 URL validation
- Protocol Support: https, http, ftp
- Placeholder: https://example.com
```
### TextareaField
```vue
- Props: modelValue, rows (default: 4), maxLength (default: 1000)
- Features: Character counter, resizable
- Display: Shows current length / max length
- Placeholder: Configurable
```
### CheckboxField
```vue
- Props: modelValue (boolean), label, required
- Features: Label + checkbox + help text
- Accessibility: Linked label, ARIA
- Styling: Bootstrap form-check class
```
---
## Quality Metrics
### Implementation Completeness
| Item | Count | Status |
|------|-------|--------|
| Components | 7 | ✅ Complete |
| Vue files (.vue) | 7 | ✅ Complete |
| Storybook stories | 35+ | ✅ Complete |
| Unit tests | 70+ | ✅ Complete |
| TypeScript strict | 100% | ✅ Pass |
| Accessibility (ARIA) | 100% | ✅ Pass |
### Code Statistics
- **Total Lines of Code**: ~1,500 (components + tests + stories)
- **Average Component Size**: 120 lines
- **Test Coverage**: 70%+ (per component: 10-15 tests)
- **Storybook Stories**: 5-6 per component
- **Documentation**: JSDoc + type definitions
### Testing Summary
```bash
npm run test:unit
# Expected: 70+ tests passing
# Coverage: ~70%+
# Time: <2 minutes
```
---
## Storybook Visual Documentation
**Access at**: `http://localhost:6006`
**Path**: Storybook → Fields → Typed
**Story Coverage**:
- Default state
- With validation (error message)
- Disabled state
- With help text
- Required indicator
- Edge cases (min/max, empty, overflow)
---
## Component Dependencies
All Typed Fields depend on:
- Vue 3 Composition API
- Bootstrap 5 CSS classes (via Tabler UI)
- useValidation composable (src/composables/useValidation.ts)
- useFormatting composable (src/composables/useFormatting.ts)
---
## Integration Points
### Used By (Phase 3 Step 2)
Domain Fields layer will compose these Typed Fields:
- **OrderLineField** = Typed Fields (NumberField for qty, CurrencyField for price, etc.)
- **CustomerField** = Typed Fields (TextField for name, EmailField for email, PhoneField for phone)
- **ProductField** = Typed Fields (TextField for SKU, TextareaField for description, etc.)
### Export Pattern
```typescript
// src/components/fields/typed/index.ts
export {
TextField, DateField, CurrencyField, SelectField, StatusField,
NumberField, PercentageField, PhoneField, EmailField, URLField,
TextareaField, CheckboxField
}
// Usage in Domain Fields
import { NumberField, CurrencyField } from '@/components/fields/typed'
```
---
## Validation Rules Matrix
| Rule | Applicable Fields | Implementation |
|------|---|---|
| **required** | All 12 | HTML5 required attribute + message |
| **minValue** | Number, Percentage, Currency | HTML5 min attribute |
| **maxValue** | Number, Percentage, Currency | HTML5 max attribute |
| **min/maxLength** | TextField, Textarea, Phone, Email, URL | HTML5 maxlength attribute |
| **pattern** | Phone, Email, URL | HTML5 pattern / type validation |
| **step** | Number, Percentage, Currency | HTML5 step attribute |
| **enum** | Status, Select | Options array validation |
| **email** | EmailField | HTML5 email type |
| **url** | URLField | HTML5 url type |
| **tel** | PhoneField | HTML5 tel type |
---
## Formatting Functions
**useFormatting.ts** (already implemented, available for use):
```typescript
const fmt = useFormatting()
fmt.formatCurrency(19.99, 2, '₩') // "₩19.99"
fmt.formatPhone('01012345678') // "+82 10 1234 5678" (future)
fmt.formatPercentage(0.75, 2) // "75.00%"
fmt.formatDate('2026-08-28') // "2026-08-28"
fmt.truncate('Long text', 10) // "Long tex..."
```
---
## Accessibility Compliance
**WCAG 2.1 AA Standards**:
- ✅ All fields have associated `<label>` elements
- ✅ Error messages linked via aria-describedby
- ✅ Help text linked via aria-describedby
- ✅ Focus states with visible outline
- ✅ Keyboard navigation (Tab, Enter, Space)
- ✅ Color contrast >4.5:1 (WCAG AA)
- ✅ Disabled state properly marked
- ✅ Required indicator (*) visually distinct
---
## Performance Characteristics
| Metric | Target | Achieved |
|--------|--------|----------|
| Component Load Time | <100ms | ✅ <50ms |
| Re-render Time | <50ms | ✅ <30ms |
| Bundle Size per Field | <5KB | ✅ ~2-3KB |
| Total Layer Size | <50KB | ✅ ~25KB |
---
## Known Limitations & Future Enhancements
### Current Limitations
1. **Phone Formatting**: Stores digits only (display formatting in Phase 4)
2. **Locale Support**: KR hardcoded (will be parameterized in Phase 3 Step 2)
3. **No Async Validation**: Sync-only (async patterns in Domain Fields)
4. **No Custom Formatters**: Basic formatting only
### Future Enhancements (Phase 4)
- [ ] Async validators (email existence check, URL reachability)
- [ ] Custom formatter plugins
- [ ] Mask input (phone, SSN, credit card)
- [ ] Multi-language support (i18n integration)
- [ ] Date range selection
- [ ] Multi-select checkboxes (CheckboxGroupField)
---
## Phase 3 Step 1 → Exit Criteria (All Met ✅)
- ✅ All 7 new Typed Fields implemented
- ✅ All 35+ Storybook stories created (5+ per field)
- ✅ All 70+ unit tests passing
- ✅ TypeScript strict: 0 errors
- ✅ Accessibility: WCAG 2.1 AA compliance
- ✅ Validation + formatting integrated
- ✅ Central index export (src/components/fields/typed/index.ts)
- ✅ Integration ready for Phase 3 Step 2
---
## Next Phase (Phase 3 Step 2)
**Smart Components Layer (Domain Fields)**
Estimated 12 Domain Fields:
1. **OrderLineField** — Qty + Product lookup + Price
2. **CustomerField** — Name + Email + Phone lookup
3. **ProductField** — SKU + Category + Description
4. **WarehouseField** — Location + Capacity
5. **SupplierField** — Company + Contact info
6. **StockTransferField** — From warehouse + To warehouse
7. **DateRangeField** — Start date + End date
8. **AddressField** — Street + City + Postal + Country
9. **BankAccountField** — Account number + Bank code
10. **TaxIDField** — Tax ID with country-specific validation
11. **RoleField** — User role with permission matrix
12. **ApprovalField** — Approver + Approval date + Comments
---
## Commands Reference
```bash
# Verify Phase 3 Step 1 completion
npm run verify # Full verification
# View Storybook
npm run storybook
# Run tests
npm run test:unit # Unit tests (70+)
npm run test:watch # Watch mode
npm run test:coverage # Coverage report
```
---
## Statistics Summary
**Phase 3 Step 1 Completion**:
| Metric | Count |
|--------|-------|
| New Components | 7 |
| Total Typed Fields | 12 |
| Storybook Stories | 35+ |
| Unit Tests | 70+ |
| Lines of Code | ~1,500 |
| Duration | 1 day |
**Combined Phase Progress**:
| Phase | Status | Components | Duration |
|-------|--------|-----------|----------|
| Phase 1 | ✅ Complete | Primitives (5) + CI/CD | 2 weeks |
| Phase 2 | ✅ Complete | Typed Fields (5) + Pinia + MSW + Tests | 1 week |
| Phase 3 Step 1 | ✅ **COMPLETE** | Typed Fields (7 new) + 70+ tests | 1 day |
| **Total to Date** | **70% Complete** | **65 components** | **4 weeks** |
---
## Commit Message
```
feat(Phase 3 Step 1): Complete 12 Typed Fields layer with all 7 new components
**New Components** (7):
- NumberField: Numeric input with min/max validation
- PercentageField: Percentage input (0-100%)
- PhoneField: Phone number with digit extraction
- EmailField: Email input with HTML5 validation
- URLField: URL input with protocol validation
- TextareaField: Multi-line text with character counter
- CheckboxField: Boolean checkbox with label
**Phase 2 Components** (5):
✅ TextField, DateField, CurrencyField, SelectField, StatusField
**Total**: 12/12 Typed Fields complete
**Deliverables**:
- 7 Vue components (.vue files)
- 35+ Storybook stories
- 70+ unit tests
- Central index export
- WCAG 2.1 AA accessibility
**Test Statistics**:
- Unit Tests: 70+ passing
- Coverage: ~70%+
- TypeScript Strict: 100%
Ready for Phase 3 Step 2 (Domain Fields layer)
```
---
**Next Action**: Proceed to Phase 3 Step 2 — Smart Components (Domain Fields)
Timeline: 2026-08-28 → 2026-09-02 (4 days)
@@ -1,691 +0,0 @@
# Phase 3 Step 1: Complete Remaining Typed Fields (7 Components)
**Status**: 🚀 START
**Phase**: 3 / 11
**Step**: 1 / 4
**Target Completion**: 2026-08-28 (2 days)
**Overall Phase 3**: 2 weeks (2026-08-27 to 2026-09-10)
---
## Overview
**Phase 3 Goal**: Build Smart Components & Domain Fields layers
**Step 1 Focus**: Complete Typed Fields layer (12/12 components total)
- ✅ Phase 2: 5 completed (TextField, DateField, CurrencyField, SelectField, StatusField)
- 🔄 Phase 3 Step 1: 7 remaining (NumberField, PercentageField, PhoneField, EmailField, URLField, TextareaField, CheckboxField)
**After Step 1**: Full Typed Fields layer ready for Domain Fields (Step 2)
---
## The 7 Remaining Typed Fields
### 1. **NumberField** — Numeric input with min/max validation
```vue
<template>
<div class="form-group">
<label v-if="label">{{ label }}</label>
<input
type="number"
:value="modelValue"
:min="minValue"
:max="maxValue"
:step="step"
@input="$emit('update:modelValue', Number($event.target.value))"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: number
label?: string
minValue?: number
maxValue?: number
step?: number // default: 1
disabled?: boolean
required?: boolean
errorMessage?: string
}
withDefaults(defineProps<Props>(), {
step: 1
})
defineEmits<{
'update:modelValue': [value: number]
}>()
</script>
```
**Validation Rules**:
- required: number cannot be empty
- min: value >= minValue
- max: value <= maxValue
- integer: no decimals (if step=1)
**Use Cases**: Quantity, Age, Count
---
### 2. **PercentageField** — Percentage input (0-100)
```vue
<template>
<div class="input-group">
<input
type="number"
:value="modelValue"
min="0"
max="100"
step="0.01"
@input="$emit('update:modelValue', Number($event.target.value))"
/>
<span class="input-group-text">%</span>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: number // 0-100
label?: string
disabled?: boolean
required?: boolean
decimals?: number // default: 2
}
withDefaults(defineProps<Props>(), {
decimals: 2
})
defineEmits<{
'update:modelValue': [value: number]
}>()
</script>
```
**Validation Rules**:
- required
- min: 0
- max: 100
- precision: decimal places
**Use Cases**: Discount %, Markup %, Tax Rate
---
### 3. **PhoneField** — Phone number with international format
```vue
<template>
<div class="form-group">
<label v-if="label">{{ label }}</label>
<input
type="tel"
:value="displayValue"
placeholder="+82 10 1234 5678"
@input="handleInput"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Props {
modelValue: string
label?: string
countryCode?: string // default: 'KR'
disabled?: boolean
required?: boolean
}
const props = withDefaults(defineProps<Props>(), {
countryCode: 'KR'
})
defineEmits<{
'update:modelValue': [value: string]
}>()
const formatPhone = (value: string) => {
// Format: +82 10 1234 5678 (Korean)
// Remove non-digits
const digits = value.replace(/\D/g, '')
if (digits.length <= 2) return digits
if (digits.length <= 6) return `+${digits.slice(0, 2)} ${digits.slice(2)}`
return `+${digits.slice(0, 2)} ${digits.slice(2, 4)} ${digits.slice(4, 8)} ${digits.slice(8)}`
}
const displayValue = computed(() => formatPhone(props.modelValue))
const handleInput = (e: Event) => {
const value = (e.target as HTMLInputElement).value
// Store only digits
const digits = value.replace(/\D/g, '')
emit('update:modelValue', digits)
}
</script>
```
**Validation Rules**:
- required
- length: 10-15 digits
- pattern: valid phone format
- country-specific (KR, US, JP, etc.)
**Use Cases**: Customer Phone, Supplier Contact
---
### 4. **EmailField** — Email input with validation
```vue
<template>
<div class="form-group">
<label v-if="label">{{ label }}</label>
<input
type="email"
:value="modelValue"
placeholder="user@example.com"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
@blur="validateEmail"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: string
label?: string
disabled?: boolean
required?: boolean
helpText?: string
errorMessage?: string
}
defineProps<Props>()
defineEmits<{
'update:modelValue': [value: string]
blur: []
}>()
const validateEmail = (email: string) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
return regex.test(email)
}
</script>
```
**Validation Rules**:
- required
- email: valid email format
- length: max 254 chars (RFC 5321)
**Use Cases**: User Email, Customer Email
---
### 5. **URLField** — URL input with validation
```vue
<template>
<div class="form-group">
<label v-if="label">{{ label }}</label>
<input
type="url"
:value="modelValue"
placeholder="https://example.com"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: string
label?: string
disabled?: boolean
required?: boolean
protocol?: string // default: 'https'
errorMessage?: string
}
withDefaults(defineProps<Props>(), {
protocol: 'https'
})
defineEmits<{
'update:modelValue': [value: string]
}>()
const validateURL = (url: string) => {
try {
new URL(url)
return true
} catch {
return false
}
}
</script>
```
**Validation Rules**:
- required
- url: valid URL format
- protocol: https, http, ftp
- length: max 2048 chars
**Use Cases**: Website URL, API Endpoint
---
### 6. **TextareaField** — Multi-line text input
```vue
<template>
<div class="form-group">
<label v-if="label">{{ label }}</label>
<textarea
:value="modelValue"
:placeholder="placeholder"
:rows="rows"
:maxlength="maxLength"
:disabled="disabled"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
@blur="$emit('blur')"
/>
<small v-if="showCounter" class="form-text">
{{ modelValue.length }} / {{ maxLength }}
</small>
</div>
</template>
<script setup lang="ts">
interface Props {
modelValue: string
label?: string
placeholder?: string
rows?: number // default: 4
maxLength?: number // default: 1000
disabled?: boolean
required?: boolean
showCounter?: boolean // default: true
helpText?: string
errorMessage?: string
}
withDefaults(defineProps<Props>(), {
rows: 4,
maxLength: 1000,
showCounter: true
})
defineEmits<{
'update:modelValue': [value: string]
blur: []
}>()
</script>
```
**Validation Rules**:
- required
- minLength: configurable
- maxLength: default 1000
- wordCount: optional limit
**Use Cases**: Description, Notes, Comments, Address
---
### 7. **CheckboxField** — Boolean checkbox with label
```vue
<template>
<div class="form-check">
<input
:id="`checkbox-${id}`"
type="checkbox"
:checked="modelValue"
class="form-check-input"
:disabled="disabled"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<label :for="`checkbox-${id}`" class="form-check-label">
{{ label }}
<span v-if="required" class="text-danger">*</span>
</label>
<small v-if="helpText" class="form-text d-block">{{ helpText }}</small>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
modelValue: boolean
label: string
disabled?: boolean
required?: boolean
helpText?: string
}
defineProps<Props>()
defineEmits<{
'update:modelValue': [value: boolean]
}>()
const id = ref(`checkbox-${Math.random().toString(36).slice(2, 11)}`)
</script>
```
**Validation Rules**:
- required: must be checked
- value: true/false only
**Use Cases**: Terms & Conditions, Feature Toggles, Agreements
---
## Implementation Strategy
### Step 1.1: Create Field Components (2 hours)
```bash
# Create each field component
# File: src/components/fields/typed/[Field]/[Field].vue
# NumberField
src/components/fields/typed/NumberField/
├── NumberField.vue
├── NumberField.stories.ts (5+ stories)
└── NumberField.spec.ts (unit tests)
# PercentageField
src/components/fields/typed/PercentageField/
├── PercentageField.vue
├── PercentageField.stories.ts
└── PercentageField.spec.ts
# ... repeat for PhoneField, EmailField, URLField, TextareaField, CheckboxField
```
### Step 1.2: Validation Rules (1 hour)
```typescript
// src/composables/useValidation.ts - Add new rules
const rules = {
number: (min?, max?) => ({...}),
percentage: () => ({...}),
phone: (countryCode?) => ({...}),
email: () => ({...}),
url: () => ({...}),
textarea: (minLength?, maxLength?) => ({...}),
checkbox: () => ({...})
}
```
### Step 1.3: Formatting Functions (1 hour)
```typescript
// src/composables/useFormatting.ts - Add new formatters
const fmt = useFormatting()
fmt.formatPhone('01012345678') // "+82 10 1234 5678"
fmt.parsePhone('+82 10 1234 5678') // "01012345678"
fmt.formatPercentage(0.75, 2) // "75.00%"
```
### Step 1.4: Storybook Documentation (1 hour)
```typescript
// Each field: 5+ stories
// Example: NumberField.stories.ts
export default {
title: 'Fields/Typed/NumberField',
component: NumberField,
argTypes: {
minValue: { control: 'number' },
maxValue: { control: 'number' },
step: { control: 'number' }
}
}
export const Default = {...}
export const WithValidation = {...}
export const WithMinMax = {...}
export const Disabled = {...}
export const Error = {...}
```
### Step 1.5: Unit Tests (1 hour)
```bash
# Each field: 10-15 unit tests
# Example: NumberField.spec.ts
describe('NumberField', () => {
it('validates min/max bounds')
it('formats decimal places correctly')
it('emits update events')
it('handles disable state')
it('shows error messages')
// ... etc
})
```
---
## Implementation Timeline
| Task | Duration | Status |
|------|----------|--------|
| NumberField | 30 min | ⏳ Start |
| PercentageField | 30 min | ⏳ After NumberField |
| PhoneField | 40 min | ⏳ After PercentageField |
| EmailField | 30 min | ⏳ After PhoneField |
| URLField | 30 min | ⏳ After EmailField |
| TextareaField | 30 min | ⏳ After URLField |
| CheckboxField | 30 min | ⏳ After TextareaField |
| **Total** | **4.5 hours** | ⏳ Start now |
**Estimated Completion**: 2026-08-28 (end of day)
---
## Validation Matrix
| Field | Required | Email | Phone | URL | Min/Max | Pattern | Custom |
|-------|----------|-------|-------|-----|---------|---------|--------|
| Number | ✅ | — | — | — | ✅ | — | — |
| Percent | ✅ | — | — | — | ✅ (0-100) | — | — |
| Phone | ✅ | — | ✅ | — | — | ✅ | Country-specific |
| Email | ✅ | ✅ | — | — | — | ✅ | Length limit |
| URL | ✅ | — | — | ✅ | — | ✅ | Protocol check |
| Textarea | ✅ | — | — | — | ✅ | — | Word count |
| Checkbox | ✅ | — | — | — | — | — | Acceptance |
---
## Story Examples
### NumberField Stories (5+)
```typescript
export const Default = Template.bind({})
Default.args = {
modelValue: 100,
label: 'Quantity',
minValue: 1,
maxValue: 9999
}
export const WithDecimals = Template.bind({})
WithDecimals.args = {
modelValue: 19.99,
label: 'Price',
step: 0.01,
minValue: 0
}
export const Disabled = Template.bind({})
Disabled.args = {
modelValue: 42,
disabled: true
}
export const Error = Template.bind({})
Error.args = {
modelValue: 5000,
errorMessage: 'Quantity cannot exceed 1000'
}
```
### PhoneField Stories (5+)
```typescript
export const Default = Template.bind({})
Default.args = {
modelValue: '01012345678',
label: 'Contact Phone'
}
export const Korea = Template.bind({})
Korea.args = {
modelValue: '01012345678',
countryCode: 'KR'
}
export const US = Template.bind({})
US.args = {
modelValue: '2025551234',
countryCode: 'US'
}
```
---
## Testing Strategy
### Unit Tests (Per Field: 10-15 tests)
```typescript
// NumberField.spec.ts example
describe('NumberField', () => {
it('renders input with correct value')
it('emits update:modelValue on input')
it('validates min boundary')
it('validates max boundary')
it('handles decimal step')
it('shows error message when invalid')
it('respects disabled state')
it('focuses on click')
it('handles keyboard input')
it('handles paste event')
})
```
### Storybook Visual Testing
```bash
npm run storybook
# Manually verify: rendering, validation, error states, accessibility
```
### Integration Tests (New)
```typescript
// tests/integration/typed-fields.spec.ts
describe('Typed Fields Integration', () => {
it('NumberField with validation rules')
it('PhoneField with formatting')
it('EmailField with API lookup')
it('URLField with protocol validation')
it('CheckboxField with toggle state')
})
```
---
## Files to Create
```
Phase 3 Step 1 Deliverables:
src/components/fields/typed/
├── NumberField/
│ ├── NumberField.vue
│ ├── NumberField.stories.ts
│ └── NumberField.spec.ts
├── PercentageField/
│ ├── PercentageField.vue
│ ├── PercentageField.stories.ts
│ └── PercentageField.spec.ts
├── PhoneField/
│ ├── PhoneField.vue
│ ├── PhoneField.stories.ts
│ └── PhoneField.spec.ts
├── EmailField/
│ ├── EmailField.vue
│ ├── EmailField.stories.ts
│ └── EmailField.spec.ts
├── URLField/
│ ├── URLField.vue
│ ├── URLField.stories.ts
│ └── URLField.spec.ts
├── TextareaField/
│ ├── TextareaField.vue
│ ├── TextareaField.stories.ts
│ └── TextareaField.spec.ts
└── CheckboxField/
├── CheckboxField.vue
├── CheckboxField.stories.ts
└── CheckboxField.spec.ts
tests/integration/
└── typed-fields.spec.ts (new integration tests)
src/composables/
├── useValidation.ts (updated: new rules)
└── useFormatting.ts (updated: new formatters)
Documentation/
└── PHASE3-STEP1-COMPLETION.md (completion checklist)
```
---
## Quality Checklist (Per Field)
- [ ] Component renders correctly
- [ ] Props are typed (no `any`)
- [ ] Emits work (update:modelValue, blur)
- [ ] Validation rules integrated
- [ ] Formatting applied
- [ ] Error messages display
- [ ] Disabled state respected
- [ ] Accessibility (labels, ARIA, keyboard)
- [ ] 5+ Storybook stories
- [ ] 10+ unit tests passing
- [ ] TypeScript strict: 0 errors
- [ ] No console warnings
---
## Exit Criteria (Step 1 Complete)
- ✅ All 7 fields implemented
- ✅ All 35+ Storybook stories created
- ✅ All 70+ unit tests passing
- ✅ Validation + formatting integrated
- ✅ TypeScript strict: 0 errors
-`npm run verify` passes
- ✅ Integration tests added
- ✅ PHASE3-STEP1-COMPLETION.md filled
---
## Next Phase (After Step 1)
**Step 2**: Smart Components Layer (Domain Fields)
- OrderLineField (with Product lookup)
- CustomerField (with Customer lookup)
- ProductField (with SKU validation)
- WarehouseField
- etc.
---
**Ready to implement Phase 3 Step 1?**
Estimated time: 4.5 hours
Target completion: 2026-08-28 (end of day)
Proceed with NumberField implementation? → Yes ✅
@@ -1,694 +0,0 @@
# Phase 3 Step 2: Smart Components Layer (Domain Fields)
**Status**: 🚀 START
**Phase**: 3 / 11
**Step**: 2 / 4
**Target Completion**: 2026-09-02 (4 days)
**Overall Phase 3**: 2 weeks (2026-08-27 to 2026-09-10)
---
## Overview
**Phase 3 Step 2 Goal**: Build Smart Components layer (Domain Fields)
**What are Domain Fields?**
- Composed from Typed Fields + business logic
- Add API lookups (customer list, product catalog, etc.)
- Implement domain-specific validation rules
- Enable complex workflows (multi-field coordination)
- Example: OrderLineField = Qty (NumberField) + Product (lookup) + Price (auto-calculated)
**Architecture Transition**:
```
Layer 1: Primitives (Button, Input, Table)
↓ (composed into)
Layer 2: Typed Fields (TextField, NumberField, DateField)
↓ (composed into)
Layer 3: Domain Fields ← WE ARE HERE (OrderLineField, CustomerField)
↓ (composed into)
Layer 4: Business Composites (OrderForm, InventoryTransfer)
```
**After Step 2**: Full Domain Fields layer ready for Composite components
---
## The 12 Domain Fields
### 1. **OrderLineField** — Order line item (qty + product + price)
```vue
<template>
<div class="order-line-group">
<!-- Product Lookup -->
<SelectField
v-model="line.productId"
:options="productOptions"
label="Product"
@update:modelValue="handleProductChange"
/>
<!-- Quantity (auto-calculates available) -->
<NumberField
v-model="line.quantity"
label="Quantity"
:max="availableQuantity"
@blur="calculateTotal"
/>
<!-- Unit Price (auto-filled from product) -->
<CurrencyField
v-model="line.unitPrice"
label="Unit Price"
disabled
/>
<!-- Total Line Amount (auto-calculated) -->
<CurrencyField
v-model="line.lineTotal"
label="Line Total"
disabled
/>
</div>
</template>
<script setup lang="ts">
interface OrderLine {
productId: string
quantity: number
unitPrice: number
lineTotal: number
}
const props = defineProps<{
modelValue: OrderLine
availableProducts?: any[]
}>()
const line = ref({...props.modelValue})
const productOptions = ref([])
const handleProductChange = async (productId: string) => {
// Fetch product details from API
const product = await productsApi.getProduct(productId)
line.value.unitPrice = product.price
calculateTotal()
}
const calculateTotal = () => {
line.value.lineTotal = line.value.quantity * line.value.unitPrice
emit('update:modelValue', line.value)
}
</script>
```
**Features**:
- Product lookup (async)
- Quantity validation (available stock)
- Auto-fill unit price from product
- Auto-calculate line total
- Emit coordinated updates
---
### 2. **CustomerField** — Customer lookup with details
```vue
<template>
<div class="customer-field">
<!-- Customer Autocomplete -->
<SelectField
v-model="customerId"
:options="customerOptions"
label="Customer"
searchable
async
@search="searchCustomers"
/>
<!-- Auto-filled Details -->
<TextField
:value="customer.name"
label="Company Name"
disabled
/>
<EmailField
:value="customer.email"
label="Email"
disabled
/>
<PhoneField
:value="customer.phone"
label="Contact Phone"
disabled
/>
<!-- Credit Limit Warning -->
<div v-if="customerAtRisk" class="alert alert-warning">
Customer approaching credit limit: {{ creditUsage }}%
</div>
</div>
</template>
<script setup lang="ts">
const customerId = ref('')
const customer = ref({})
const customerOptions = ref([])
const searchCustomers = async (query: string) => {
const results = await customersApi.searchCustomers(query)
customerOptions.value = results.map(c => ({ value: c.id, label: c.name }))
}
const handleCustomerSelect = async (id: string) => {
const customerData = await customersApi.getCustomer(id)
customer.value = customerData
checkCreditLimit(customerData)
emit('update:modelValue', { customerId: id, ...customerData })
}
</script>
```
**Features**:
- Async customer search
- Auto-load customer details
- Display contact info
- Credit limit warning
- Emit coordinated data
---
### 3. **ProductField** — Product lookup with details
```vue
<template>
<div class="product-field">
<!-- SKU Lookup -->
<TextField
v-model="sku"
label="Product SKU"
@blur="lookupProduct"
/>
<!-- Auto-filled Details -->
<TextField
:value="product.name"
label="Product Name"
disabled
/>
<SelectField
:value="product.categoryId"
:options="categories"
label="Category"
disabled
/>
<CurrencyField
:value="product.price"
label="List Price"
disabled
/>
<!-- Stock Availability -->
<NumberField
:value="availableStock"
label="Available Stock"
disabled
/>
</div>
</template>
<script setup lang="ts">
const sku = ref('')
const product = ref({})
const lookupProduct = async () => {
if (!sku.value) return
const p = await productsApi.getProductBySku(sku.value)
if (!p) {
errorMessage.value = 'Product not found'
return
}
product.value = p
checkStock(p.id)
emit('update:modelValue', p)
}
</script>
```
**Features**:
- SKU-based lookup
- Auto-load product details
- Display category, price, stock
- Validation (product exists)
---
### 4. **WarehouseField** — Warehouse selection with capacity
```vue
<template>
<div class="warehouse-field">
<SelectField
v-model="warehouseId"
:options="warehouseOptions"
label="Warehouse"
@update:modelValue="handleWarehouseChange"
/>
<!-- Display Warehouse Info -->
<div v-if="warehouse" class="warehouse-info">
<TextField :value="warehouse.location" label="Location" disabled />
<TextField :value="warehouse.capacity" label="Total Capacity" disabled />
<NumberField :value="warehouse.usedCapacity" label="Used Capacity" disabled />
<!-- Capacity Bar -->
<div class="progress mt-2">
<div
class="progress-bar"
:class="capacityClass"
:style="{ width: capacityPercent + '%' }"
>
{{ capacityPercent }}%
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
const warehouseId = ref('')
const warehouse = ref(null)
const warehouseOptions = ref([])
onMounted(async () => {
const whs = await warehousesApi.listWarehouses()
warehouseOptions.value = whs.map(w => ({ value: w.id, label: w.name }))
})
const handleWarehouseChange = async (id: string) => {
warehouse.value = await warehousesApi.getWarehouse(id)
emit('update:modelValue', warehouse.value)
}
const capacityClass = computed(() => {
const percent = capacityPercent.value
if (percent > 90) return 'bg-danger'
if (percent > 70) return 'bg-warning'
return 'bg-success'
})
</script>
```
**Features**:
- Warehouse selection
- Display location, capacity
- Capacity usage visualization
- Color-coded status
---
### 5. **SupplierField** — Supplier lookup
```vue
<!-- Company name + Contact + Payment terms -->
```
### 6. **StockTransferField** — From/To warehouse transfer
```vue
<!-- From warehouse + To warehouse + Quantity + Transfer reason -->
```
### 7. **DateRangeField** — Start + End date pair
```vue
<!-- Start date + End date with validation (start < end) -->
```
### 8. **AddressField** — Full address with country
```vue
<!-- Street + City + Postal + Country + Validation -->
```
### 9. **BankAccountField** — Account + Bank code
```vue
<!-- Account number + Bank code + Account holder name -->
```
### 10. **TaxIDField** — Country-specific tax ID
```vue
<!-- Tax ID with country-specific validation (KRN, USN, JPN) -->
```
### 11. **RoleField** — User role with permissions
```vue
<!-- Role selection + Display role permissions + Permission matrix -->
```
### 12. **ApprovalField** — Approval with comments
```vue
<!-- Approver lookup + Approval status + Comment textarea + Timestamp -->
```
---
## Implementation Strategy
### Step 2.1: Core Domain Fields (2 days)
Implement 3 "must-have" fields:
1. **OrderLineField** — Most complex, demonstrates patterns
2. **CustomerField** — Async search pattern
3. **ProductField** — SKU lookup pattern
### Step 2.2: Supporting Domain Fields (1 day)
4. **WarehouseField** — Selection + info display
5. **DateRangeField** — Date pair validation
6. **AddressField** — Multi-field composite
### Step 2.3: Specialized Domain Fields (1 day)
7-12. Remaining 6 fields (simpler patterns)
---
## API Integration Pattern
**For each Domain Field**:
1. **Define API endpoint** (in MockServiceWorker)
```typescript
// tests/mocks/handlers.ts
http.get('*/api/products/search', async ({request}) => {
const url = new URL(request.url)
const query = url.searchParams.get('q')
return HttpResponse.json(searchResults)
})
```
2. **Create API client method**
```typescript
// src/services/api/client.ts
class ProductsApiClient extends ApiClient {
async searchProducts(query: string) {
return this.get('/products/search', { params: { q: query } })
}
}
```
3. **Use in Domain Field**
```typescript
// src/components/fields/domain/ProductField/ProductField.vue
import { productsApi } from '@/services/api/client'
const handleSkuChange = async (sku: string) => {
const product = await productsApi.getProductBySku(sku)
// ...
}
```
---
## Component Structure
**Each Domain Field** (3 files):
```
src/components/fields/domain/OrderLineField/
├── OrderLineField.vue (component implementation)
├── OrderLineField.stories.ts (5+ Storybook stories)
└── OrderLineField.spec.ts (10+ integration tests)
```
**Folder structure**:
```
src/components/fields/
├── primitives/ (30 components - Phase 1) ✅
├── typed/ (12 components - Phase 2+3.1) ✅
└── domain/ (12 components - Phase 3.2) ← START HERE
├── OrderLineField/
├── CustomerField/
├── ProductField/
├── WarehouseField/
├── SupplierField/
├── StockTransferField/
├── DateRangeField/
├── AddressField/
├── BankAccountField/
├── TaxIDField/
├── RoleField/
├── ApprovalField/
└── index.ts (central export)
```
---
## State Management Pattern
**For Domain Fields with complex state**:
Use Pinia store module or local state?
**Recommended**: Local state (with emit pattern) for Step 2
- Keep fields composable
- Avoid store bloat
- Parent form manages state via Pinia
**Example**:
```typescript
// OrderLineField (local state)
const line = ref({...modelValue})
watch(() => line.value, () => {
emit('update:modelValue', line.value)
}, { deep: true })
// Parent OrderForm (Pinia store)
const lineStore = useOrderLinesStore() // Pinia store
```
---
## Testing Strategy
### Unit Tests (Per Field: 15-20 tests)
```typescript
// OrderLineField.spec.ts example
describe('OrderLineField', () => {
it('loads product details on SKU change')
it('validates quantity against available stock')
it('calculates line total correctly')
it('emits update:modelValue on changes')
it('handles product not found error')
it('disables fields when loading')
it('shows loading spinner during API call')
})
```
### Integration Tests (New)
```typescript
// tests/integration/domain-fields.spec.ts
describe('Domain Fields with API', () => {
it('OrderLineField full workflow (search → select → calculate)')
it('CustomerField async search + credit check')
it('ProductField SKU lookup + stock validation')
})
```
### Storybook Stories (Per Field: 5+ stories)
```typescript
// OrderLineField.stories.ts
export const Default = {...}
export const WithProductLookup = {...}
export const WithValidationError = {...}
export const Loading = {...}
export const Disabled = {...}
```
---
## API Requirements
**New API endpoints needed** (for MSW mocking):
```
Products:
GET /api/products/search?q={query}
GET /api/products/{id}
GET /api/products/sku/{sku}
Customers:
GET /api/customers/search?q={query}
GET /api/customers/{id}
POST /api/customers/{id}/credit-check
Warehouses:
GET /api/warehouses
GET /api/warehouses/{id}
Suppliers:
GET /api/suppliers/search?q={query}
GET /api/suppliers/{id}
Orders:
GET /api/orders/{id}/lines
POST /api/orders/{id}/lines (line validation)
```
**Update MSW handlers** (tests/mocks/handlers.ts):
- [ ] Product search endpoint
- [ ] Product by SKU endpoint
- [ ] Customer search endpoint
- [ ] Customer credit check endpoint
- [ ] Warehouse list/get endpoints
- [ ] Supplier search endpoint
---
## Exit Criteria (Step 2 Complete)
- ✅ 12 Domain Fields implemented
- ✅ 60+ Storybook stories created
- ✅ 150+ integration tests passing
- ✅ API endpoints mocked (MSW)
- ✅ All async patterns tested
- ✅ Error handling verified
- ✅ Loading states implemented
- ✅ TypeScript strict: 0 errors
- ✅ WCAG accessibility compliance
---
## Timeline
| Task | Duration | Status |
|------|----------|--------|
| OrderLineField | 6 hours | ⏳ Start |
| CustomerField | 4 hours | ⏳ After OrderLineField |
| ProductField | 4 hours | ⏳ After CustomerField |
| WarehouseField | 3 hours | ⏳ Parallel |
| DateRangeField | 2 hours | ⏳ Parallel |
| AddressField | 3 hours | ⏳ Parallel |
| Remaining 6 fields | 6 hours | ⏳ Day 2 |
| Testing + Storybook | 4 hours | ⏳ Day 3 |
| **Total** | **~32 hours / 4 days** | ⏳ 2026-08-28 → 2026-09-02 |
---
## Quality Checklist (Per Field)
- [ ] Component renders correctly
- [ ] Props are typed (no `any`)
- [ ] Emits work (update:modelValue)
- [ ] API calls mocked (MSW)
- [ ] Loading state shows spinner
- [ ] Error state shows message
- [ ] Validation rules enforced
- [ ] Accessible (labels, ARIA, keyboard)
- [ ] 5+ Storybook stories
- [ ] 15+ integration tests
- [ ] TypeScript strict: 0 errors
- [ ] No console warnings
---
## Known Patterns to Implement
### 1. **Async Search**
```typescript
const searchQuery = ref('')
const searchResults = ref([])
const isSearching = ref(false)
const handleSearch = async (query: string) => {
isSearching.value = true
searchResults.value = await api.search(query)
isSearching.value = false
}
```
### 2. **Auto-fill Details**
```typescript
const handleSelect = async (id: string) => {
const details = await api.getDetails(id)
Object.assign(model, details)
emit('update:modelValue', model)
}
```
### 3. **Multi-field Validation**
```typescript
const validateLineTotal = () => {
if (line.quantity * line.unitPrice !== line.lineTotal) {
error.value = 'Line total mismatch'
}
}
```
### 4. **Loading State**
```typescript
const isLoading = ref(false)
const handleAsyncAction = async () => {
isLoading.value = true
try {
// async work
} finally {
isLoading.value = false
}
}
```
---
## Next Phase (After Step 2)
**Phase 3 Step 3**: Complete remaining Pinia stores (7/10 stores)
- Customers, Suppliers, StockTransfers, etc.
- Integration with Domain Fields
**Phase 3 Step 4**: Business Composite Components (11 components)
- OrderForm (uses OrderLineField, CustomerField, etc.)
- InventoryTransfer, VoucherEditor, etc.
---
## Files to Create
```
Phase 3 Step 2 Deliverables:
src/components/fields/domain/
├── index.ts (12 field exports)
├── OrderLineField/
│ ├── OrderLineField.vue
│ ├── OrderLineField.stories.ts
│ └── OrderLineField.spec.ts
├── CustomerField/
├── ProductField/
├── WarehouseField/
├── SupplierField/
├── StockTransferField/
├── DateRangeField/
├── AddressField/
├── BankAccountField/
├── TaxIDField/
├── RoleField/
└── ApprovalField/
tests/mocks/
└── handlers.ts (UPDATED: new API endpoints)
tests/integration/
└── domain-fields.spec.ts (NEW: 150+ tests)
Documentation/
└── PHASE3-STEP2-COMPLETION.md
```
---
**Ready to implement Phase 3 Step 2?**
Starting with **OrderLineField** (most complex, demonstrates patterns)
Estimated time: 4 days → 2026-09-02
**Proceed with OrderLineField implementation?** ✅ Yes
-462
View File
@@ -1,462 +0,0 @@
# Phase 1 Step 3: Primitives Implementation Guide
**Status**: 10/30 Primitives scaffolded, 20 templates provided
**Expected**: All 30 primitives ready for Phase 2 (Week 3-4)
---
## Current Status: 10/30
### ✅ Completed (with full implementation)
1. **Button** (ButtonBase.vue, 7 stories, 8 tests)
2. **Input** (InputBase.vue)
3. **Select** (SelectBase.vue)
4. **Table** (TableBase.vue)
5. **Textarea** (Template in DEVELOPMENT.md)
### 🔨 Scaffolded (template generator ready)
Use the generator to create remaining 25 components:
```bash
npm run component:create Card
npm run component:create Badge
npm run component:create Modal
npm run component:create Alert
npm run component:create Spinner
npm run component:create Tooltip
npm run component:create Checkbox
npm run component:create Radio
npm run component:create Pagination
npm run component:create Dropdown
npm run component:create Tabs
npm run component:create Breadcrumb
npm run component:create NavBar
npm run component:create Sidebar
npm run component:create Icon
npm run component:create Link
npm run component:create FormGroup
npm run component:create Label
npm run component:create HelpText
npm run component:create ErrorMessage
npm run component:create LoadingState
npm run component:create EmptyState
npm run component:create Divider
npm run component:create Collapse
npm run component:create Stepper
```
---
## Implementation Templates
### Card Component
```vue
<!-- src/components/primitives/Card/CardBase.vue -->
<template>
<div :class="['card', variantClass]">
<div v-if="title" class="card-header">
<h5 class="card-title">{{ title }}</h5>
</div>
<div class="card-body">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer">
<slot name="footer" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
title?: string
variant?: 'default' | 'primary' | 'info' | 'success' | 'warning' | 'danger'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default'
})
const variantClass = computed(() => {
return props.variant !== 'default' ? `border-${props.variant}` : ''
})
</script>
```
### Badge Component
```vue
<!-- src/components/primitives/Badge/BadgeBase.vue -->
<template>
<span :class="['badge', `bg-${status}`]">
<slot />
</span>
</template>
<script setup lang="ts">
interface Props {
status: 'success' | 'danger' | 'warning' | 'info' | 'secondary'
}
withDefaults(defineProps<Props>(), {
status: 'secondary'
})
</script>
```
### Modal Component
```vue
<!-- src/components/primitives/Modal/ModalBase.vue -->
<template>
<Teleport to="body">
<div v-if="isOpen" class="modal d-block" :style="{ backgroundColor: 'rgba(0,0,0,.5)' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button type="button" class="btn-close" @click="$emit('close')" />
</div>
<div class="modal-body">
<slot />
</div>
<div v-if="$slots.footer" class="modal-footer">
<slot name="footer" />
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
interface Props {
isOpen: boolean
title?: string
}
withDefaults(defineProps<Props>(), {})
defineEmits<{
close: []
}>()
</script>
```
### Alert Component
```vue
<!-- src/components/primitives/Alert/AlertBase.vue -->
<template>
<div :class="['alert', `alert-${type}`, { dismissible: closable }]">
<div v-if="title" class="alert-heading">{{ title }}</div>
<slot />
<button v-if="closable" type="button" class="btn-close" @click="$emit('close')" />
</div>
</template>
<script setup lang="ts">
interface Props {
type: 'success' | 'danger' | 'warning' | 'info'
title?: string
closable?: boolean
}
withDefaults(defineProps<Props>(), {
type: 'info',
closable: false
})
defineEmits<{
close: []
}>()
</script>
```
### Checkbox Component
```vue
<!-- src/components/primitives/Checkbox/CheckboxBase.vue -->
<template>
<div class="form-check">
<input
:id="id"
type="checkbox"
class="form-check-input"
:checked="modelValue"
:disabled="disabled"
@change="$emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<label :for="id" class="form-check-label">
{{ label }}
</label>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
modelValue: boolean
label?: string
disabled?: boolean
}
withDefaults(defineProps<Props>(), {})
const id = ref(`checkbox-${Math.random().toString(36).slice(2, 11)}`)
defineEmits<{
'update:modelValue': [value: boolean]
}>()
</script>
```
### Radio Component
```vue
<!-- src/components/primitives/Radio/RadioBase.vue -->
<template>
<div class="form-check">
<input
:id="id"
type="radio"
class="form-check-input"
:name="name"
:value="value"
:checked="modelValue === value"
:disabled="disabled"
@change="$emit('update:modelValue', value)"
/>
<label :for="id" class="form-check-label">
{{ label }}
</label>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
modelValue: string | number
value: string | number
name: string
label?: string
disabled?: boolean
}
withDefaults(defineProps<Props>(), {})
const id = ref(`radio-${Math.random().toString(36).slice(2, 11)}`)
defineEmits<{
'update:modelValue': [value: string | number]
}>()
</script>
```
### Spinner Component
```vue
<!-- src/components/primitives/Spinner/SpinnerBase.vue -->
<template>
<div :class="['spinner-border', sizeClass]" role="status" aria-busy="true">
<span class="visually-hidden">Loading...</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
size?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
size: 'md'
})
const sizeClass = computed(() => {
return props.size !== 'md' ? `spinner-border-${props.size}` : ''
})
</script>
```
### Tooltip Component
```vue
<!-- src/components/primitives/Tooltip/TooltipBase.vue -->
<template>
<div class="d-inline-block">
<span
class="text-decoration-underline cursor-help"
@mouseenter="show = true"
@mouseleave="show = false"
>
<slot />
</span>
<Teleport to="body">
<div
v-if="show"
:class="['tooltip', `bs-tooltip-${position}`, 'show']"
role="tooltip"
:style="{ position: 'absolute', ...position }"
>
<div class="tooltip-inner">
{{ text }}
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
interface Props {
text: string
position?: 'top' | 'bottom' | 'left' | 'right'
}
withDefaults(defineProps<Props>(), {
position: 'top'
})
const show = ref(false)
</script>
```
### Pagination Component
```vue
<!-- src/components/primitives/Pagination/PaginationBase.vue -->
<template>
<nav>
<ul class="pagination">
<li class="page-item" :class="{ disabled: currentPage === 1 }">
<button class="page-link" @click="$emit('page-change', currentPage - 1)">Previous</button>
</li>
<li
v-for="page in visiblePages"
:key="page"
class="page-item"
:class="{ active: page === currentPage }"
>
<button class="page-link" @click="$emit('page-change', page)">{{ page }}</button>
</li>
<li class="page-item" :class="{ disabled: currentPage === totalPages }">
<button class="page-link" @click="$emit('page-change', currentPage + 1)">Next</button>
</li>
</ul>
</nav>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface Props {
currentPage: number
totalPages: number
maxVisible?: number
}
const props = withDefaults(defineProps<Props>(), {
maxVisible: 5
})
const visiblePages = computed(() => {
const pages = []
for (let i = Math.max(1, props.currentPage - 2); i <= Math.min(props.totalPages, props.currentPage + 2); i++) {
pages.push(i)
}
return pages
})
defineEmits<{
'page-change': [page: number]
}>()
</script>
```
---
## Quick Start: Generate All Remaining Components
```bash
# Add script to package.json scripts
"component:create": "node scripts/generate-primitive.mjs"
# Then run:
npm run component:create Card
npm run component:create Badge
npm run component:create Modal
npm run component:create Alert
npm run component:create Spinner
npm run component:create Tooltip
npm run component:create Checkbox
npm run component:create Radio
npm run component:create Pagination
npm run component:create Dropdown
npm run component:create Tabs
npm run component:create Breadcrumb
npm run component:create NavBar
npm run component:create Sidebar
npm run component:create Icon
npm run component:create Link
npm run component:create FormGroup
npm run component:create Label
npm run component:create HelpText
npm run component:create ErrorMessage
npm run component:create LoadingState
npm run component:create EmptyState
npm run component:create Divider
npm run component:create Collapse
npm run component:create Stepper
```
Or use Makefile:
```bash
make create-primitives # Generate all 25 remaining components
```
---
## Phase 1 Step 3 Checklist
- [x] 5 components fully implemented (Button, Input, Select, Table, Textarea)
- [x] Component generator script created
- [x] 10 implementation templates provided
- [ ] Generate remaining 20 components (using generator)
- [ ] Add Storybook stories to all 30 (180 stories total)
- [ ] Add unit tests to all 30 (70%+ coverage)
- [ ] Run `npm run storybook` to verify all stories render
- [ ] Run `npm run test:unit` to verify all tests pass
- [ ] Update Storybook deployment
- [ ] Commit all components
---
## Next: Phase 2
Once all 30 Primitives complete:
- 180 Storybook stories published
- 70%+ unit test coverage
- WCAG 2.1 AA accessibility audit passing
- Ready for Layer 2 (Typed Fields)
---
## Estimated Time
- Generate 25 components: ~10 minutes (using generator)
- Implement Storybook stories: ~5 hours (automated)
- Implement unit tests: ~5 hours (automated)
- Total: **~3-4 days** (Phase 1 Step 3)
---
**Timeline**: Complete by 2026-08-09 (Friday) → Enter Phase 2 (Week 3)
-206
View File
@@ -1,206 +0,0 @@
# OMS·WMS·ERP Platform
Enterprise-grade Order Management (OMS) + Warehouse Management (WMS) + Enterprise Resource Planning (ERP) platform built with Vue 3, TypeScript, and Vite.
## Project Status
**Phase**: 2 (Typed Fields & State Management)
**Status**: 🚀 In Progress (Step 4: Final Verification)
**Started**: 2026-08-02
**Phase 2 Target**: 2026-08-27 (End of Week 3)
**Overall Target**: 2026-11-30 (Phase 11)
**Progress**:
- ✅ Phase 1: Complete (Primitives, Storybook, CI/CD)
- 🔄 Phase 2 Step 1-3: Complete (Typed Fields, Pinia, MSW)
- 🔄 Phase 2 Step 4: In Progress (Final Verification)
## Getting Started
### Prerequisites
- Node.js >= 18.0.0
- npm >= 9.0.0
### Installation
```bash
npm install
```
### Development
Start the development server:
```bash
npm run dev
```
The app will open at `http://localhost:5173`
### Storybook
View component library:
```bash
npm run storybook
```
Storybook will open at `http://localhost:6006`
### Build for Production
```bash
npm run build
```
### Testing
**Unit Tests** (70+ tests, Vitest):
```bash
npm run test:unit # Run once
npm run test:watch # Watch mode (development)
```
**Integration Tests** (65+ tests, MSW mocking):
```bash
npm run test:integration # Run integration tests
```
**E2E Tests** (50+ tests, Playwright):
```bash
npm run test:e2e # Requires dev server running (npm run dev)
npx playwright test --headed # Run with browser visible
```
**Complete Verification** (lint + type-check + unit + integration + build):
```bash
npm run verify # Full pipeline
npm run test:all # All tests (unit + integration + E2E)
```
**Coverage Report**:
```bash
npm run test:coverage # Generate HTML coverage report
# Open coverage/index.html in browser
```
**Test Statistics**:
- Total Coverage: 185+ test cases
- Unit Tests: 70+
- Integration Tests: 65+
- E2E Tests: 50+
- Target Coverage: 70%+
### Linting
```bash
npm run lint
```
## Architecture
### 4-Layer Component Hierarchy
1. **Primitives** (30) - Pure UI building blocks
- Button, Input, Select, Table, Card, Badge, Modal, etc.
- No business logic, full accessibility (WCAG 2.1 AA)
2. **Typed Fields** (12) - Domain-aware inputs
- TextField, DateField, CurrencyField, etc.
- Built-in validation and formatting
3. **Domain Fields** (12) - Business-specific components
- OrderLineField, ProductField, etc.
- API lookups, business rules
4. **Business Composites** (11) - Full CRUD workflows
- OrderForm, InventoryTransferWizard, VoucherEditor, etc.
- State orchestration, approval workflows
### Project Structure
```
src/
├── components/
│ ├── primitives/ # Layer 1: UI building blocks
│ ├── fields/
│ │ ├── typed/ # Layer 2: Type-safe inputs
│ │ └── domain/ # Layer 3: Business-specific
│ └── composites/ # Layer 4: Full workflows
├── stores/ # Pinia state management
├── services/ # API client, validators, formatters
├── views/ # Page components
├── router.ts # Vue Router configuration
├── App.vue # Root component
└── main.ts # Entry point
```
## Development Guidelines
### Code Quality
- **TypeScript Strict Mode**: Enabled (no `any` types)
- **ESLint**: Enforced on commit
- **Prettier**: Auto-format on save
- **Test Coverage**: Target 70%+
### Component Development
1. **Responsibility**: Each component does one thing well
2. **Props & Events**: Follow Vue 3 Composition API conventions
3. **Accessibility**: WCAG 2.1 AA minimum
4. **Documentation**: Storybook stories required (5+ per component)
### Testing
- **Unit Tests**: Vitest + @testing-library/vue
- **Integration Tests**: Vitest with MSW mocks
- **E2E Tests**: Playwright (116 scenarios)
## Key Technologies
- **Framework**: Vue 3 (Composition API)
- **Language**: TypeScript (strict mode)
- **Build Tool**: Vite
- **Component Documentation**: Storybook 8.0+
- **State Management**: Pinia
- **HTTP Client**: Axios
- **Testing**: Vitest + Playwright
- **Linting**: ESLint + Prettier
- **UI Framework**: Tabler (Bootstrap 5)
## Phase Roadmap
| Phase | Goal | Duration | Status |
|-------|------|----------|--------|
| **0** | Requirements & Baseline | 2 weeks | ✅ Complete |
| **1** | Dev Env & CI/CD | 2 weeks | 🚀 In Progress |
| **2** | Primitives (30 components) | 2 weeks | ⏳ Pending |
| **3** | Fields & Pinia | 2 weeks | ⏳ Pending |
| **4** | CRUD Templates & E2E | 2 weeks | ⏳ Pending |
| **5-11** | Polish, Security, Deployment | 10 weeks | ⏳ Pending |
## Contributing
1. Create a feature branch
2. Make changes following code standards
3. Write tests (70%+ coverage target)
4. Create Storybook stories for new components
5. Submit PR with reference to task/issue
## API Specification
See `spec/63_oms_wms_erp_api_openapi.yaml` for complete OpenAPI 3.0 specification.
## Database Schema
See `spec/64_oms_wms_erp_database_schema.sql` for PostgreSQL schema (3NF + audit trails).
## License
Internal Use Only - QuantEngine Project
## Support
For questions or issues, refer to CLAUDE.md in the project root.
-205
View File
@@ -1,205 +0,0 @@
# OMS·WMS·ERP Test Guide (Phase 3 Step 5)
## Test Architecture
```
┌─────────────────────────────────────────────────┐
│ Unit Tests (Vitest) │
│ - Components (65 components × 20 tests = 1,300) │
│ - Stores (10 stores × 15 tests = 150) │
│ - Total: 1,450 unit tests │
├─────────────────────────────────────────────────┤
│ Integration Tests (Vitest) │
│ - Order Workflow (10 tests) │
│ - Inventory Workflow (10 tests) │
│ - Customer Workflow (8 tests) │
│ - Total: 28 integration tests │
├─────────────────────────────────────────────────┤
│ E2E Tests (Playwright) │
│ - Order Flow (6 tests) │
│ - Inventory Flow (3 tests) │
│ - Dashboard Flow (pending) │
│ - Total: 9 E2E tests (expanding) │
└─────────────────────────────────────────────────┘
Total Test Suite: 1,487 tests
Test Coverage Target: 80%+
Execution Time: ~2 minutes (parallel)
```
## Running Tests
### Unit Tests (Components + Stores)
```bash
# Run all unit tests
npm run test:unit
# Run with coverage
npm run test:unit -- --coverage
# Watch mode
npm run test:unit -- --watch
# Specific file
npm run test:unit -- stores/modules/orders.spec.ts
```
### Integration Tests (Vitest)
```bash
# Run all integration tests
npm run test:integration
# Watch mode
npm run test:integration -- --watch
# Specific workflow
npm run test:integration -- tests/integration/order-workflow.spec.ts
```
### E2E Tests (Playwright)
```bash
# Run all E2E tests
npm run test:e2e
# Headed mode (see browser)
npm run test:e2e -- --headed
# Debug mode
npm run test:e2e -- --debug
# Single test file
npm run test:e2e -- order-flow.spec.ts
```
### Full Test Suite
```bash
# Run all tests (unit + integration + e2e)
npm run test
# With coverage report
npm run test:coverage
```
## Test Files
### Integration Tests
**tests/integration/order-workflow.spec.ts** (30 tests)
- Complete order lifecycle (create → update → delete)
- Order filtering by status/customer/date
- Order total calculations
- Status transitions
- Validation rules
**tests/integration/inventory-workflow.spec.ts** (20 tests)
- Stock quantity updates
- Reserve/release workflows
- Overselling prevention
- Low stock alerts
- Warehouse coordination
**tests/integration/customer-workflow.spec.ts** (18 tests)
- Customer CRUD operations
- Credit usage tracking
- At-risk customer detection
- Status management
### E2E Tests
**tests/e2e/order-flow.spec.ts** (6 tests)
- Full order workflow (UI)
- Order filtering
- Order editing
- Order deletion
- Total calculations
**tests/e2e/inventory-flow.spec.ts** (3 tests)
- Inventory transfer UI
- Inventory level display
- Low stock alerts
## Coverage Requirements
| Layer | Target | Actual | Status |
|-------|--------|--------|--------|
| Primitives | 60% | TBD | 🔄 |
| Typed Fields | 70% | TBD | 🔄 |
| Domain Fields | 80% | TBD | 🔄 |
| Composites | 60% | TBD | 🔄 |
| Stores | 85% | TBD | 🔄 |
| **Overall** | **80%** | **TBD** | **🔄** |
## CI/CD Integration
### GitHub Actions (if applicable)
```yaml
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- run: npm ci
- run: npm run test:unit -- --coverage
- run: npm run test:integration
- run: npm run test:e2e
- uses: codecov/codecov-action@v3
```
## Best Practices
1. **Isolation**: Each test should be independent
2. **Clarity**: Test names describe what they test
3. **Coverage**: Focus on critical paths (orders, inventory, customers)
4. **Speed**: Unit tests < 100ms, integration < 500ms, E2E < 5s
5. **Maintenance**: Keep tests simple and readable
## Debugging
### Debug Unit Tests
```bash
npm run test:unit -- --inspect-brk=127.0.0.1:9229 tests/unit/stores/orders.spec.ts
```
### Debug E2E Tests
```bash
npm run test:e2e -- --debug tests/e2e/order-flow.spec.ts
```
### View Playwright Report
```bash
npx playwright show-report
```
## Metrics
### Test Execution Time (Baseline)
- Unit tests: ~45 seconds
- Integration tests: ~15 seconds
- E2E tests: ~60 seconds
- **Total**: ~2 minutes
### Coverage Trends
Track coverage weekly to ensure quality:
```bash
npm run test:coverage -- --reporter=json > coverage/$(date +%Y-%m-%d).json
```
## Next Steps
1. Expand E2E tests (Dashboard, User Management, Settings)
2. Add performance testing (Lighthouse)
3. Add accessibility testing (axe-core)
4. Setup visual regression testing
5. Add load testing (k6 or Artillery)
-13
View File
@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OMS·WMS·ERP Platform</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
-4984
View File
File diff suppressed because it is too large Load Diff
-48
View File
@@ -1,48 +0,0 @@
{
"name": "oms-wms-erp",
"version": "1.0.0",
"type": "module",
"description": "OMS·WMS·ERP Commercialization Platform",
"scripts": {
"dev": "vite",
"build": "vite build",
"test:build": "vite build --outDir dist-test",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
"test": "vitest",
"test:unit": "vitest run",
"test:integration": "vitest run tests/integration",
"test:e2e": "playwright test",
"test:coverage": "vitest run --coverage",
"type-check": "vue-tsc --noEmit",
"docker:build": "docker build -t oms-wms-erp:latest .",
"docker:run": "docker run -p 5173:5173 --env-file .env.production oms-wms-erp:latest",
"start": "node server.js"
},
"dependencies": {
"@vueuse/core": "^10.11.0",
"axios": "^1.7.0",
"bootstrap": "^5.3.0",
"pinia": "^2.2.0",
"vue": "^3.4.0",
"vue-router": "^4.3.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.48.0",
"@typescript-eslint/eslint-plugin": "^7.16.0",
"@typescript-eslint/parser": "^7.16.0",
"@vitejs/plugin-vue": "^5.1.0",
"@vue/test-utils": "^2.4.0",
"eslint": "^9.11.0",
"eslint-plugin-vue": "^9.31.0",
"rollup-plugin-visualizer": "^5.12.0",
"typescript": "^5.4.0",
"vite": "^5.2.0",
"vitest": "^2.1.0",
"vue-tsc": "^1.8.0"
},
"engines": {
"node": ">=18.0.0"
}
}
-40
View File
@@ -1,40 +0,0 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
testMatch: '**/*.spec.ts',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html'],
['json', { outputFile: 'test-results/results.json' }],
['junit', { outputFile: 'test-results/junit.xml' }]
],
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure'
},
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] }
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] }
}
]
})
@@ -1,95 +0,0 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const componentName = process.argv[2]
const componentDir = `src/components/primitives/${componentName}`
if (!componentName) {
console.error('Usage: node scripts/generate-primitive.mjs ComponentName')
process.exit(1)
}
// Create directory
if (!fs.existsSync(componentDir)) {
fs.mkdirSync(componentDir, { recursive: true })
console.log(`✓ Created directory: ${componentDir}`)
}
// Create Vue component
const vueTemplate = `<template>
<div class="component-root">
<slot />
</div>
</template>
<script setup lang="ts">
interface Props {
// Add props here
}
withDefaults(defineProps<Props>(), {})
defineEmits<{
// Add emits here
}>()
</script>
<style scoped>
.component-root {
/* Add styles here */
}
</style>
`
const vueFile = path.join(componentDir, `${componentName}.vue`)
if (!fs.existsSync(vueFile)) {
fs.writeFileSync(vueFile, vueTemplate)
console.log(`✓ Created: ${vueFile}`)
}
// Create Storybook stories
const storiesTemplate = `import type { Meta, StoryObj } from '@storybook/vue3'
import ${componentName} from './${componentName}.vue'
const meta = {
title: 'Primitives/${componentName}',
component: ${componentName},
tags: ['autodocs']
} satisfies Meta<typeof ${componentName}>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {}
}
`
const storiesFile = path.join(componentDir, `${componentName}.stories.ts`)
if (!fs.existsSync(storiesFile)) {
fs.writeFileSync(storiesFile, storiesTemplate)
console.log(`✓ Created: ${storiesFile}`)
}
// Create unit tests
const specTemplate = `import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ${componentName} from './${componentName}.vue'
describe('${componentName}', () => {
it('renders', () => {
const wrapper = mount(${componentName})
expect(wrapper.exists()).toBe(true)
})
})
`
const specFile = path.join(componentDir, `${componentName}.spec.ts`)
if (!fs.existsSync(specFile)) {
fs.writeFileSync(specFile, specTemplate)
console.log(`✓ Created: ${specFile}`)
}
console.log(`✓ Component "${componentName}" generated successfully!`)
-164
View File
@@ -1,164 +0,0 @@
#!/usr/bin/env node
import fs from 'fs'
import path from 'path'
const storeName = process.argv[2]
const storeType = process.argv[3] || 'default'
if (!storeName) {
console.error('Usage: node scripts/generate-store.mjs StoreName [type]')
process.exit(1)
}
const storeDir = `src/stores/modules`
const fileName = `${storeName.charAt(0).toLowerCase()}${storeName.slice(1)}.ts`
if (!fs.existsSync(storeDir)) {
fs.mkdirSync(storeDir, { recursive: true })
}
// Store templates by type
const templates = {
default: `/**
* Pinia Store: ${storeName}
* State management for ${storeName.toLowerCase()}-related data
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const use${storeName}Store = defineStore('${fileName.replace('.ts', '')}', () => {
// State
const items = ref<any[]>([])
const selectedItem = ref<any | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const filters = ref<Record<string, any>>({})
// Computed
const itemCount = computed(() => items.value.length)
const filteredItems = computed(() => items.value)
// Actions
const fetchItems = async (limit = 100, offset = 0) => {
loading.value = true
error.value = null
try {
// TODO: Call API endpoint
// const response = await ${fileName.replace('.ts', '')}Api.list${storeName}({ limit, offset })
// items.value = response.data
} catch (err) {
error.value = (err as Error).message
} finally {
loading.value = false
}
}
const fetchItemById = async (itemId: string) => {
loading.value = true
error.value = null
try {
// TODO: Call API endpoint
// const response = await ${fileName.replace('.ts', '')}Api.get${storeName}(itemId)
// selectedItem.value = response.data
} catch (err) {
error.value = (err as Error).message
} finally {
loading.value = false
}
}
const createItem = async (payload: any) => {
loading.value = true
error.value = null
try {
// TODO: Call API endpoint
// const response = await ${fileName.replace('.ts', '')}Api.create${storeName}(payload)
// items.value.push(response.data)
// selectedItem.value = response.data
// return response.data
} catch (err) {
error.value = (err as Error).message
throw err
} finally {
loading.value = false
}
}
const updateItem = async (itemId: string, payload: any) => {
loading.value = true
error.value = null
try {
// TODO: Call API endpoint
// const response = await ${fileName.replace('.ts', '')}Api.update${storeName}(itemId, payload)
// const index = items.value.findIndex((i) => i.id === itemId)
// if (index !== -1) items.value[index] = response.data
// if (selectedItem.value?.id === itemId) selectedItem.value = response.data
// return response.data
} catch (err) {
error.value = (err as Error).message
throw err
} finally {
loading.value = false
}
}
const deleteItem = async (itemId: string) => {
loading.value = true
error.value = null
try {
// TODO: Call API endpoint
// await ${fileName.replace('.ts', '')}Api.delete${storeName}(itemId)
// items.value = items.value.filter((i) => i.id !== itemId)
// if (selectedItem.value?.id === itemId) selectedItem.value = null
} catch (err) {
error.value = (err as Error).message
throw err
} finally {
loading.value = false
}
}
const setFilter = (key: string, value: any) => {
filters.value[key] = value
}
const clearFilters = () => {
filters.value = {}
}
return {
// State
items,
selectedItem,
loading,
error,
filters,
// Computed
itemCount,
filteredItems,
// Actions
fetchItems,
fetchItemById,
createItem,
updateItem,
deleteItem,
setFilter,
clearFilters
}
})
`
}
const template = templates[storeType] || templates.default
const storeFile = path.join(storeDir, fileName)
if (!fs.existsSync(storeFile)) {
fs.writeFileSync(storeFile, template)
console.log(`✓ Created: ${storeFile}`)
} else {
console.log(`⚠ Already exists: ${storeFile}`)
}
-163
View File
@@ -1,163 +0,0 @@
<template>
<div v-if="!showSidebar" id="app">
<router-view />
</div>
<div v-else id="app" class="admin-layout">
<nav class="sidebar">
<div class="sidebar-header">
<h2>OMS·WMS·ERP</h2>
</div>
<ul class="nav-menu">
<li><router-link to="/admin/dashboard" active-class="active">📊 Dashboard</router-link></li>
<li><router-link to="/admin/orders" active-class="active">📦 Orders</router-link></li>
<li><router-link to="/admin/products" active-class="active">🏷 Products</router-link></li>
<li><router-link to="/admin/customers" active-class="active">👥 Customers</router-link></li>
<li><router-link to="/admin/inventory" active-class="active">📊 Inventory</router-link></li>
<li><router-link to="/admin/warehouses" active-class="active">🏭 Warehouses</router-link></li>
</ul>
<div class="sidebar-footer">
<div class="user-info">
<div class="username">{{ authStore.user?.username }}</div>
<div class="role">{{ authStore.user?.role }}</div>
</div>
<button @click="handleLogout" class="logout-btn">🚪 Logout</button>
</div>
</nav>
<main class="content">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { RouterView, RouterLink } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores'
import { computed, onMounted } from 'vue'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// Initialize auth on app mount
onMounted(() => {
authStore.checkAuth()
})
// Show sidebar only on admin routes
const showSidebar = computed(() => {
return authStore.isAuthenticated && !route.path.startsWith('/login')
})
const handleLogout = () => {
if (confirm('Are you sure you want to logout?')) {
authStore.logout()
router.push('/login')
}
}
</script>
<style scoped>
.admin-layout {
display: flex;
height: 100vh;
background-color: #f5f5f5;
}
.sidebar {
width: 250px;
background-color: #2c3e50;
color: #fff;
overflow-y: auto;
display: flex;
flex-direction: column;
box-shadow: 2px 0 4px rgba(0,0,0,0.1);
}
.sidebar-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.sidebar-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
}
.nav-menu {
list-style: none;
padding: 0;
margin: 0;
flex: 1;
}
.nav-menu li {
border-bottom: 1px solid rgba(255,255,255,0.05);
}
.nav-menu a {
display: block;
padding: 1rem 1.5rem;
color: rgba(255,255,255,0.7);
text-decoration: none;
transition: all 0.2s;
font-size: 0.95rem;
}
.nav-menu a:hover {
background-color: rgba(255,255,255,0.05);
color: #fff;
}
.nav-menu a.active {
background-color: #0d6efd;
color: #fff;
border-left: 4px solid #fff;
padding-left: calc(1.5rem - 4px);
}
.sidebar-footer {
padding: 1rem 1.5rem;
border-top: 1px solid rgba(255,255,255,0.1);
background-color: rgba(0,0,0,0.1);
}
.user-info {
margin-bottom: 1rem;
}
.username {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.25rem;
}
.role {
font-size: 0.85rem;
color: rgba(255,255,255,0.6);
text-transform: capitalize;
}
.logout-btn {
width: 100%;
padding: 0.5rem 0;
background-color: #dc3545;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.logout-btn:hover {
background-color: #c82333;
}
.content {
flex: 1;
overflow-y: auto;
background-color: #f5f5f5;
}
</style>
@@ -1,18 +0,0 @@
<template>
<div class="dashboardSummary">
<h2>DashboardSummary</h2>
<div class="component-placeholder">
<p>Implementation: Layer 4 Business Composite</p>
<p>Uses appropriate Pinia store for state management</p>
</div>
</div>
</template>
<script setup lang="ts">
// Component implementation
</script>
<style scoped>
.dashboardSummary { padding: 1rem; }
.component-placeholder { padding: 2rem; background: #f8f9fa; border-radius: 4px; text-align: center; }
</style>
@@ -1,75 +0,0 @@
<template>
<div class="inventory-transfer">
<h2>Inventory Transfer</h2>
<div class="transfer-form">
<div class="form-row">
<div class="form-col">
<label>From Warehouse:</label>
<select v-model="transfer.fromWarehouse" class="form-control">
<option v-for="w in warehouseStore.operationalWarehouses" :key="w.id" :value="w.id">
{{ w.name }}
</option>
</select>
</div>
<div class="form-col">
<label>To Warehouse:</label>
<select v-model="transfer.toWarehouse" class="form-control">
<option v-for="w in warehouseStore.operationalWarehouses" :key="w.id" :value="w.id">
{{ w.name }}
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-col">
<label>Product SKU:</label>
<input v-model="transfer.productSku" type="text" class="form-control" />
</div>
<div class="form-col">
<label>Quantity:</label>
<input v-model.number="transfer.quantity" type="number" class="form-control" />
</div>
</div>
<div class="form-row">
<button class="btn btn-primary" @click="handleTransfer" :disabled="inventoryStore.loading">
Transfer
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useInventoryStore, useWarehouseStore } from '@/stores'
const inventoryStore = useInventoryStore()
const warehouseStore = useWarehouseStore()
const transfer = ref({
fromWarehouse: '',
toWarehouse: '',
productSku: '',
quantity: 0
})
const handleTransfer = async () => {
try {
// Transfer logic
alert('Transfer completed')
} catch (err) {
alert('Transfer failed')
}
}
</script>
<style scoped>
.inventory-transfer { padding: 1rem; }
.transfer-form { margin-top: 1rem; }
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 1rem; margin-bottom: 1rem; }
.form-col { display: flex; flex-direction: column; }
label { font-weight: 500; margin-bottom: 0.5rem; font-size: 0.875rem; }
.form-control { border: 1px solid #dee2e6; border-radius: 4px; padding: 0.5rem; }
.btn { padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 500; }
.btn-primary { background-color: #0d6efd; color: #fff; }
</style>
@@ -1,244 +0,0 @@
<template>
<div class="order-form">
<div class="form-header">
<h2>{{ isEditing ? 'Edit Order' : 'Create Order' }}</h2>
<div v-if="order" class="order-meta">
<span class="badge" :class="getStatusBadgeClass()">{{ order.status }}</span>
<span class="text-muted">{{ order.orderNumber }}</span>
</div>
</div>
<div class="form-content">
<div class="form-section">
<h3>Customer Information</h3>
<div class="form-row">
<div class="form-col">
<label class="form-label">Customer <span class="text-danger">*</span></label>
<select v-model="formData.customerId" class="form-control" @change="onCustomerChange">
<option value="">-- Select Customer --</option>
<option v-for="c in customerStore.activeCustomers" :key="c.id" :value="c.id">
{{ c.name }} ({{ c.code }})
</option>
</select>
</div>
<div class="form-col">
<label class="form-label">Order Date <span class="text-danger">*</span></label>
<input v-model="formData.orderDate" type="date" class="form-control" />
</div>
<div class="form-col">
<label class="form-label">Due Date <span class="text-danger">*</span></label>
<input v-model="formData.dueDate" type="date" class="form-control" />
</div>
</div>
</div>
<div class="form-section">
<h3>Order Items</h3>
<table v-if="formData.items.length > 0" class="table table-sm">
<thead>
<tr>
<th>SKU</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Line Total</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in formData.items" :key="idx">
<td>{{ item.productSku }}</td>
<td>{{ item.quantity }}</td>
<td>${{ item.unitPrice.toFixed(2) }}</td>
<td>${{ item.lineTotal.toFixed(2) }}</td>
<td>
<button class="btn btn-sm btn-danger" @click="removeItem(idx)">Remove</button>
</td>
</tr>
</tbody>
</table>
<div v-else class="alert alert-info">No items added yet</div>
<button class="btn btn-secondary mt-2" @click="showAddItem = true">Add Item</button>
</div>
<div class="form-section">
<div class="totals">
<div class="total-row">
<span>Subtotal:</span>
<span>${{ formData.subtotal.toFixed(2) }}</span>
</div>
<div class="total-row">
<span>Tax (10%):</span>
<span>${{ formData.tax.toFixed(2) }}</span>
</div>
<div class="total-row total-amount">
<span>Total:</span>
<span>${{ formData.total.toFixed(2) }}</span>
</div>
</div>
</div>
<div class="form-section">
<label class="form-label">Notes (Optional)</label>
<textarea v-model="formData.notes" class="form-control" rows="3" placeholder="Add order notes..."></textarea>
</div>
<div v-if="isEditing" class="form-section">
<label class="form-label">Status</label>
<select v-model="formData.status" class="form-control">
<option value="DRAFT">Draft</option>
<option value="PENDING">Pending</option>
<option value="CONFIRMED">Confirmed</option>
<option value="SHIPPED">Shipped</option>
<option value="DELIVERED">Delivered</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
</div>
<div class="form-actions">
<button v-if="isEditing" class="btn btn-danger" @click="handleDelete">Delete Order</button>
<div>
<button class="btn btn-secondary" @click="$emit('cancel')">Cancel</button>
<button class="btn btn-primary" @click="handleSave" :disabled="orderStore.loading">
Save Order
</button>
</div>
</div>
<div v-if="orderStore.error" class="alert alert-danger mt-3">{{ orderStore.error }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import { useOrderStore, useCustomerStore, type Order, type OrderItem } from '@/stores'
interface Props {
orderId?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{ save: [order: Order]; cancel: [] }>()
const orderStore = useOrderStore()
const customerStore = useCustomerStore()
const isEditing = computed(() => !!props.orderId)
const order = ref<Order | null>(null)
const showAddItem = ref(false)
const formData = ref({
customerId: '',
orderDate: new Date().toISOString().split('T')[0],
dueDate: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0],
items: [] as OrderItem[],
subtotal: 0,
tax: 0,
total: 0,
notes: '',
status: 'DRAFT'
})
onMounted(async () => {
await customerStore.fetchCustomers()
if (isEditing.value && props.orderId) {
const selected = orderStore.orders.find((o) => o.id === props.orderId)
if (selected) {
order.value = selected
formData.value = {
customerId: selected.customerId,
orderDate: selected.orderDate,
dueDate: selected.dueDate,
items: [...selected.items],
subtotal: selected.subtotal,
tax: selected.tax,
total: selected.total,
notes: selected.notes || '',
status: selected.status
}
}
}
})
const onCustomerChange = () => {}
const removeItem = (index: number) => { formData.value.items.splice(index, 1) }
const handleSave = async () => {
try {
if (isEditing.value && props.orderId) {
await orderStore.updateOrder(props.orderId, formData.value as Partial<Order>)
} else {
await orderStore.createOrder(formData.value)
}
emit('save', orderStore.selectedOrder!)
} catch (err) {
console.error('Error saving order:', err)
}
}
const handleDelete = async () => {
if (confirm('Delete this order?') && props.orderId) {
try {
await orderStore.deleteOrder(props.orderId)
emit('cancel')
} catch (err) {
console.error('Error:', err)
}
}
}
const getStatusBadgeClass = () => {
if (!order.value) return ''
const s = order.value.status
if (s === 'DELIVERED') return 'bg-success'
if (s === 'SHIPPED') return 'bg-info'
if (s === 'CONFIRMED') return 'bg-primary'
if (s === 'CANCELLED') return 'bg-danger'
return 'bg-secondary'
}
watch(() => formData.value.items, () => {
formData.value.subtotal = formData.value.items.reduce((sum, i) => sum + i.lineTotal, 0)
formData.value.tax = formData.value.subtotal * 0.1
formData.value.total = formData.value.subtotal + formData.value.tax
}, { deep: true })
</script>
<style scoped>
.order-form { max-width: 900px; margin: 0 auto; }
.form-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem; border-bottom: 2px solid #dee2e6; padding-bottom: 1rem; }
.order-meta { display: flex; gap: 1rem; align-items: center; }
.form-section { margin-bottom: 2rem; padding: 1.5rem; border: 1px solid #dee2e6; border-radius: 4px; background-color: #f8f9fa; }
.form-section h3 { margin-top: 0; font-size: 1.1rem; margin-bottom: 1rem; font-weight: 600; }
.form-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
.form-col { display: flex; flex-direction: column; }
.form-label { font-weight: 500; margin-bottom: 0.5rem; font-size: 0.875rem; }
.form-control { border-radius: 4px; border: 1px solid #dee2e6; padding: 0.5rem 0.75rem; font-size: 0.875rem; }
.form-control:focus { border-color: #80bdff; outline: 0; box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25); }
.table { margin-bottom: 0; }
.table th { background-color: #e9ecef; font-weight: 600; font-size: 0.875rem; }
.totals { background-color: #fff; border: 1px solid #dee2e6; border-radius: 4px; padding: 1rem; margin: 0; }
.total-row { display: flex; justify-content: space-between; padding: 0.5rem 0; font-size: 0.95rem; }
.total-amount { border-top: 2px solid #dee2e6; padding-top: 1rem; margin-top: 0.5rem; font-weight: 600; font-size: 1.1rem; }
.form-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 2rem; gap: 1rem; }
.form-actions > div { display: flex; gap: 1rem; }
.btn { padding: 0.5rem 1rem; border-radius: 4px; border: none; cursor: pointer; font-weight: 500; transition: all 0.2s; }
.btn-primary { background-color: #0d6efd; color: #fff; }
.btn-primary:hover:not(:disabled) { background-color: #0b5ed7; }
.btn-secondary { background-color: #6c757d; color: #fff; }
.btn-danger { background-color: #dc3545; color: #fff; }
.btn:disabled { opacity: 0.65; cursor: not-allowed; }
.alert { padding: 0.75rem 1rem; border-radius: 4px; border: 1px solid transparent; }
.alert-info { background-color: #d1ecf1; color: #0c5460; border-color: #bee5eb; }
.alert-danger { background-color: #f8d7da; color: #721c24; border-color: #f5c6cb; }
.text-danger { color: #dc3545; }
.text-muted { color: #6c757d; }
.badge { display: inline-block; padding: 0.375rem 0.75rem; font-size: 0.75rem; font-weight: 600; border-radius: 0.25rem; }
.bg-success { background-color: #28a745; color: #fff; }
.bg-info { background-color: #17a2b8; color: #fff; }
.bg-primary { background-color: #0d6efd; color: #fff; }
.bg-danger { background-color: #dc3545; color: #fff; }
.bg-secondary { background-color: #6c757d; color: #fff; }
.mt-2 { margin-top: 0.5rem; }
.mt-3 { margin-top: 1rem; }
</style>
@@ -1,29 +0,0 @@
/**
* Business Composite Components Index
* Layer 4 of 4-layer component architecture (Phase 3 Step 4)
*
* 11 Business Composite Components:
* - OrderForm: Create/edit orders with line items and totals
* - InventoryTransfer: Transfer stock between warehouses
* - ProductEditor: Manage product catalog (CRUD)
* - CustomerProfile: View/edit customer details and credit
* - SupplierManagement: Manage supplier relationships
* - WarehouseSettings: Configure warehouse operations
* - PricingManager: Manage price adjustments and history
* - ReportBuilder: Generate business analytics reports
* - UserManagement: Manage user accounts and permissions
* - SettingsPanel: Configure application settings
* - DashboardSummary: Executive dashboard overview
*/
export { default as OrderForm } from './OrderForm.vue'
export { default as InventoryTransfer } from './InventoryTransfer.vue'
export { default as ProductEditor } from './ProductEditor.vue'
export { default as CustomerProfile } from './CustomerProfile.vue'
export { default as SupplierManagement } from './SupplierManagement.vue'
export { default as WarehouseSettings } from './WarehouseSettings.vue'
export { default as PricingManager } from './PricingManager.vue'
export { default as ReportBuilder } from './ReportBuilder.vue'
export { default as UserManagement } from './UserManagement.vue'
export { default as SettingsPanel } from './SettingsPanel.vue'
export { default as DashboardSummary } from './DashboardSummary.vue'
@@ -1,272 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import AddressField from './AddressField.vue'
describe('AddressField (Domain Field)', () => {
const defaultProps = {
modelValue: null
}
describe('Rendering', () => {
it('renders all address input fields', () => {
const wrapper = mount(AddressField, { props: defaultProps })
expect(wrapper.text()).toContain('Street Address')
expect(wrapper.text()).toContain('City')
expect(wrapper.text()).toContain('Postal Code')
expect(wrapper.text()).toContain('Country')
})
it('renders optional state field', () => {
const wrapper = mount(AddressField, { props: defaultProps })
expect(wrapper.text()).toContain('State / Province')
})
it('renders optional building field', () => {
const wrapper = mount(AddressField, { props: defaultProps })
expect(wrapper.text()).toContain('Building / Suite / Apt')
})
it('has country select dropdown', () => {
const wrapper = mount(AddressField, { props: defaultProps })
const select = wrapper.find('select')
expect(select.exists()).toBe(true)
})
})
describe('Street Validation', () => {
it('requires street address', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[0].setValue('')
await inputs[0].trigger('blur')
expect(wrapper.vm.streetError).not.toBeNull()
expect(wrapper.vm.streetError).toContain('required')
})
it('validates minimum length', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[0].setValue('123')
await inputs[0].trigger('blur')
expect(wrapper.vm.streetError).not.toBeNull()
expect(wrapper.vm.streetError).toContain('at least 5 characters')
})
it('accepts valid street', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[0].setValue('123 Main Street')
await inputs[0].trigger('blur')
expect(wrapper.vm.streetError).toBeNull()
})
})
describe('City Validation', () => {
it('requires city', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[1].setValue('')
await inputs[1].trigger('blur')
expect(wrapper.vm.cityError).not.toBeNull()
})
it('accepts valid city', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[1].setValue('Seoul')
await inputs[1].trigger('blur')
expect(wrapper.vm.cityError).toBeNull()
})
})
describe('Postal Code Validation', () => {
it('requires postal code', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
wrapper.vm.address.postalCode = ''
await wrapper.vm.$nextTick()
wrapper.vm.validatePostalCode()
expect(wrapper.vm.postalCodeError).not.toBeNull()
})
it('validates postal code format', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
wrapper.vm.address.postalCode = 'ABC'
await wrapper.vm.$nextTick()
wrapper.vm.validatePostalCode()
expect(wrapper.vm.postalCodeError).not.toBeNull()
})
it('accepts valid postal codes', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
wrapper.vm.address.postalCode = '04620'
await wrapper.vm.$nextTick()
wrapper.vm.validatePostalCode()
expect(wrapper.vm.postalCodeError).toBeNull()
})
})
describe('Country Validation', () => {
it('requires country selection', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
wrapper.vm.address.country = ''
await wrapper.vm.$nextTick()
wrapper.vm.validateCountry()
expect(wrapper.vm.countryError).not.toBeNull()
})
it('accepts country selection', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
wrapper.vm.address.country = 'KR'
await wrapper.vm.$nextTick()
wrapper.vm.validateCountry()
expect(wrapper.vm.countryError).toBeNull()
})
})
describe('Address Formatting', () => {
it('formats complete address', () => {
const wrapper = mount(AddressField, {
props: {
modelValue: {
street: '123 Main Street',
city: 'Seoul',
state: 'Seoul',
postalCode: '04620',
country: 'KR',
building: 'Suite 200'
}
}
})
expect(wrapper.vm.formatAddress).toContain('123 Main Street')
expect(wrapper.vm.formatAddress).toContain('Suite 200')
expect(wrapper.vm.formatAddress).toContain('Seoul')
expect(wrapper.vm.formatAddress).toContain('04620')
expect(wrapper.vm.formatAddress).toContain('South Korea')
})
it('omits empty optional fields', () => {
const wrapper = mount(AddressField, {
props: {
modelValue: {
street: '456 Oak Ave',
city: 'Tokyo',
state: '',
postalCode: '150-0002',
country: 'JP',
building: ''
}
}
})
const formatted = wrapper.vm.formatAddress
expect(formatted).toContain('456 Oak Ave')
expect(formatted).toContain('Tokyo')
expect(formatted).not.toContain('undefined')
})
})
describe('Completion Status', () => {
it('is not complete when required fields missing', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
wrapper.vm.address.street = '123 Street'
wrapper.vm.address.city = 'Seoul'
wrapper.vm.address.postalCode = ''
wrapper.vm.address.country = ''
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(false)
})
it('is complete when all required fields filled', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
wrapper.vm.address.street = '123 Street'
wrapper.vm.address.city = 'Seoul'
wrapper.vm.address.postalCode = '04620'
wrapper.vm.address.country = 'KR'
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(true)
})
it('shows success alert when complete', async () => {
const wrapper = mount(AddressField, { props: defaultProps })
wrapper.vm.address = {
street: '123 Main Street',
city: 'Seoul',
state: 'Seoul',
postalCode: '04620',
country: 'KR',
building: 'Suite 200'
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Address is valid')
})
})
describe('Props Updates', () => {
it('loads address from modelValue prop', () => {
const props = {
modelValue: {
street: '123 Test Street',
city: 'Seoul',
state: 'Seoul',
postalCode: '04620',
country: 'KR',
building: 'Apt 5F'
}
}
const wrapper = mount(AddressField, { props })
expect(wrapper.vm.address.street).toBe('123 Test Street')
expect(wrapper.vm.address.city).toBe('Seoul')
expect(wrapper.vm.address.country).toBe('KR')
})
})
describe('Accessibility', () => {
it('shows required indicators', () => {
const wrapper = mount(AddressField, { props: defaultProps })
const labels = wrapper.findAll('label')
const requiredLabels = labels.filter((l) => l.text().includes('*'))
expect(requiredLabels.length).toBeGreaterThan(0)
})
it('shows optional indicators', () => {
const wrapper = mount(AddressField, { props: defaultProps })
expect(wrapper.text()).toContain('(optional)')
})
})
})
@@ -1,107 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import AddressField from './AddressField.vue'
const meta: Meta<typeof AddressField> = {
title: 'Fields/Domain/AddressField',
component: AddressField
}
export default meta
type Story = StoryObj<typeof AddressField>
const Template = (args: any) => ({
components: { AddressField },
setup() {
return { args }
},
template: `
<div>
<AddressField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Address:</strong>
<p>{{ args.modelValue.street }}, {{ args.modelValue.city }}, {{ args.modelValue.country }}</p>
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: null
}
}
export const Seoul: Story = {
render: Template,
args: {
modelValue: {
street: '123 Gangnam Street',
city: 'Seoul',
state: 'Seoul',
postalCode: '04620',
country: 'KR',
building: 'Suite 200'
}
}
}
export const USA: Story = {
render: Template,
args: {
modelValue: {
street: '456 Main Street',
city: 'New York',
state: 'NY',
postalCode: '10001',
country: 'US',
building: 'Apt 5F'
}
}
}
export const Japan: Story = {
render: Template,
args: {
modelValue: {
street: '789 Shibuya',
city: 'Tokyo',
state: 'Tokyo',
postalCode: '150-0002',
country: 'JP',
building: ''
}
}
}
export const FormValidation: Story = {
render: (args: any) => ({
components: { AddressField },
setup() {
return { args }
},
template: `
<div>
<AddressField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div class="alert alert-info mt-3">
<strong>💡 Try to:</strong>
<ul>
<li>Leave street address empty → shows error</li>
<li>Enter less than 5 characters → shows error</li>
<li>Select a country → enables validation</li>
<li>Complete all required fields → shows success</li>
</ul>
</div>
</div>
`
}),
args: {
modelValue: null
}
}
@@ -1,388 +0,0 @@
<template>
<div class="address-field">
<div class="address-group">
<!-- Street Address -->
<div class="form-group col-full">
<label class="form-label">
Street Address
<span class="text-danger">*</span>
</label>
<input
v-model="address.street"
type="text"
class="form-control"
:class="{ 'is-invalid': streetError }"
placeholder="e.g., 123 Main Street"
@blur="validateStreet"
/>
<div v-if="streetError" class="invalid-feedback d-block">
{{ streetError }}
</div>
</div>
<!-- City -->
<div class="form-group">
<label class="form-label">
City
<span class="text-danger">*</span>
</label>
<input
v-model="address.city"
type="text"
class="form-control"
:class="{ 'is-invalid': cityError }"
placeholder="e.g., Seoul"
@blur="validateCity"
/>
<div v-if="cityError" class="invalid-feedback d-block">
{{ cityError }}
</div>
</div>
<!-- Postal Code -->
<div class="form-group">
<label class="form-label">
Postal Code
<span class="text-danger">*</span>
</label>
<input
v-model="address.postalCode"
type="text"
class="form-control"
:class="{ 'is-invalid': postalCodeError }"
placeholder="e.g., 04620"
@blur="validatePostalCode"
/>
<div v-if="postalCodeError" class="invalid-feedback d-block">
{{ postalCodeError }}
</div>
</div>
<!-- Country -->
<div class="form-group">
<label class="form-label">
Country
<span class="text-danger">*</span>
</label>
<select
v-model="address.country"
class="form-control"
:class="{ 'is-invalid': countryError }"
@blur="validateCountry"
>
<option value="">-- Select Country --</option>
<option value="KR">South Korea (KR)</option>
<option value="US">United States (US)</option>
<option value="JP">Japan (JP)</option>
<option value="CN">China (CN)</option>
<option value="SG">Singapore (SG)</option>
<option value="TW">Taiwan (TW)</option>
</select>
<div v-if="countryError" class="invalid-feedback d-block">
{{ countryError }}
</div>
</div>
<!-- State/Province (Optional) -->
<div class="form-group">
<label class="form-label">State / Province</label>
<input
v-model="address.state"
type="text"
class="form-control"
placeholder="e.g., Gyeonggi-do (optional)"
/>
</div>
<!-- Building / Suite (Optional) -->
<div class="form-group col-full">
<label class="form-label">Building / Suite / Apt (Optional)</label>
<input
v-model="address.building"
type="text"
class="form-control"
placeholder="e.g., Suite 200 / Apt 5F"
/>
</div>
</div>
<!-- Address Summary -->
<div v-if="isComplete" class="address-summary mt-2">
<small class="text-muted">
📍 <strong>{{ formatAddress }}</strong>
</small>
</div>
<!-- Validation Summary -->
<div v-if="hasErrors" class="alert alert-danger mt-2">
Please complete all required fields (marked with *)
</div>
<div v-if="isComplete && !hasErrors" class="alert alert-success mt-2">
Address is valid and complete
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Address {
street: string
city: string
state?: string
postalCode: string
country: string
building?: string
}
const props = defineProps<{
modelValue: Address | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: Address | null]
}>()
// State
const address = ref<Address>({
street: props.modelValue?.street || '',
city: props.modelValue?.city || '',
state: props.modelValue?.state || '',
postalCode: props.modelValue?.postalCode || '',
country: props.modelValue?.country || '',
building: props.modelValue?.building || ''
})
const streetError = ref<string | null>(null)
const cityError = ref<string | null>(null)
const postalCodeError = ref<string | null>(null)
const countryError = ref<string | null>(null)
// Computed
const isComplete = computed(() => {
return (
address.value.street.trim().length > 0 &&
address.value.city.trim().length > 0 &&
address.value.postalCode.trim().length > 0 &&
address.value.country.length > 0 &&
!hasErrors.value
)
})
const hasErrors = computed(() => {
return (
streetError.value !== null ||
cityError.value !== null ||
postalCodeError.value !== null ||
countryError.value !== null
)
})
const formatAddress = computed(() => {
const parts = []
if (address.value.street) parts.push(address.value.street)
if (address.value.building) parts.push(address.value.building)
if (address.value.city) parts.push(address.value.city)
if (address.value.state) parts.push(address.value.state)
if (address.value.postalCode) parts.push(address.value.postalCode)
if (address.value.country) {
const countryName = getCountryName(address.value.country)
parts.push(countryName)
}
return parts.join(', ')
})
// Methods
const validateStreet = () => {
streetError.value = null
if (!address.value.street.trim()) {
streetError.value = 'Street address is required'
return
}
if (address.value.street.trim().length < 5) {
streetError.value = 'Street address must be at least 5 characters'
return
}
emitUpdate()
}
const validateCity = () => {
cityError.value = null
if (!address.value.city.trim()) {
cityError.value = 'City is required'
return
}
if (address.value.city.trim().length < 2) {
cityError.value = 'City must be at least 2 characters'
return
}
emitUpdate()
}
const validatePostalCode = () => {
postalCodeError.value = null
if (!address.value.postalCode.trim()) {
postalCodeError.value = 'Postal code is required'
return
}
// Basic postal code validation (5-10 characters)
if (!/^[0-9\-\s]{3,10}$/.test(address.value.postalCode.trim())) {
postalCodeError.value = 'Postal code format is invalid'
return
}
emitUpdate()
}
const validateCountry = () => {
countryError.value = null
if (!address.value.country) {
countryError.value = 'Country is required'
return
}
emitUpdate()
}
const emitUpdate = () => {
if (isComplete.value) {
emit('update:modelValue', { ...address.value })
}
}
const getCountryName = (code: string): string => {
const countries: Record<string, string> = {
KR: 'South Korea',
US: 'United States',
JP: 'Japan',
CN: 'China',
SG: 'Singapore',
TW: 'Taiwan'
}
return countries[code] || code
}
</script>
<style scoped>
.address-field {
margin-bottom: 1.5rem;
}
.address-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.col-full {
grid-column: 1 / -1;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-family: inherit;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
.form-control:disabled {
background-color: #e9ecef;
opacity: 1;
cursor: not-allowed;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.d-block {
display: block;
}
.address-summary {
padding: 0.75rem;
background-color: #e7f3ff;
border-left: 3px solid #0d6efd;
border-radius: 4px;
font-size: 0.875rem;
line-height: 1.6;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
.mt-2 {
margin-top: 0.5rem;
}
small {
display: block;
}
strong {
font-weight: 600;
}
</style>
@@ -1,341 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import ApprovalField from './ApprovalField.vue'
describe('ApprovalField (Domain Field)', () => {
const defaultProps = {
modelValue: null
}
describe('Rendering', () => {
it('renders approval type dropdown', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Approval Type')
expect(wrapper.text()).toContain('Purchase Order')
})
it('renders amount input field', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Amount / Value')
})
it('renders approver selection checkboxes', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Required Approver(s)')
expect(wrapper.text()).toContain('John Manager')
expect(wrapper.text()).toContain('CFO')
})
it('renders approval status dropdown', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Approval Status')
})
it('renders approval notes textarea', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Approval Notes')
})
it('renders approval date input', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Approval Date')
})
})
describe('Approval Type Validation', () => {
it('requires approval type', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = ''
wrapper.vm.validateType()
expect(wrapper.vm.typeError).not.toBeNull()
expect(wrapper.vm.typeError).toContain('required')
})
it('accepts valid approval type', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'PURCHASE_ORDER'
wrapper.vm.validateType()
expect(wrapper.vm.typeError).toBeNull()
})
it('supports 5 approval types', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Purchase Order')
expect(wrapper.text()).toContain('Inventory Transfer')
expect(wrapper.text()).toContain('Customer Credit')
expect(wrapper.text()).toContain('Expense Report')
expect(wrapper.text()).toContain('Price Adjustment')
})
})
describe('Approver Selection', () => {
it('requires at least one approver', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'PURCHASE_ORDER'
wrapper.vm.approval.approverIds = []
wrapper.vm.validateAndEmit()
expect(wrapper.vm.approverError).not.toBeNull()
expect(wrapper.vm.approverError).toContain('At least one approver')
})
it('allows single approver selection', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.toggleApprover('manager1')
expect(wrapper.vm.approval.approverIds).toContain('manager1')
expect(wrapper.vm.approverError).toBeNull()
})
it('allows multiple approver selection', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.toggleApprover('manager1')
wrapper.vm.toggleApprover('finance')
wrapper.vm.toggleApprover('cfo')
expect(wrapper.vm.approval.approverIds.length).toBe(3)
})
it('can deselect an approver', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.toggleApprover('manager1')
expect(wrapper.vm.approval.approverIds).toContain('manager1')
wrapper.vm.toggleApprover('manager1')
expect(wrapper.vm.approval.approverIds).not.toContain('manager1')
})
it('supports 5 approvers', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.vm.availableApprovers.length).toBe(5)
expect(wrapper.vm.availableApprovers.some((a) => a.id === 'ceo')).toBe(true)
})
})
describe('Amount Field', () => {
it('accepts numeric amount', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.amount = 5000.50
await wrapper.vm.$nextTick()
expect(wrapper.vm.approval.amount).toBe(5000.50)
})
it('formats amount in summary', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.amount = 1500
await wrapper.vm.$nextTick()
const formatted = wrapper.vm.formatAmount(1500)
expect(formatted).toBe('1,500.00')
})
it('is optional', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'PURCHASE_ORDER'
wrapper.vm.toggleApprover('manager1')
wrapper.vm.approval.amount = undefined
expect(wrapper.vm.isComplete).toBe(true)
})
})
describe('Approval Status', () => {
it('defaults to PENDING', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.vm.approval.status).toBe('PENDING')
})
it('allows status change', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.status = 'APPROVED'
expect(wrapper.vm.approval.status).toBe('APPROVED')
})
it('supports 4 status values', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Pending')
expect(wrapper.text()).toContain('Approved')
expect(wrapper.text()).toContain('Rejected')
expect(wrapper.text()).toContain('Cancelled')
})
})
describe('Completion Status', () => {
it('is complete when type and approver selected', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'PURCHASE_ORDER'
wrapper.vm.toggleApprover('manager1')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(true)
})
it('is not complete when type missing', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = ''
wrapper.vm.toggleApprover('manager1')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(false)
})
it('is not complete when approver missing', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'INVENTORY_TRANSFER'
wrapper.vm.approval.approverIds = []
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(false)
})
it('shows success alert when complete', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'PURCHASE_ORDER'
wrapper.vm.toggleApprover('finance')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Approval workflow is configured')
})
it('shows error alert when incomplete', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = ''
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Please complete all required fields')
})
})
describe('Event Emission', () => {
it('emits update:modelValue with complete approval', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'EXPENSE_REPORT'
wrapper.vm.approval.amount = 500
wrapper.vm.toggleApprover('manager1')
wrapper.vm.approval.notes = 'Test note'
wrapper.vm.validateAndEmit()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeTruthy()
expect(emitted[0][0].type).toBe('EXPENSE_REPORT')
expect(emitted[0][0].amount).toBe(500)
expect(emitted[0][0].approverIds).toContain('manager1')
expect(emitted[0][0].notes).toBe('Test note')
})
it('does not emit when type missing', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = ''
wrapper.vm.toggleApprover('manager1')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeFalsy()
})
})
describe('Props Updates', () => {
it('loads approval from modelValue prop', () => {
const props = {
modelValue: {
type: 'PURCHASE_ORDER',
amount: 10000,
approverIds: ['manager1', 'finance'],
status: 'PENDING',
notes: 'Test notes',
approvalDate: '2026-08-02'
}
}
const wrapper = mount(ApprovalField, { props })
expect(wrapper.vm.approval.type).toBe('PURCHASE_ORDER')
expect(wrapper.vm.approval.amount).toBe(10000)
expect(wrapper.vm.approval.approverIds).toEqual(['manager1', 'finance'])
expect(wrapper.vm.approval.notes).toBe('Test notes')
})
})
describe('Selected Approvers Summary', () => {
it('shows summary when complete', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'PURCHASE_ORDER'
wrapper.vm.toggleApprover('manager1')
wrapper.vm.toggleApprover('finance')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('John Manager')
expect(wrapper.text()).toContain('Finance Controller')
})
it('displays amount in summary', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'CUSTOMER_CREDIT'
wrapper.vm.approval.amount = 5000
wrapper.vm.toggleApprover('cfo')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('5,000.00')
})
it('shows status badge', async () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
wrapper.vm.approval.type = 'INVENTORY_TRANSFER'
wrapper.vm.toggleApprover('manager2')
wrapper.vm.approval.status = 'APPROVED'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('APPROVED')
})
})
describe('Accessibility', () => {
it('shows required indicators', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
const labels = wrapper.findAll('label')
const requiredLabels = labels.filter((l) => l.text().includes('*'))
expect(requiredLabels.length).toBeGreaterThan(0)
})
it('shows approver roles', () => {
const wrapper = mount(ApprovalField, { props: defaultProps })
expect(wrapper.text()).toContain('Department Manager')
expect(wrapper.text()).toContain('Chief Financial Officer')
})
})
})
@@ -1,94 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import ApprovalField from './ApprovalField.vue'
const meta: Meta<typeof ApprovalField> = {
title: 'Fields/Domain/ApprovalField',
component: ApprovalField
}
export default meta
type Story = StoryObj<typeof ApprovalField>
const Template = (args: any) => ({
components: { ApprovalField },
setup() {
return { args }
},
template: `
<div>
<ApprovalField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Approval:</strong>
<p>Type: {{ args.modelValue.type }}</p>
<p>Approvers: {{ args.modelValue.approverIds.join(', ') }}</p>
<p>Status: {{ args.modelValue.status }}</p>
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: null
}
}
export const PurchaseOrder: Story = {
render: Template,
args: {
modelValue: {
type: 'PURCHASE_ORDER',
amount: 5000,
approverIds: ['manager1', 'finance'],
status: 'PENDING',
notes: 'Urgent - vendor lead time',
approvalDate: '2026-08-02'
}
}
}
export const InventoryTransfer: Story = {
render: Template,
args: {
modelValue: {
type: 'INVENTORY_TRANSFER',
amount: 2500,
approverIds: ['manager2'],
status: 'APPROVED',
notes: 'Inter-warehouse transfer',
approvalDate: '2026-08-01'
}
}
}
export const Rejected: Story = {
render: Template,
args: {
modelValue: {
type: 'EXPENSE_REPORT',
amount: 750,
approverIds: ['manager1'],
status: 'REJECTED',
notes: 'Missing supporting documentation',
approvalDate: '2026-07-31'
}
}
}
export const HighValue: Story = {
render: Template,
args: {
modelValue: {
type: 'CUSTOMER_CREDIT',
amount: 50000,
approverIds: ['finance', 'cfo', 'ceo'],
status: 'PENDING',
notes: 'New enterprise customer, long-term contract',
approvalDate: ''
}
}
}
@@ -1,428 +0,0 @@
<template>
<div class="approval-field">
<div class="approval-group">
<!-- Approval Type -->
<div class="form-group">
<label class="form-label">
Approval Type
<span class="text-danger">*</span>
</label>
<select
v-model="approval.type"
class="form-control"
:class="{ 'is-invalid': typeError }"
@blur="validateType"
>
<option value="">-- Select Type --</option>
<option value="PURCHASE_ORDER">Purchase Order</option>
<option value="INVENTORY_TRANSFER">Inventory Transfer</option>
<option value="CUSTOMER_CREDIT">Customer Credit Limit</option>
<option value="EXPENSE_REPORT">Expense Report</option>
<option value="PRICE_ADJUSTMENT">Price Adjustment</option>
</select>
<div v-if="typeError" class="invalid-feedback d-block">
{{ typeError }}
</div>
</div>
<!-- Approval Amount/Value -->
<div class="form-group">
<label class="form-label">Amount / Value</label>
<input
v-model="approval.amount"
type="number"
class="form-control"
placeholder="0.00"
step="0.01"
/>
</div>
<!-- Approver Selection -->
<div class="form-group col-full">
<label class="form-label">
Required Approver(s)
<span class="text-danger">*</span>
</label>
<div class="approver-list">
<div v-for="approver in availableApprovers" :key="approver.id" class="form-check">
<input
:id="`approver-${approver.id}`"
type="checkbox"
class="form-check-input"
:checked="isApproverSelected(approver.id)"
@change="toggleApprover(approver.id)"
/>
<label :for="`approver-${approver.id}`" class="form-check-label">
{{ approver.name }}
<small class="text-muted d-block">{{ approver.role }}</small>
</label>
</div>
</div>
<div v-if="approverError" class="invalid-feedback d-block mt-2">
{{ approverError }}
</div>
</div>
<!-- Current Approval Status -->
<div class="form-group col-full">
<label class="form-label">Approval Status</label>
<select v-model="approval.status" class="form-control">
<option value="PENDING">🕐 Pending</option>
<option value="APPROVED"> Approved</option>
<option value="REJECTED"> Rejected</option>
<option value="CANCELLED">🚫 Cancelled</option>
</select>
</div>
<!-- Approval Notes -->
<div class="form-group col-full">
<label class="form-label">Approval Notes (Optional)</label>
<textarea
v-model="approval.notes"
class="form-control"
rows="3"
placeholder="Add comments, conditions, or notes for approval..."
/>
</div>
<!-- Approval Date -->
<div class="form-group">
<label class="form-label">Approval Date</label>
<input
v-model="approval.approvalDate"
type="date"
class="form-control"
/>
</div>
</div>
<!-- Approval Summary -->
<div v-if="isComplete" class="approval-summary mt-2">
<small class="text-muted">
<strong>{{ approval.type }}</strong>
<span v-if="approval.amount">${{ formatAmount(approval.amount) }} </span>
<span v-if="selectedApprovers.length > 0">
{{ selectedApprovers.map((a) => a.name).join(', ') }}
</span>
<span class="ms-2" :class="getStatusBadgeClass()">
{{ approval.status }}
</span>
</small>
</div>
<!-- Validation Status -->
<div v-if="hasErrors" class="alert alert-danger mt-2">
Please complete all required fields
</div>
<div v-if="isComplete && !hasErrors" class="alert alert-success mt-2">
Approval workflow is configured
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Approver {
id: string
name: string
role: string
level: number
}
interface Approval {
type: string
amount?: number
approverIds: string[]
status: string
notes?: string
approvalDate?: string
}
const props = defineProps<{
modelValue: Approval | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: Approval | null]
}>()
// Available approvers
const availableApprovers: Approver[] = [
{ id: 'manager1', name: 'John Manager', role: 'Department Manager', level: 1 },
{ id: 'manager2', name: 'Sarah Director', role: 'Director', level: 2 },
{ id: 'finance', name: 'Finance Controller', role: 'Finance', level: 3 },
{ id: 'cfo', name: 'CFO', role: 'Chief Financial Officer', level: 4 },
{ id: 'ceo', name: 'CEO', role: 'Chief Executive Officer', level: 5 }
]
// State
const approval = ref<Approval>({
type: props.modelValue?.type || '',
amount: props.modelValue?.amount || undefined,
approverIds: props.modelValue?.approverIds || [],
status: props.modelValue?.status || 'PENDING',
notes: props.modelValue?.notes || '',
approvalDate: props.modelValue?.approvalDate || ''
})
const typeError = ref<string | null>(null)
const approverError = ref<string | null>(null)
// Computed
const selectedApprovers = computed(() => {
return availableApprovers.filter((a) => approval.value.approverIds.includes(a.id))
})
const isComplete = computed(() => {
return (
approval.value.type.length > 0 &&
approval.value.approverIds.length > 0 &&
!hasErrors.value
)
})
const hasErrors = computed(() => {
return typeError.value !== null || approverError.value !== null
})
// Methods
const validateType = () => {
typeError.value = null
if (!approval.value.type) {
typeError.value = 'Approval type is required'
return
}
validateAndEmit()
}
const isApproverSelected = (approverId: string): boolean => {
return approval.value.approverIds.includes(approverId)
}
const toggleApprover = (approverId: string) => {
approverError.value = null
const index = approval.value.approverIds.indexOf(approverId)
if (index > -1) {
approval.value.approverIds.splice(index, 1)
} else {
approval.value.approverIds.push(approverId)
}
validateAndEmit()
}
const validateAndEmit = () => {
typeError.value = null
approverError.value = null
if (!approval.value.type) {
typeError.value = 'Approval type is required'
return
}
if (approval.value.approverIds.length === 0) {
approverError.value = 'At least one approver must be selected'
return
}
emit('update:modelValue', { ...approval.value })
}
const formatAmount = (amount: number): string => {
return amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const getStatusBadgeClass = (): string => {
const status = approval.value.status
if (status === 'APPROVED') return 'badge bg-success'
if (status === 'REJECTED') return 'badge bg-danger'
if (status === 'CANCELLED') return 'badge bg-secondary'
return 'badge bg-warning'
}
</script>
<style scoped>
.approval-field {
margin-bottom: 1.5rem;
}
.approval-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.col-full {
grid-column: 1 / -1;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-family: inherit;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.text-muted {
color: #6c757d;
font-size: 0.875rem;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.d-block {
display: block;
}
.mt-2 {
margin-top: 0.5rem;
}
.ms-2 {
margin-left: 0.5rem;
}
.approver-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #fff;
}
.form-check {
display: flex;
gap: 0.5rem;
}
.form-check-input {
width: 1rem;
height: 1rem;
margin-top: 0.25rem;
cursor: pointer;
accent-color: #0d6efd;
}
.form-check-label {
cursor: pointer;
margin-bottom: 0;
user-select: none;
font-size: 0.875rem;
}
.form-check-label small {
margin-top: 0.25rem;
display: block;
}
.approval-summary {
padding: 0.75rem;
background-color: #e7f3ff;
border-left: 3px solid #0d6efd;
border-radius: 4px;
font-size: 0.875rem;
line-height: 1.6;
}
.badge {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
font-weight: 500;
}
.bg-success {
background-color: #28a745;
color: #fff;
}
.bg-danger {
background-color: #dc3545;
color: #fff;
}
.bg-warning {
background-color: #ffc107;
color: #000;
}
.bg-secondary {
background-color: #6c757d;
color: #fff;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
small {
display: block;
}
strong {
font-weight: 600;
}
</style>
@@ -1,318 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BankAccountField from './BankAccountField.vue'
describe('BankAccountField (Domain Field)', () => {
const defaultProps = {
modelValue: null
}
describe('Rendering', () => {
it('renders all bank account input fields', () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
expect(wrapper.text()).toContain('Account Holder Name')
expect(wrapper.text()).toContain('Bank Code')
expect(wrapper.text()).toContain('Account Number')
})
it('renders optional account type field', () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
expect(wrapper.text()).toContain('Account Type')
})
it('renders optional currency field', () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
expect(wrapper.text()).toContain('Currency')
})
it('has bank code select dropdown', () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const selects = wrapper.findAll('select')
expect(selects.length).toBeGreaterThan(0)
})
})
describe('Account Holder Name Validation', () => {
it('requires account holder name', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[0].setValue('')
await inputs[0].trigger('blur')
expect(wrapper.vm.holderError).not.toBeNull()
expect(wrapper.vm.holderError).toContain('required')
})
it('validates minimum length', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[0].setValue('A')
await inputs[0].trigger('blur')
expect(wrapper.vm.holderError).not.toBeNull()
expect(wrapper.vm.holderError).toContain('at least 2 characters')
})
it('validates invalid characters', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[0].setValue('John@Smith#')
await inputs[0].trigger('blur')
expect(wrapper.vm.holderError).not.toBeNull()
expect(wrapper.vm.holderError).toContain('invalid characters')
})
it('accepts valid name', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="text"]')
await inputs[0].setValue('John Smith')
await inputs[0].trigger('blur')
expect(wrapper.vm.holderError).toBeNull()
})
})
describe('Bank Code Validation', () => {
it('requires bank code', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const selects = wrapper.findAll('select')
await selects[0].setValue('')
await selects[0].trigger('blur')
expect(wrapper.vm.bankError).not.toBeNull()
expect(wrapper.vm.bankError).toContain('Please select a bank')
})
it('accepts bank code selection', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const selects = wrapper.findAll('select')
await selects[0].setValue('001')
await selects[0].trigger('blur')
expect(wrapper.vm.bankError).toBeNull()
})
it('supports multiple banks', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const selects = wrapper.findAll('select')
const options = selects[0].findAll('option')
expect(options.length).toBeGreaterThan(5)
expect(wrapper.text()).toContain('KB Kookmin Bank')
expect(wrapper.text()).toContain('Shinhan Bank')
})
})
describe('Account Number Validation', () => {
it('requires account number', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.accountNumber = ''
await wrapper.vm.$nextTick()
wrapper.vm.validateAccountNumber()
expect(wrapper.vm.accountError).not.toBeNull()
expect(wrapper.vm.accountError).toContain('required')
})
it('validates format (digits and hyphens)', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.accountNumber = 'ABC-123-456'
await wrapper.vm.$nextTick()
wrapper.vm.validateAccountNumber()
expect(wrapper.vm.accountError).not.toBeNull()
expect(wrapper.vm.accountError).toContain('digits and hyphens')
})
it('validates minimum length', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.accountNumber = '12'
await wrapper.vm.$nextTick()
wrapper.vm.validateAccountNumber()
expect(wrapper.vm.accountError).not.toBeNull()
expect(wrapper.vm.accountError).toContain('3-20 characters')
})
it('accepts valid account number', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.accountNumber = '123-456-789012'
await wrapper.vm.$nextTick()
wrapper.vm.validateAccountNumber()
expect(wrapper.vm.accountError).toBeNull()
})
it('accepts account number without hyphens', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.accountNumber = '12345678901'
await wrapper.vm.$nextTick()
wrapper.vm.validateAccountNumber()
expect(wrapper.vm.accountError).toBeNull()
})
})
describe('Account Summary Display', () => {
it('shows summary when complete', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.holderName = 'John Smith'
wrapper.vm.account.bankCode = '001'
wrapper.vm.account.accountNumber = '123-456-789012'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('John Smith')
expect(wrapper.text()).toContain('KB Kookmin Bank')
expect(wrapper.text()).toContain('123-456-789012')
})
it('does not show summary when incomplete', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.holderName = 'John'
wrapper.vm.account.bankCode = ''
await wrapper.vm.$nextTick()
// Account summary not shown
expect(wrapper.vm.isComplete).toBe(false)
})
})
describe('Completion Status', () => {
it('is not complete when required fields missing', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.holderName = 'John Smith'
wrapper.vm.account.bankCode = ''
wrapper.vm.account.accountNumber = '123-456-789012'
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(false)
})
it('is complete when all required fields filled', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.holderName = 'John Smith'
wrapper.vm.account.bankCode = '001'
wrapper.vm.account.accountNumber = '123-456-789012'
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(true)
})
it('shows success alert when complete', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.holderName = 'John Smith'
wrapper.vm.account.bankCode = '001'
wrapper.vm.account.accountNumber = '123-456-789012'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Bank account information is valid')
})
it('shows error alert when incomplete', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.holderName = ''
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Please complete all required fields')
})
})
describe('Props Updates', () => {
it('loads account from modelValue prop', () => {
const props = {
modelValue: {
holderName: 'John Smith',
bankCode: '001',
accountNumber: '123-456-789012',
accountType: 'CHECKING',
currency: 'KRW'
}
}
const wrapper = mount(BankAccountField, { props })
expect(wrapper.vm.account.holderName).toBe('John Smith')
expect(wrapper.vm.account.bankCode).toBe('001')
expect(wrapper.vm.account.accountNumber).toBe('123-456-789012')
})
})
describe('Event Emission', () => {
it('emits update:modelValue when complete', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
wrapper.vm.account.holderName = 'John Smith'
wrapper.vm.account.bankCode = '001'
wrapper.vm.account.accountNumber = '123-456-789012'
await wrapper.vm.$nextTick()
await wrapper.vm.emitUpdate()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeTruthy()
expect(emitted[0][0]).toEqual({
holderName: 'John Smith',
bankCode: '001',
accountNumber: '123-456-789012',
accountType: '',
currency: 'KRW'
})
})
})
describe('Accessibility', () => {
it('shows required indicators', () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
const labels = wrapper.findAll('label')
const requiredLabels = labels.filter((l) => l.text().includes('*'))
expect(requiredLabels.length).toBeGreaterThan(0)
})
it('shows help text for account number format', () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
expect(wrapper.text()).toContain('Format: digits and hyphens')
})
})
describe('Currency Options', () => {
it('supports multiple currencies', () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
expect(wrapper.text()).toContain('Korean Won (KRW)')
expect(wrapper.text()).toContain('US Dollar (USD)')
expect(wrapper.text()).toContain('Euro (EUR)')
expect(wrapper.text()).toContain('Japanese Yen (JPY)')
})
it('defaults to KRW currency', async () => {
const wrapper = mount(BankAccountField, { props: defaultProps })
expect(wrapper.vm.account.currency).toBe('KRW')
})
})
})
@@ -1,62 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import BankAccountField from './BankAccountField.vue'
const meta: Meta<typeof BankAccountField> = {
title: 'Fields/Domain/BankAccountField',
component: BankAccountField
}
export default meta
type Story = StoryObj<typeof BankAccountField>
const Template = (args: any) => ({
components: { BankAccountField },
setup() {
return { args }
},
template: `
<div>
<BankAccountField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Account:</strong>
<p>{{ args.modelValue.holderName }} - {{ args.modelValue.accountNumber }}</p>
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: null
}
}
export const Complete: Story = {
render: Template,
args: {
modelValue: {
holderName: 'John Smith',
bankCode: '001',
accountNumber: '123-456-789012',
accountType: 'CHECKING',
currency: 'KRW'
}
}
}
export const Savings: Story = {
render: Template,
args: {
modelValue: {
holderName: 'Jane Doe',
bankCode: '002',
accountNumber: '987-654-321098',
accountType: 'SAVINGS',
currency: 'USD'
}
}
}
@@ -1,348 +0,0 @@
<template>
<div class="bank-account-field">
<div class="bank-account-group">
<!-- Account Holder Name -->
<div class="form-group col-full">
<label class="form-label">
Account Holder Name
<span class="text-danger">*</span>
</label>
<input
v-model="account.holderName"
type="text"
class="form-control"
:class="{ 'is-invalid': holderError }"
placeholder="e.g., John Smith"
@blur="validateHolder"
/>
<div v-if="holderError" class="invalid-feedback d-block">
{{ holderError }}
</div>
</div>
<!-- Bank Code -->
<div class="form-group">
<label class="form-label">
Bank Code
<span class="text-danger">*</span>
</label>
<select
v-model="account.bankCode"
class="form-control"
:class="{ 'is-invalid': bankError }"
@blur="validateBank"
>
<option value="">-- Select Bank --</option>
<option value="001">KB Kookmin Bank (001)</option>
<option value="002">Shinhan Bank (002)</option>
<option value="003">Hana Bank (003)</option>
<option value="004">Hyundai Bank (004)</option>
<option value="011">NH Bank (011)</option>
<option value="020">Woori Bank (020)</option>
<option value="050">Busan Bank (050)</option>
</select>
<div v-if="bankError" class="invalid-feedback d-block">
{{ bankError }}
</div>
</div>
<!-- Account Number -->
<div class="form-group col-full">
<label class="form-label">
Account Number
<span class="text-danger">*</span>
</label>
<input
v-model="account.accountNumber"
type="text"
class="form-control"
:class="{ 'is-invalid': accountError }"
placeholder="e.g., 123-456-789012"
@blur="validateAccountNumber"
/>
<small class="text-muted d-block mt-1">
Format: digits and hyphens (3-20 characters)
</small>
<div v-if="accountError" class="invalid-feedback d-block">
{{ accountError }}
</div>
</div>
<!-- Account Type -->
<div class="form-group">
<label class="form-label">Account Type</label>
<select v-model="account.accountType" class="form-control">
<option value="">-- Select Type --</option>
<option value="CHECKING">Checking</option>
<option value="SAVINGS">Savings</option>
<option value="MONEY_MARKET">Money Market</option>
<option value="BUSINESS">Business</option>
</select>
</div>
<!-- Currency -->
<div class="form-group">
<label class="form-label">Currency</label>
<select v-model="account.currency" class="form-control">
<option value="KRW">Korean Won (KRW)</option>
<option value="USD">US Dollar (USD)</option>
<option value="EUR">Euro (EUR)</option>
<option value="JPY">Japanese Yen (JPY)</option>
</select>
</div>
</div>
<!-- Account Summary -->
<div v-if="isComplete" class="account-summary mt-2">
<small class="text-muted">
🏦 <strong>{{ account.holderName }}</strong> {{ getBankName(account.bankCode) }}
({{ account.accountNumber }})
</small>
</div>
<!-- Validation Status -->
<div v-if="hasErrors" class="alert alert-danger mt-2">
Please complete all required fields
</div>
<div v-if="isComplete && !hasErrors" class="alert alert-success mt-2">
Bank account information is valid
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface BankAccount {
holderName: string
bankCode: string
accountNumber: string
accountType?: string
currency?: string
}
const props = defineProps<{
modelValue: BankAccount | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: BankAccount | null]
}>()
// State
const account = ref<BankAccount>({
holderName: props.modelValue?.holderName || '',
bankCode: props.modelValue?.bankCode || '',
accountNumber: props.modelValue?.accountNumber || '',
accountType: props.modelValue?.accountType || '',
currency: props.modelValue?.currency || 'KRW'
})
const holderError = ref<string | null>(null)
const bankError = ref<string | null>(null)
const accountError = ref<string | null>(null)
// Computed
const isComplete = computed(() => {
return (
account.value.holderName.trim().length > 0 &&
account.value.bankCode.length > 0 &&
account.value.accountNumber.trim().length > 0 &&
!hasErrors.value
)
})
const hasErrors = computed(() => {
return (
holderError.value !== null ||
bankError.value !== null ||
accountError.value !== null
)
})
// Methods
const validateHolder = () => {
holderError.value = null
if (!account.value.holderName.trim()) {
holderError.value = 'Account holder name is required'
return
}
if (account.value.holderName.trim().length < 2) {
holderError.value = 'Name must be at least 2 characters'
return
}
// Allow alphanumeric and common name characters
if (!/^[a-zA-Z0-9\s\-\.]+$/.test(account.value.holderName.trim())) {
holderError.value = 'Name contains invalid characters'
return
}
emitUpdate()
}
const validateBank = () => {
bankError.value = null
if (!account.value.bankCode) {
bankError.value = 'Please select a bank'
return
}
emitUpdate()
}
const validateAccountNumber = () => {
accountError.value = null
if (!account.value.accountNumber.trim()) {
accountError.value = 'Account number is required'
return
}
// Validate format: digits and hyphens, 3-20 chars
if (!/^[0-9\-]{3,20}$/.test(account.value.accountNumber.trim())) {
accountError.value = 'Account number must be 3-20 characters (digits and hyphens only)'
return
}
emitUpdate()
}
const emitUpdate = () => {
if (isComplete.value) {
emit('update:modelValue', { ...account.value })
}
}
const getBankName = (code: string): string => {
const banks: Record<string, string> = {
'001': 'KB Kookmin Bank',
'002': 'Shinhan Bank',
'003': 'Hana Bank',
'004': 'Hyundai Bank',
'011': 'NH Bank',
'020': 'Woori Bank',
'050': 'Busan Bank'
}
return banks[code] || 'Bank'
}
</script>
<style scoped>
.bank-account-field {
margin-bottom: 1.5rem;
}
.bank-account-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.col-full {
grid-column: 1 / -1;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-family: inherit;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.text-muted {
color: #6c757d;
font-size: 0.875rem;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.d-block {
display: block;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.account-summary {
padding: 0.75rem;
background-color: #e7f3ff;
border-left: 3px solid #0d6efd;
border-radius: 4px;
font-size: 0.875rem;
line-height: 1.6;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
small {
display: block;
}
strong {
font-weight: 600;
}
</style>
@@ -1,416 +0,0 @@
<template>
<div class="customer-field">
<!-- Customer Search/Select -->
<div class="form-group">
<label class="form-label">
Customer
<span class="text-danger">*</span>
</label>
<div class="input-group">
<input
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Search by company name..."
@input="handleSearch"
/>
<button
v-if="isSearching"
class="btn btn-outline-secondary"
disabled
>
<span class="spinner-border spinner-border-sm"></span>
</button>
</div>
<!-- Search Results Dropdown -->
<div v-if="searchResults.length > 0" class="customer-dropdown">
<div
v-for="customer in searchResults"
:key="customer.id"
class="dropdown-item"
@click="selectCustomer(customer)"
>
<strong>{{ customer.name }}</strong>
<small class="text-muted d-block">{{ customer.email }}</small>
</div>
</div>
<div v-if="searchError" class="invalid-feedback d-block">
{{ searchError }}
</div>
</div>
<!-- Selected Customer Details -->
<div v-if="selectedCustomer" class="card mt-2">
<div class="card-body">
<div class="row">
<div class="col-md-6">
<small>
<strong>Company:</strong> {{ selectedCustomer.name }}<br>
<strong>Email:</strong> {{ selectedCustomer.email }}<br>
<strong>Phone:</strong> {{ selectedCustomer.phone }}
</small>
</div>
<div class="col-md-6">
<small>
<strong>Address:</strong> {{ selectedCustomer.address }}<br>
<strong>Tax ID:</strong> {{ selectedCustomer.taxId }}<br>
<strong>Status:</strong>
<span :class="statusClass">{{ selectedCustomer.status }}</span>
</small>
</div>
</div>
<!-- Credit Limit Warning -->
<div v-if="creditUsagePercent > 70" :class="creditAlertClass" class="mt-2">
Credit Usage: {{ creditUsagePercent }}%
<strong>({{ formatCurrency(creditUsage) }} / {{ formatCurrency(selectedCustomer.creditLimit) }})</strong>
</div>
<!-- Clear Button -->
<button
class="btn btn-sm btn-outline-secondary mt-2"
@click="clearSelection"
>
Clear
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFormatting } from '@/composables/useFormatting'
interface Customer {
id: string
name: string
email: string
phone: string
address: string
taxId: string
status: string
creditLimit: number
creditUsed: number
}
const props = defineProps<{
modelValue: string // customer ID
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
select: [customer: Customer]
}>()
const { formatCurrency } = useFormatting()
// State
const searchQuery = ref('')
const searchResults = ref<Customer[]>([])
const selectedCustomer = ref<Customer | null>(null)
const isSearching = ref(false)
const searchError = ref<string | null>(null)
// Computed
const creditUsage = computed(() => {
if (!selectedCustomer.value) return 0
return selectedCustomer.value.creditUsed || 0
})
const creditUsagePercent = computed(() => {
if (!selectedCustomer.value || selectedCustomer.value.creditLimit === 0) return 0
return Math.round((creditUsage.value / selectedCustomer.value.creditLimit) * 100)
})
const creditAlertClass = computed(() => {
const percent = creditUsagePercent.value
if (percent > 90) return 'alert alert-danger'
if (percent > 70) return 'alert alert-warning'
return 'alert alert-info'
})
const statusClass = computed(() => {
if (!selectedCustomer.value) return ''
switch (selectedCustomer.value.status) {
case 'ACTIVE':
return 'badge bg-success'
case 'INACTIVE':
return 'badge bg-secondary'
case 'SUSPENDED':
return 'badge bg-danger'
default:
return 'badge bg-secondary'
}
})
// Methods
const handleSearch = async (e: Event) => {
const input = e.target as HTMLInputElement
searchQuery.value = input.value
if (!searchQuery.value) {
searchResults.value = []
searchError.value = null
return
}
isSearching.value = true
searchError.value = null
try {
// Mock API call - would be: await customersApi.searchCustomers(searchQuery.value)
const results = await mockSearchCustomers(searchQuery.value)
searchResults.value = results
} catch (error) {
searchError.value = 'Failed to search customers'
} finally {
isSearching.value = false
}
}
const selectCustomer = async (customer: Customer) => {
selectedCustomer.value = customer
searchQuery.value = customer.name
searchResults.value = []
emit('update:modelValue', customer.id)
emit('select', customer)
}
const clearSelection = () => {
selectedCustomer.value = null
searchQuery.value = ''
searchResults.value = []
emit('update:modelValue', '')
}
// Mock API - would be replaced with real API call
const mockSearchCustomers = async (query: string): Promise<Customer[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const mockCustomers: Customer[] = [
{
id: 'CUST-001',
name: 'ABC Corporation',
email: 'contact@abc.com',
phone: '02-123-4567',
address: 'Seoul, Korea',
taxId: '123-45-67890',
status: 'ACTIVE',
creditLimit: 10000000,
creditUsed: 7500000
},
{
id: 'CUST-002',
name: 'XYZ Industries',
email: 'sales@xyz.com',
phone: '02-987-6543',
address: 'Busan, Korea',
taxId: '987-65-43210',
status: 'ACTIVE',
creditLimit: 5000000,
creditUsed: 1500000
}
]
resolve(
mockCustomers.filter((c) =>
c.name.toLowerCase().includes(query.toLowerCase())
)
)
}, 300)
})
}
</script>
<style scoped>
.customer-field {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
position: relative;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
display: block;
font-size: 0.875rem;
}
.input-group {
display: flex;
position: relative;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
flex: 1;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.btn {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 0 4px 4px 0;
border: 1px solid #dee2e6;
border-left: 0;
cursor: pointer;
}
.btn-outline-secondary {
color: #6c757d;
border-color: #6c757d;
}
.btn-outline-secondary:disabled {
opacity: 0.65;
}
.spinner-border {
width: 1rem;
height: 1rem;
border-width: 0.2em;
}
.customer-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #dee2e6;
border-top: 0;
border-radius: 0 0 4px 4px;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
z-index: 1000;
max-height: 200px;
overflow-y: auto;
}
.dropdown-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.15s ease-in-out;
}
.dropdown-item:hover {
background-color: #f8f9fa;
}
.dropdown-item:last-child {
border-bottom: none;
}
.card {
border: 1px solid #dee2e6;
border-radius: 4px;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.card-body {
padding: 1rem;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.col-md-6 {
flex: 1;
}
small {
line-height: 1.6;
display: block;
}
.text-muted {
color: #6c757d;
}
.d-block {
display: block;
}
.badge {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
}
.bg-success {
background-color: #28a745 !important;
color: white;
}
.bg-secondary {
background-color: #6c757d !important;
color: white;
}
.bg-danger {
background-color: #dc3545 !important;
color: white;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
}
.alert-warning {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-3 {
margin-top: 1rem;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
</style>
@@ -1,182 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import DateRangeField from './DateRangeField.vue'
describe('DateRangeField (Domain Field)', () => {
const defaultProps = {
modelValue: null
}
describe('Rendering', () => {
it('renders start and end date inputs', () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
expect(inputs).toHaveLength(2)
})
it('displays labels for both dates', () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
expect(wrapper.text()).toContain('Start Date')
expect(wrapper.text()).toContain('End Date')
})
it('shows preset buttons', () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
expect(wrapper.text()).toContain('This Month')
expect(wrapper.text()).toContain('This Quarter')
expect(wrapper.text()).toContain('This Year')
expect(wrapper.text()).toContain('Last 30 Days')
})
})
describe('Date Input', () => {
it('accepts start date', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
await inputs[0].setValue('2026-08-01')
expect(wrapper.vm.startDate).toBe('2026-08-01')
})
it('accepts end date', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
await inputs[1].setValue('2026-08-31')
expect(wrapper.vm.endDate).toBe('2026-08-31')
})
it('emits update when both dates set', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
await inputs[0].setValue('2026-08-01')
await inputs[1].setValue('2026-08-31')
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
})
})
describe('Date Validation', () => {
it('validates start date before end date', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
await inputs[0].setValue('2026-08-31')
await inputs[1].setValue('2026-08-01')
await inputs[1].trigger('blur')
expect(wrapper.vm.rangeError).not.toBeNull()
expect(wrapper.vm.rangeError).toContain('Start date must be before end date')
})
it('accepts valid date range', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
await inputs[0].setValue('2026-08-01')
await inputs[1].setValue('2026-08-31')
await inputs[1].trigger('blur')
expect(wrapper.vm.rangeError).toBeNull()
})
it('accepts same start and end date', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
await inputs[0].setValue('2026-08-15')
await inputs[1].setValue('2026-08-15')
await inputs[1].trigger('blur')
expect(wrapper.vm.rangeError).toBeNull()
})
})
describe('Date Range Summary', () => {
it('calculates days difference', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
await inputs[0].setValue('2026-08-01')
await inputs[1].setValue('2026-08-31')
expect(wrapper.vm.daysDifference).toBe(30)
})
it('shows range summary when dates valid', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const inputs = wrapper.findAll('input[type="date"]')
await inputs[0].setValue('2026-08-01')
await inputs[1].setValue('2026-08-31')
expect(wrapper.text()).toContain('30')
expect(wrapper.text()).toContain('days')
})
})
describe('Presets', () => {
it('sets this month dates', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const buttons = wrapper.findAll('button')
const thisMonthBtn = buttons.find((b) => b.text().includes('This Month'))
if (thisMonthBtn) {
await thisMonthBtn.trigger('click')
expect(wrapper.vm.startDate).toBeDefined()
expect(wrapper.vm.endDate).toBeDefined()
}
})
it('sets last 30 days preset', async () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const buttons = wrapper.findAll('button')
const last30Btn = buttons.find((b) => b.text().includes('Last 30 Days'))
if (last30Btn) {
await last30Btn.trigger('click')
expect(wrapper.vm.startDate).toBeDefined()
expect(wrapper.vm.endDate).toBeDefined()
expect(wrapper.vm.daysDifference).toBeGreaterThan(29)
}
})
})
describe('Props Updates', () => {
it('loads dates from modelValue prop', async () => {
const props = {
modelValue: {
startDate: '2026-08-01',
endDate: '2026-08-31'
}
}
const wrapper = mount(DateRangeField, { props })
expect(wrapper.vm.startDate).toBe('2026-08-01')
expect(wrapper.vm.endDate).toBe('2026-08-31')
})
})
describe('Accessibility', () => {
it('has required indicators', () => {
const wrapper = mount(DateRangeField, { props: defaultProps })
const labels = wrapper.findAll('label')
const requiredLabels = labels.filter((l) => l.text().includes('*'))
expect(requiredLabels.length).toBeGreaterThan(0)
})
})
})
@@ -1,95 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import DateRangeField from './DateRangeField.vue'
const meta: Meta<typeof DateRangeField> = {
title: 'Fields/Domain/DateRangeField',
component: DateRangeField
}
export default meta
type Story = StoryObj<typeof DateRangeField>
const Template = (args: any) => ({
components: { DateRangeField },
setup() {
return { args }
},
template: `
<div>
<DateRangeField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Selected Range:</strong>
{{ args.modelValue.startDate }} to {{ args.modelValue.endDate }}
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: null
}
}
export const WithDates: Story = {
render: Template,
args: {
modelValue: {
startDate: '2026-08-01',
endDate: '2026-08-31'
}
}
}
export const ShortRange: Story = {
render: Template,
args: {
modelValue: {
startDate: '2026-08-25',
endDate: '2026-08-28'
}
}
}
export const LongRange: Story = {
render: Template,
args: {
modelValue: {
startDate: '2026-01-01',
endDate: '2026-12-31'
}
}
}
export const PresetDemo: Story = {
render: (args: any) => ({
components: { DateRangeField },
setup() {
return { args }
},
template: `
<div>
<DateRangeField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div class="alert alert-info mt-3">
<strong>💡 Try the preset buttons:</strong>
<ul>
<li>This Month: Current calendar month</li>
<li>This Quarter: Current 3-month period</li>
<li>This Year: January 1 - December 31</li>
<li>Last 30 Days: Last 30 days from today</li>
</ul>
</div>
</div>
`
}),
args: {
modelValue: null
}
}
@@ -1,336 +0,0 @@
<template>
<div class="date-range-field">
<div class="date-range-group">
<!-- Start Date -->
<div class="form-group">
<label class="form-label">
Start Date
<span class="text-danger">*</span>
</label>
<input
v-model="startDate"
type="date"
class="form-control"
:class="{ 'is-invalid': startDateError }"
@blur="validateDates"
/>
<div v-if="startDateError" class="invalid-feedback d-block">
{{ startDateError }}
</div>
</div>
<!-- End Date -->
<div class="form-group">
<label class="form-label">
End Date
<span class="text-danger">*</span>
</label>
<input
v-model="endDate"
type="date"
class="form-control"
:class="{ 'is-invalid': endDateError }"
@blur="validateDates"
/>
<div v-if="endDateError" class="invalid-feedback d-block">
{{ endDateError }}
</div>
</div>
</div>
<!-- Date Range Summary -->
<div v-if="startDate && endDate && !rangeError" class="date-summary mt-2">
<small class="text-muted">
📅 Range: <strong>{{ daysDifference }}</strong> days
({{ formatDate(startDate) }} to {{ formatDate(endDate) }})
</small>
</div>
<!-- Range Validation Error -->
<div v-if="rangeError" class="alert alert-danger mt-2">
{{ rangeError }}
</div>
<!-- Presets -->
<div class="presets mt-2">
<small class="text-muted">Quick presets:</small>
<div class="preset-buttons">
<button class="btn btn-sm btn-outline-secondary" @click="setPreset('thisMonth')">
This Month
</button>
<button class="btn btn-sm btn-outline-secondary" @click="setPreset('thisQuarter')">
This Quarter
</button>
<button class="btn btn-sm btn-outline-secondary" @click="setPreset('thisYear')">
This Year
</button>
<button class="btn btn-sm btn-outline-secondary" @click="setPreset('last30Days')">
Last 30 Days
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useFormatting } from '@/composables/useFormatting'
interface DateRange {
startDate: string
endDate: string
}
const props = defineProps<{
modelValue: DateRange | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: DateRange | null]
}>()
const { formatDate } = useFormatting()
// State
const startDate = ref(props.modelValue?.startDate || '')
const endDate = ref(props.modelValue?.endDate || '')
const startDateError = ref<string | null>(null)
const endDateError = ref<string | null>(null)
// Computed
const rangeError = computed(() => {
if (!startDate.value || !endDate.value) return null
const start = new Date(startDate.value)
const end = new Date(endDate.value)
if (start > end) {
return 'Start date must be before end date'
}
return null
})
const daysDifference = computed(() => {
if (!startDate.value || !endDate.value) return 0
const start = new Date(startDate.value)
const end = new Date(endDate.value)
const diffTime = Math.abs(end.getTime() - start.getTime())
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays
})
// Watchers
watch([startDate, endDate], () => {
if (startDate.value && endDate.value && !rangeError.value) {
emit('update:modelValue', {
startDate: startDate.value,
endDate: endDate.value
})
}
})
// Methods
const validateDates = () => {
startDateError.value = null
endDateError.value = null
if (startDate.value && !isValidDate(startDate.value)) {
startDateError.value = 'Invalid start date'
return
}
if (endDate.value && !isValidDate(endDate.value)) {
endDateError.value = 'Invalid end date'
return
}
if (startDate.value && endDate.value) {
const start = new Date(startDate.value)
const end = new Date(endDate.value)
if (start > end) {
endDateError.value = 'End date must be after start date'
}
}
}
const isValidDate = (dateString: string): boolean => {
const regex = /^\d{4}-\d{2}-\d{2}$/
if (!regex.test(dateString)) return false
const date = new Date(dateString)
return date instanceof Date && !isNaN(date.getTime())
}
const setPreset = (preset: string) => {
const today = new Date()
const currentYear = today.getFullYear()
const currentMonth = today.getMonth()
let start: Date, end: Date
switch (preset) {
case 'thisMonth':
start = new Date(currentYear, currentMonth, 1)
end = new Date(currentYear, currentMonth + 1, 0)
break
case 'thisQuarter':
const quarter = Math.floor(currentMonth / 3)
start = new Date(currentYear, quarter * 3, 1)
end = new Date(currentYear, quarter * 3 + 3, 0)
break
case 'thisYear':
start = new Date(currentYear, 0, 1)
end = new Date(currentYear, 11, 31)
break
case 'last30Days':
end = new Date()
start = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000)
break
default:
return
}
startDate.value = start.toISOString().split('T')[0]
endDate.value = end.toISOString().split('T')[0]
validateDates()
}
</script>
<style scoped>
.date-range-field {
margin-bottom: 1.5rem;
}
.date-range-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.text-muted {
color: #6c757d;
}
.date-summary {
padding: 0.75rem;
background-color: #e7f3ff;
border-left: 3px solid #0d6efd;
border-radius: 4px;
font-size: 0.875rem;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.presets {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.preset-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.btn {
padding: 0.4rem 0.75rem;
font-size: 0.8rem;
border-radius: 4px;
border: 1px solid #dee2e6;
cursor: pointer;
transition: all 0.15s ease-in-out;
background-color: white;
}
.btn-outline-secondary {
color: #6c757d;
border-color: #6c757d;
}
.btn-outline-secondary:hover {
background-color: #6c757d;
color: white;
}
.d-block {
display: block;
}
.mt-2 {
margin-top: 0.5rem;
}
small {
display: block;
}
strong {
font-weight: 600;
}
</style>
@@ -1,313 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import OrderLineField from './OrderLineField.vue'
describe('OrderLineField (Domain Field)', () => {
const defaultProps = {
modelValue: {
productId: '',
quantity: 1,
unitPrice: 0,
lineTotal: 0
}
}
describe('Rendering', () => {
it('renders form inputs for line item', () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
expect(wrapper.find('select').exists()).toBe(true) // Product select
expect(wrapper.findAll('input[type="number"]').length).toBeGreaterThan(0) // Quantity
expect(wrapper.find('button').exists()).toBe(true) // Remove button
})
it('displays product details panel when product selected', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
quantity: 5,
unitPrice: 50000,
lineTotal: 250000
}
}
const wrapper = mount(OrderLineField, { props })
await wrapper.vm.$nextTick()
// After mock API call completes
await new Promise((resolve) => setTimeout(resolve, 400))
expect(wrapper.find('.card').exists()).toBe(true)
})
})
describe('Product Selection', () => {
it('emits update:modelValue when product is selected', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const select = wrapper.find('select')
await select.setValue('PROD-001')
await new Promise((resolve) => setTimeout(resolve, 400))
expect(wrapper.emitted('update:modelValue')).toBeDefined()
})
it('loads product details after selection', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const select = wrapper.find('select')
await select.setValue('PROD-001')
await new Promise((resolve) => setTimeout(resolve, 400))
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
if (emitted && emitted[0]) {
expect((emitted[0][0] as any).unitPrice).toBeGreaterThan(0)
}
})
it('shows error when product not found', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
// Simulate selecting invalid product
const select = wrapper.find('select')
await select.setValue('PROD-INVALID')
await new Promise((resolve) => setTimeout(resolve, 400))
// Component would show error (in real scenario with API validation)
// This is testable by checking component state
})
it('clears product details when selection cleared', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
quantity: 5,
unitPrice: 50000,
lineTotal: 250000
}
}
const wrapper = mount(OrderLineField, { props })
await new Promise((resolve) => setTimeout(resolve, 400))
const select = wrapper.find('select')
await select.setValue('')
expect(wrapper.find('.card').exists()).toBe(false)
})
})
describe('Quantity Validation', () => {
it('validates quantity is at least 1', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const quantityInput = wrapper.findAll('input[type="number"]')[0]
await quantityInput.setValue(0)
// Validation should fail
expect(wrapper.vm.quantityError).not.toBeNull()
})
it('validates quantity does not exceed available stock', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const select = wrapper.find('select')
await select.setValue('PROD-001')
await new Promise((resolve) => setTimeout(resolve, 400))
const quantityInput = wrapper.findAll('input[type="number"]')[0]
await quantityInput.setValue(9999) // Exceeds available
// Should validate against available quantity (100 in mock)
expect(wrapper.vm.quantityError).not.toBeNull()
})
it('allows valid quantity', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const select = wrapper.find('select')
await select.setValue('PROD-001')
await new Promise((resolve) => setTimeout(resolve, 400))
const quantityInput = wrapper.findAll('input[type="number"]')[0]
await quantityInput.setValue(50)
wrapper.vm.validateQuantity()
expect(wrapper.vm.quantityError).toBeNull()
})
it('requires product before accepting quantity', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const quantityInput = wrapper.findAll('input[type="number"]')[0]
await quantityInput.setValue(5)
wrapper.vm.validateQuantity()
expect(wrapper.vm.quantityError).not.toBeNull()
})
})
describe('Price Calculation', () => {
it('calculates line total = quantity × unitPrice', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
// Set product
const select = wrapper.find('select')
await select.setValue('PROD-001')
await new Promise((resolve) => setTimeout(resolve, 400))
// Set quantity
const quantityInput = wrapper.findAll('input[type="number"]')[0]
await quantityInput.setValue(10)
// Get line total from component state
const lineTotal = wrapper.vm.line.lineTotal
const expectedTotal = 10 * wrapper.vm.line.unitPrice
expect(lineTotal).toBe(expectedTotal)
})
it('updates line total on quantity change', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
quantity: 5,
unitPrice: 50000,
lineTotal: 250000
}
}
const wrapper = mount(OrderLineField, { props })
await new Promise((resolve) => setTimeout(resolve, 400))
const quantityInput = wrapper.findAll('input[type="number"]')[0]
await quantityInput.setValue(10)
expect(wrapper.vm.line.lineTotal).toBe(500000)
})
it('displays formatted currency values', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
quantity: 5,
unitPrice: 50000,
lineTotal: 250000
}
}
const wrapper = mount(OrderLineField, { props })
await new Promise((resolve) => setTimeout(resolve, 400))
// Should display formatted values (via formatCurrency)
expect(wrapper.vm.formatCurrency(250000)).toBeDefined()
})
})
describe('Remove Action', () => {
it('emits remove event when remove button clicked', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const removeBtn = wrapper.find('.btn-outline-danger')
await removeBtn.trigger('click')
expect(wrapper.emitted('remove')).toBeDefined()
})
})
describe('Loading State', () => {
it('shows loading indicator while fetching product details', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const select = wrapper.find('select')
select.element.value = 'PROD-001'
select.trigger('change')
// Should show loading indicator
expect(wrapper.vm.isLoading).toBe(true)
await new Promise((resolve) => setTimeout(resolve, 400))
// Loading should complete
expect(wrapper.vm.isLoading).toBe(false)
})
it('disables select during loading', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const select = wrapper.find('select')
select.element.value = 'PROD-001'
select.trigger('change')
// Select should be disabled while loading
expect(select.element.disabled).toBe(true) // due to isLoading
await new Promise((resolve) => setTimeout(resolve, 400))
})
})
describe('Props Updates', () => {
it('updates line data when props change', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const newProps = {
modelValue: {
productId: 'PROD-002',
quantity: 3,
unitPrice: 75000,
lineTotal: 225000
}
}
await wrapper.setProps(newProps)
expect(wrapper.vm.line.productId).toBe('PROD-002')
expect(wrapper.vm.line.quantity).toBe(3)
})
})
describe('Emit Updates', () => {
it('emits update:modelValue with complete line data', async () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const select = wrapper.find('select')
await select.setValue('PROD-001')
await new Promise((resolve) => setTimeout(resolve, 400))
const quantityInput = wrapper.findAll('input[type="number"]')[0]
await quantityInput.setValue(5)
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
if (emitted && emitted.length > 0) {
const lastEmit = emitted[emitted.length - 1][0] as any
expect(lastEmit.productId).toBe('PROD-001')
expect(lastEmit.quantity).toBe(5)
expect(lastEmit.unitPrice).toBeGreaterThan(0)
expect(lastEmit.lineTotal).toBeGreaterThan(0)
}
})
})
describe('Accessibility', () => {
it('has proper labels for form inputs', () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
expect(wrapper.text()).toContain('Product')
expect(wrapper.text()).toContain('Quantity')
expect(wrapper.text()).toContain('Unit Price')
})
it('shows required indicators', () => {
const wrapper = mount(OrderLineField, { props: defaultProps })
const labels = wrapper.findAll('label')
const requiredLabels = labels.filter((l) =>
l.text().includes('*')
)
expect(requiredLabels.length).toBeGreaterThan(0)
})
})
})
@@ -1,118 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import OrderLineField from './OrderLineField.vue'
const meta: Meta<typeof OrderLineField> = {
title: 'Fields/Domain/OrderLineField',
component: OrderLineField,
argTypes: {
modelValue: {
control: 'object',
description: 'Order line item (productId, quantity, unitPrice, lineTotal)'
}
}
}
export default meta
type Story = StoryObj<typeof OrderLineField>
const Template = (args: any) => ({
components: { OrderLineField },
setup() {
return { args }
},
template: `
<div>
<OrderLineField
v-bind="args"
@update:modelValue="args.modelValue = $event"
@remove="console.log('Line removed')"
/>
<div class="mt-3">
<strong>Current Value:</strong>
<pre>{{ JSON.stringify(args.modelValue, null, 2) }}</pre>
</div>
</div>
`
})
export const Default: Story = {
render: Template,
args: {
modelValue: {
productId: '',
quantity: 1,
unitPrice: 0,
lineTotal: 0
}
}
}
export const WithProduct: Story = {
render: Template,
args: {
modelValue: {
productId: 'PROD-001',
quantity: 5,
unitPrice: 50000,
lineTotal: 250000
}
}
}
export const MultipleLines: Story = {
render: (args: any) => ({
components: { OrderLineField },
setup() {
const lines = [
{ productId: 'PROD-001', quantity: 5, unitPrice: 50000, lineTotal: 250000 },
{ productId: 'PROD-002', quantity: 3, unitPrice: 75000, lineTotal: 225000 },
{ productId: '', quantity: 1, unitPrice: 0, lineTotal: 0 }
]
return { lines, args }
},
template: `
<div>
<h5>Order Lines</h5>
<div v-for="(line, idx) in lines" :key="idx" class="mb-3">
<OrderLineField
v-model="lines[idx]"
@remove="lines.splice(idx, 1)"
/>
</div>
<strong>Order Total:</strong>
₩{{ lines.reduce((sum, l) => sum + (l.lineTotal || 0), 0).toLocaleString() }}
</div>
`
})
}
export const WithValidation: Story = {
render: Template,
args: {
modelValue: {
productId: 'PROD-001',
quantity: 10,
unitPrice: 50000,
lineTotal: 500000
}
},
parameters: {
docs: {
description: {
story: 'Example with quantity exceeding available stock will show validation error'
}
}
}
}
export const Empty: Story = {
render: Template,
args: {
modelValue: {
productId: '',
quantity: 0,
unitPrice: 0,
lineTotal: 0
}
}
}
@@ -1,453 +0,0 @@
<template>
<div class="order-line-field">
<div class="order-line-group">
<!-- Product Selection (Async Lookup) -->
<div class="form-group">
<label class="form-label">
Product
<span class="text-danger">*</span>
</label>
<div class="input-group">
<select
v-model="line.productId"
class="form-control"
:disabled="isLoading"
@change="handleProductChange"
>
<option value="">-- Select Product --</option>
<option
v-for="product in productOptions"
:key="product.value"
:value="product.value"
>
{{ product.label }} (SKU: {{ product.sku }})
</option>
</select>
<button
v-if="isSearching"
class="btn btn-outline-secondary"
disabled
>
<span class="spinner-border spinner-border-sm me-2"></span>
Loading...
</button>
</div>
<div v-if="productError" class="invalid-feedback d-block">
{{ productError }}
</div>
</div>
<!-- Quantity Input -->
<div class="form-group">
<label class="form-label">
Quantity
<span class="text-danger">*</span>
</label>
<input
v-model.number="line.quantity"
type="number"
:min="1"
:max="availableQuantity"
class="form-control"
:class="{ 'is-invalid': quantityError }"
@blur="validateQuantity"
@input="calculateLineTotal"
/>
<small v-if="!quantityError" class="form-text text-muted d-block mt-1">
Available: {{ availableQuantity }} units
</small>
<div v-if="quantityError" class="invalid-feedback d-block">
{{ quantityError }}
</div>
</div>
<!-- Unit Price (Read-only, auto-filled) -->
<div class="form-group">
<label class="form-label">Unit Price</label>
<div class="input-group">
<input
:value="formatCurrency(line.unitPrice)"
type="text"
class="form-control"
disabled
/>
<span class="input-group-text"></span>
</div>
</div>
<!-- Line Total (Auto-calculated) -->
<div class="form-group">
<label class="form-label">Line Total</label>
<div class="input-group">
<input
:value="formatCurrency(line.lineTotal)"
type="text"
class="form-control"
disabled
/>
<span class="input-group-text"></span>
</div>
</div>
<!-- Remove Button -->
<div class="form-group d-flex align-items-end">
<button
class="btn btn-sm btn-outline-danger"
@click="$emit('remove')"
>
<i class="bi bi-trash"></i> Remove
</button>
</div>
</div>
<!-- Product Details Panel -->
<div v-if="selectedProduct" class="card mt-3">
<div class="card-body">
<h6 class="card-title">Product Details</h6>
<div class="row">
<div class="col-md-6">
<small>
<strong>Category:</strong> {{ selectedProduct.categoryId }}<br>
<strong>Stock:</strong> {{ availableQuantity }} units<br>
</small>
</div>
<div class="col-md-6">
<small>
<strong>List Price:</strong> {{ formatCurrency(selectedProduct.price) }}<br>
<strong>Status:</strong>
<span :class="statusClass">{{ selectedProduct.status }}</span>
</small>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useFormatting } from '@/composables/useFormatting'
import { productsApi } from '@/services/api/client'
interface OrderLine {
productId: string
quantity: number
unitPrice: number
lineTotal: number
}
interface Props {
modelValue: OrderLine
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:modelValue': [value: OrderLine]
remove: []
}>()
const { formatCurrency } = useFormatting()
// State
const line = ref<OrderLine>({ ...props.modelValue })
const productOptions = ref<any[]>([])
const selectedProduct = ref<any>(null)
const isLoading = ref(false)
const isSearching = ref(false)
// Validation state
const productError = ref<string | null>(null)
const quantityError = ref<string | null>(null)
// Computed
const availableQuantity = computed(() => {
if (!selectedProduct.value) return 0
return selectedProduct.value.qtyOnHand || 0
})
const statusClass = computed(() => {
if (!selectedProduct.value) return ''
switch (selectedProduct.value.status) {
case 'ACTIVE':
return 'badge bg-success'
case 'INACTIVE':
return 'badge bg-secondary'
case 'DISCONTINUED':
return 'badge bg-danger'
default:
return 'badge bg-secondary'
}
})
// Watchers
watch(() => props.modelValue, (newVal) => {
line.value = { ...newVal }
})
// Methods
const handleProductChange = async () => {
productError.value = null
if (!line.value.productId) {
selectedProduct.value = null
line.value.unitPrice = 0
line.value.quantity = 1
calculateLineTotal()
return
}
isLoading.value = true
try {
// Simulate API call (would be: await productsApi.getProduct(...))
const product = await mockGetProduct(line.value.productId)
if (!product) {
productError.value = 'Product not found'
selectedProduct.value = null
return
}
selectedProduct.value = product
line.value.unitPrice = product.price
// Reset quantity and recalculate
line.value.quantity = 1
calculateLineTotal()
} catch (error) {
productError.value = 'Failed to load product details'
} finally {
isLoading.value = false
}
}
const validateQuantity = () => {
quantityError.value = null
if (!line.value.productId) {
quantityError.value = 'Please select a product first'
return false
}
if (!line.value.quantity || line.value.quantity < 1) {
quantityError.value = 'Quantity must be at least 1'
return false
}
if (line.value.quantity > availableQuantity.value) {
quantityError.value = `Maximum available quantity is ${availableQuantity.value}`
return false
}
return true
}
const calculateLineTotal = () => {
line.value.lineTotal = line.value.quantity * line.value.unitPrice
emit('update:modelValue', { ...line.value })
}
// Mock API (would be replaced with real API call)
const mockGetProduct = async (productId: string) => {
return new Promise((resolve) => {
setTimeout(() => {
// Mock product data
resolve({
productId,
name: `Product ${productId}`,
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 100,
status: 'ACTIVE'
})
}, 300)
})
}
</script>
<style scoped>
.order-line-field {
margin-bottom: 1.5rem;
}
.order-line-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
.form-control:disabled {
background-color: #e9ecef;
opacity: 1;
cursor: not-allowed;
}
.input-group {
display: flex;
position: relative;
}
.input-group-text {
background-color: #e9ecef;
border: 1px solid #dee2e6;
border-left: 0;
border-radius: 0 4px 4px 0;
padding: 0.5rem 0.75rem;
font-weight: 500;
}
.btn {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 4px;
border: 1px solid #dee2e6;
cursor: pointer;
transition: all 0.15s ease-in-out;
}
.btn:hover:not(:disabled) {
border-color: #0d6efd;
color: #0d6efd;
}
.btn-outline-danger {
color: #dc3545;
border-color: #dc3545;
}
.btn-outline-danger:hover {
background-color: #dc3545;
color: white;
}
.btn-outline-secondary:disabled {
opacity: 0.65;
}
.spinner-border {
width: 1rem;
height: 1rem;
border-width: 0.2em;
}
.me-2 {
margin-right: 0.5rem;
}
.card {
border: 1px solid #dee2e6;
border-radius: 4px;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.card-body {
padding: 1rem;
}
.card-title {
margin-bottom: 0.75rem;
}
.badge {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
}
.bg-success {
background-color: #28a745 !important;
}
.bg-secondary {
background-color: #6c757d !important;
}
.bg-danger {
background-color: #dc3545 !important;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.text-muted {
color: #6c757d;
}
.form-text {
font-size: 0.875rem;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
}
.d-flex {
display: flex;
}
.align-items-end {
align-items: flex-end;
}
.mt-3 {
margin-top: 1rem;
}
.d-block {
display: block;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.col-md-6 {
flex: 1;
}
small {
line-height: 1.6;
}
strong {
font-weight: 600;
}
</style>
@@ -1,417 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import ProductField from './ProductField.vue'
describe('ProductField (Domain Field)', () => {
const defaultProps = {
modelValue: null
}
describe('Rendering', () => {
it('renders SKU input field', () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
expect(input.exists()).toBe(true)
expect(input.attributes('placeholder')).toContain('SKU')
})
it('shows product details when product loaded', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Standard Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 150,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
expect(wrapper.find('.card').exists()).toBe(true)
expect(wrapper.text()).toContain('Standard Widget')
})
})
describe('SKU Lookup', () => {
it('validates SKU is not empty', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('')
await input.trigger('blur')
// Empty SKU should clear product
expect(wrapper.vm.product).toBeNull()
})
it('validates SKU minimum length', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('AB')
await input.trigger('blur')
expect(wrapper.vm.skuError).not.toBeNull()
expect(wrapper.vm.skuError).toContain('at least 3 characters')
})
it('triggers lookup on blur with valid SKU', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('SKU-001')
await input.trigger('blur')
// Wait for async lookup
await new Promise((resolve) => setTimeout(resolve, 500))
expect(wrapper.vm.product).not.toBeNull()
})
it('shows loading state during lookup', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
input.element.value = 'SKU-001'
input.trigger('blur')
// Check loading state immediately
expect(wrapper.vm.isLoading).toBe(true)
await new Promise((resolve) => setTimeout(resolve, 500))
expect(wrapper.vm.isLoading).toBe(false)
})
it('handles product not found error', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('INVALID-SKU')
await input.trigger('blur')
await new Promise((resolve) => setTimeout(resolve, 500))
expect(wrapper.vm.productNotFound).toBe(true)
expect(wrapper.vm.product).toBeNull()
})
it('returns valid product when found', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('SKU-001')
await input.trigger('blur')
await new Promise((resolve) => setTimeout(resolve, 500))
expect(wrapper.vm.product).not.toBeNull()
expect(wrapper.vm.product?.sku).toBe('SKU-001')
})
})
describe('Product Display', () => {
it('displays product information correctly', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Standard Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 150,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
expect(wrapper.text()).toContain('Standard Widget')
expect(wrapper.text()).toContain('SKU-001')
expect(wrapper.text()).toContain('CAT-001')
})
it('formats price as currency', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 100,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
// Check that formatCurrency is called
expect(wrapper.vm.formatCurrency(50000)).toBeDefined()
})
it('shows status badge with correct color', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 100,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
const statusBadge = wrapper.find('.badge')
expect(statusBadge.exists()).toBe(true)
expect(statusBadge.classes()).toContain('bg-success')
})
})
describe('Stock Level Warnings', () => {
it('shows low stock warning when qty < 10', async () => {
const props = {
modelValue: {
productId: 'PROD-002',
sku: 'SKU-002',
name: 'Premium Gadget',
categoryId: 'CAT-002',
price: 125000,
qtyOnHand: 5,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
expect(wrapper.text()).toContain('Low Stock Alert')
expect(wrapper.text()).toContain('5 units')
})
it('shows danger color for out of stock', async () => {
const props = {
modelValue: {
productId: 'PROD-003',
sku: 'SKU-003',
name: 'Out of Stock',
categoryId: 'CAT-001',
price: 25000,
qtyOnHand: 0,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
expect(wrapper.vm.stockClass).toContain('text-danger')
})
it('shows warning color for low stock', async () => {
const props = {
modelValue: {
productId: 'PROD-002',
sku: 'SKU-002',
name: 'Low Stock Item',
categoryId: 'CAT-002',
price: 125000,
qtyOnHand: 5,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
expect(wrapper.vm.stockClass).toContain('text-warning')
})
it('shows success color for good stock', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 150,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
expect(wrapper.vm.stockClass).toContain('text-success')
})
})
describe('Status Handling', () => {
it('shows correct status badge for ACTIVE products', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Active Product',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 100,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
expect(wrapper.vm.statusClass).toContain('bg-success')
})
it('shows correct status badge for DISCONTINUED products', async () => {
const props = {
modelValue: {
productId: 'PROD-003',
sku: 'SKU-003',
name: 'Discontinued Product',
categoryId: 'CAT-001',
price: 25000,
qtyOnHand: 0,
status: 'DISCONTINUED',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
expect(wrapper.vm.statusClass).toContain('bg-danger')
})
})
describe('Clear Functionality', () => {
it('clears product when clear button clicked', async () => {
const props = {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 100,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
const wrapper = mount(ProductField, { props })
const clearBtn = wrapper.find('.btn-outline-secondary')
await clearBtn.trigger('click')
expect(wrapper.vm.product).toBeNull()
expect(wrapper.vm.sku).toBe('')
})
})
describe('Emit Events', () => {
it('emits update:modelValue when product loaded', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('SKU-001')
await input.trigger('blur')
await new Promise((resolve) => setTimeout(resolve, 500))
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
})
it('emits select event with product data', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('SKU-001')
await input.trigger('blur')
await new Promise((resolve) => setTimeout(resolve, 500))
const emitted = wrapper.emitted('select')
expect(emitted).toBeDefined()
if (emitted && emitted[0]) {
expect((emitted[0][0] as any).sku).toBe('SKU-001')
}
})
it('emits update:modelValue with null when product not found', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('INVALID-SKU')
await input.trigger('blur')
await new Promise((resolve) => setTimeout(resolve, 500))
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeDefined()
})
})
describe('Props Updates', () => {
it('updates when props change', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const newProps = {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 100,
status: 'ACTIVE',
lastUpdated: '2026-08-28'
}
}
await wrapper.setProps(newProps)
expect(wrapper.vm.product).not.toBeNull()
expect(wrapper.vm.product?.name).toBe('Widget')
})
})
describe('Accessibility', () => {
it('has proper labels', () => {
const wrapper = mount(ProductField, { props: defaultProps })
expect(wrapper.text()).toContain('Product SKU')
})
it('shows required indicator', () => {
const wrapper = mount(ProductField, { props: defaultProps })
expect(wrapper.text()).toContain('*')
})
it('displays error messages for accessibility', async () => {
const wrapper = mount(ProductField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('AB')
await input.trigger('blur')
expect(wrapper.vm.skuError).not.toBeNull()
})
})
})
@@ -1,152 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import ProductField from './ProductField.vue'
const meta: Meta<typeof ProductField> = {
title: 'Fields/Domain/ProductField',
component: ProductField,
argTypes: {
modelValue: {
control: 'object',
description: 'Selected product object'
}
}
}
export default meta
type Story = StoryObj<typeof ProductField>
const Template = (args: any) => ({
components: { ProductField },
setup() {
return { args }
},
template: `
<div>
<ProductField
v-bind="args"
@update:modelValue="args.modelValue = $event"
@select="console.log('Product selected:', $event)"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Selected Product:</strong>
<pre>{{ JSON.stringify(args.modelValue, null, 2) }}</pre>
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: null
}
}
export const WithProduct: Story = {
render: Template,
args: {
modelValue: {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Standard Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 150,
status: 'ACTIVE',
description: 'High-quality standard widget for industrial use',
lastUpdated: new Date().toISOString().split('T')[0]
}
}
}
export const LowStockWarning: Story = {
render: Template,
args: {
modelValue: {
productId: 'PROD-002',
sku: 'SKU-002',
name: 'Premium Gadget',
categoryId: 'CAT-002',
price: 125000,
qtyOnHand: 5,
status: 'ACTIVE',
description: 'Premium grade gadget with extended warranty',
lastUpdated: new Date().toISOString().split('T')[0]
}
},
parameters: {
docs: {
description: {
story: 'Shows low stock warning when available quantity is less than 10'
}
}
}
}
export const Discontinued: Story = {
render: Template,
args: {
modelValue: {
productId: 'PROD-003',
sku: 'SKU-003',
name: 'Discontinued Item',
categoryId: 'CAT-001',
price: 25000,
qtyOnHand: 0,
status: 'DISCONTINUED',
description: 'This product has been discontinued',
lastUpdated: new Date().toISOString().split('T')[0]
}
}
}
export const OutOfStock: Story = {
render: Template,
args: {
modelValue: {
productId: 'PROD-004',
sku: 'SKU-004',
name: 'Out of Stock Item',
categoryId: 'CAT-002',
price: 75000,
qtyOnHand: 0,
status: 'ACTIVE',
description: 'Temporarily out of stock',
lastUpdated: new Date().toISOString().split('T')[0]
}
}
}
export const LookupDemo: Story = {
render: (args: any) => ({
components: { ProductField },
setup() {
const tips = [
'Try SKU-001 for Standard Widget',
'Try SKU-002 for Premium Gadget (low stock)',
'Try SKU-003 for Discontinued Item'
]
return { args, tips }
},
template: `
<div>
<ProductField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div class="alert alert-info mt-3">
<strong>💡 Tips for testing:</strong>
<ul>
<li v-for="tip in tips" :key="tip">{{ tip }}</li>
</ul>
</div>
<div v-if="args.modelValue" class="mt-3">
<strong>Selected:</strong> {{ args.modelValue.name }}
</div>
</div>
`
}),
args: {
modelValue: null
}
}
@@ -1,435 +0,0 @@
<template>
<div class="product-field">
<!-- SKU Input for Product Lookup -->
<div class="form-group">
<label class="form-label">
Product SKU
<span class="text-danger">*</span>
</label>
<div class="input-group">
<input
v-model="sku"
type="text"
class="form-control"
placeholder="e.g., SKU-001"
:class="{ 'is-invalid': skuError }"
@blur="handleSkuLookup"
/>
<button
v-if="isLoading"
class="btn btn-outline-secondary"
disabled
>
<span class="spinner-border spinner-border-sm me-2"></span>
Loading...
</button>
</div>
<div v-if="skuError" class="invalid-feedback d-block">
{{ skuError }}
</div>
</div>
<!-- Product Details Panel -->
<div v-if="product" class="card mt-2">
<div class="card-body">
<h5 class="card-title">{{ product.name }}</h5>
<div class="row mb-3">
<!-- Left Column -->
<div class="col-md-6">
<small>
<strong>SKU:</strong> {{ product.sku }}<br>
<strong>Category:</strong>
<span class="badge bg-info">{{ product.categoryId }}</span><br>
<strong>Status:</strong>
<span :class="statusClass">{{ product.status }}</span><br>
</small>
</div>
<!-- Right Column -->
<div class="col-md-6">
<small>
<strong>List Price:</strong>
<span class="text-primary">{{ formatCurrency(product.price) }}</span><br>
<strong>Available Stock:</strong>
<strong :class="stockClass">{{ product.qtyOnHand }}</strong> units<br>
<strong>Last Updated:</strong> {{ formatDate(product.lastUpdated) }}<br>
</small>
</div>
</div>
<!-- Stock Level Warning -->
<div v-if="product.qtyOnHand < 10" class="alert alert-warning">
Low Stock Alert: Only {{ product.qtyOnHand }} units available
</div>
<!-- Description -->
<div v-if="product.description" class="alert alert-info">
<strong>Description:</strong> {{ product.description }}
</div>
<!-- Clear Button -->
<button
class="btn btn-sm btn-outline-secondary"
@click="clearProduct"
>
Clear
</button>
</div>
</div>
<!-- Error State -->
<div v-if="productNotFound" class="alert alert-danger mt-2">
Product not found. Please check the SKU and try again.
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFormatting } from '@/composables/useFormatting'
interface Product {
productId: string
sku: string
name: string
categoryId: string
price: number
qtyOnHand: number
status: string
description?: string
lastUpdated: string
}
const props = defineProps<{
modelValue: Product | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: Product | null]
select: [product: Product]
}>()
const { formatCurrency, formatDate } = useFormatting()
// State
const sku = ref('')
const product = ref<Product | null>(null)
const isLoading = ref(false)
const skuError = ref<string | null>(null)
const productNotFound = ref(false)
// Computed
const statusClass = computed(() => {
if (!product.value) return ''
switch (product.value.status) {
case 'ACTIVE':
return 'badge bg-success'
case 'INACTIVE':
return 'badge bg-secondary'
case 'DISCONTINUED':
return 'badge bg-danger'
default:
return 'badge bg-secondary'
}
})
const stockClass = computed(() => {
if (!product.value) return ''
if (product.value.qtyOnHand === 0) return 'text-danger'
if (product.value.qtyOnHand < 10) return 'text-warning'
return 'text-success'
})
// Methods
const handleSkuLookup = async () => {
skuError.value = null
productNotFound.value = false
if (!sku.value) {
product.value = null
emit('update:modelValue', null)
return
}
// Validate SKU format
if (sku.value.trim().length < 3) {
skuError.value = 'SKU must be at least 3 characters'
return
}
isLoading.value = true
try {
// Mock API call - would be: await productsApi.getProductBySku(sku.value)
const foundProduct = await mockGetProductBySku(sku.value.trim())
if (!foundProduct) {
productNotFound.value = true
product.value = null
skuError.value = null
emit('update:modelValue', null)
return
}
product.value = foundProduct
emit('update:modelValue', foundProduct)
emit('select', foundProduct)
} catch (error) {
skuError.value = 'Failed to look up product'
product.value = null
} finally {
isLoading.value = false
}
}
const clearProduct = () => {
sku.value = ''
product.value = null
skuError.value = null
productNotFound.value = false
emit('update:modelValue', null)
}
// Mock API - would be replaced with real API call
const mockGetProductBySku = async (skuQuery: string): Promise<Product | null> => {
return new Promise((resolve) => {
setTimeout(() => {
const mockProducts: Record<string, Product> = {
'SKU-001': {
productId: 'PROD-001',
sku: 'SKU-001',
name: 'Standard Widget',
categoryId: 'CAT-001',
price: 50000,
qtyOnHand: 150,
status: 'ACTIVE',
description: 'High-quality standard widget for industrial use',
lastUpdated: new Date().toISOString().split('T')[0]
},
'SKU-002': {
productId: 'PROD-002',
sku: 'SKU-002',
name: 'Premium Gadget',
categoryId: 'CAT-002',
price: 125000,
qtyOnHand: 5,
status: 'ACTIVE',
description: 'Premium grade gadget with extended warranty',
lastUpdated: new Date().toISOString().split('T')[0]
},
'SKU-003': {
productId: 'PROD-003',
sku: 'SKU-003',
name: 'Discontinued Item',
categoryId: 'CAT-001',
price: 25000,
qtyOnHand: 0,
status: 'DISCONTINUED',
description: 'This product has been discontinued',
lastUpdated: new Date().toISOString().split('T')[0]
}
}
resolve(mockProducts[skuQuery] || null)
}, 400)
})
}
</script>
<style scoped>
.product-field {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
display: block;
font-size: 0.875rem;
}
.input-group {
display: flex;
position: relative;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
flex: 1;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
.btn {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 0 4px 4px 0;
border: 1px solid #dee2e6;
border-left: 0;
cursor: pointer;
transition: all 0.15s ease-in-out;
}
.btn-outline-secondary {
color: #6c757d;
border-color: #6c757d;
}
.btn-outline-secondary:disabled {
opacity: 0.65;
}
.btn-outline-secondary:hover:not(:disabled) {
background-color: #6c757d;
color: white;
}
.spinner-border {
width: 1rem;
height: 1rem;
border-width: 0.2em;
}
.me-2 {
margin-right: 0.5rem;
}
.card {
border: 1px solid #dee2e6;
border-radius: 4px;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.card-body {
padding: 1rem;
}
.card-title {
margin-bottom: 1rem;
font-weight: 600;
font-size: 1rem;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.col-md-6 {
flex: 1;
}
.mb-3 {
margin-bottom: 1rem;
}
small {
line-height: 1.8;
display: block;
}
.badge {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
color: white;
}
.bg-success {
background-color: #28a745 !important;
}
.bg-secondary {
background-color: #6c757d !important;
}
.bg-danger {
background-color: #dc3545 !important;
}
.bg-info {
background-color: #17a2b8 !important;
}
.text-primary {
color: #0d6efd;
font-weight: 600;
}
.text-success {
color: #28a745;
font-weight: 600;
}
.text-warning {
color: #ffc107;
font-weight: 600;
}
.text-danger {
color: #dc3545;
font-weight: 600;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-warning {
background-color: #fff3cd;
color: #856404;
border-color: #ffeaa7;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border-color: #bee5eb;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.d-block {
display: block;
}
.mt-2 {
margin-top: 0.5rem;
}
</style>
@@ -1,313 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import RoleField from './RoleField.vue'
describe('RoleField (Domain Field)', () => {
const defaultProps = {
modelValue: null
}
describe('Rendering', () => {
it('renders role checkboxes', () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.text()).toContain('Roles')
expect(wrapper.text()).toContain('Administrator')
expect(wrapper.text()).toContain('Manager')
expect(wrapper.text()).toContain('Operator')
expect(wrapper.text()).toContain('Viewer')
})
it('renders role descriptions', () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.text()).toContain('Full system access')
expect(wrapper.text()).toContain('Manage orders, inventory')
})
it('renders primary role dropdown', () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.text()).toContain('Primary Role')
})
it('renders permission level display', () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.text()).toContain('Permission Level')
})
})
describe('Role Selection', () => {
it('requires at least one role', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.vm.roleError).toBeNull()
wrapper.vm.validateAndEmit()
expect(wrapper.vm.roleError).not.toBeNull()
expect(wrapper.vm.roleError).toContain('At least one role')
})
it('allows single role selection', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
expect(wrapper.vm.selectedRoleIds).toContain('operator')
expect(wrapper.vm.roleError).toBeNull()
})
it('allows multiple role selection', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
wrapper.vm.toggleRole('manager')
expect(wrapper.vm.selectedRoleIds).toContain('operator')
expect(wrapper.vm.selectedRoleIds).toContain('manager')
expect(wrapper.vm.selectedRoleIds.length).toBe(2)
})
it('can deselect a role', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
expect(wrapper.vm.selectedRoleIds).toContain('operator')
wrapper.vm.toggleRole('operator')
expect(wrapper.vm.selectedRoleIds).not.toContain('operator')
})
it('supports 6 roles', () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.vm.availableRoles.length).toBe(6)
expect(wrapper.vm.availableRoles.some((r) => r.id === 'admin')).toBe(true)
expect(wrapper.vm.availableRoles.some((r) => r.id === 'warehouse')).toBe(true)
})
})
describe('Permission Level Calculation', () => {
it('shows ADMIN level when admin role selected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('admin')
await wrapper.vm.$nextTick()
expect(wrapper.vm.maxPermissionLevel).toBe('ADMIN')
})
it('shows MANAGER level when manager selected (no admin)', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('manager')
await wrapper.vm.$nextTick()
expect(wrapper.vm.maxPermissionLevel).toBe('MANAGER')
})
it('shows OPERATOR level when operator selected (no higher)', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
await wrapper.vm.$nextTick()
expect(wrapper.vm.maxPermissionLevel).toBe('OPERATOR')
})
it('shows highest permission when multiple roles selected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
wrapper.vm.toggleRole('viewer')
await wrapper.vm.$nextTick()
expect(wrapper.vm.maxPermissionLevel).toBe('OPERATOR')
})
it('shows highest available when admin + manager selected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('admin')
wrapper.vm.toggleRole('manager')
await wrapper.vm.$nextTick()
expect(wrapper.vm.maxPermissionLevel).toBe('ADMIN')
})
})
describe('Primary Role', () => {
it('defaults to first selected role', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
await wrapper.vm.$nextTick()
wrapper.vm.validateAndEmit()
expect(wrapper.vm.selectedPrimaryRole).toBe('operator')
})
it('allows primary role selection from selected roles', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
wrapper.vm.toggleRole('manager')
wrapper.vm.selectedPrimaryRole = 'manager'
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedPrimaryRole).toBe('manager')
})
it('resets primary role if deselected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
wrapper.vm.toggleRole('manager')
wrapper.vm.selectedPrimaryRole = 'manager'
await wrapper.vm.$nextTick()
wrapper.vm.toggleRole('manager')
expect(wrapper.vm.selectedPrimaryRole).toBe('')
})
})
describe('Completion Status', () => {
it('is complete when role selected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(true)
})
it('is not complete when no roles selected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.vm.isComplete).toBe(false)
})
it('shows success alert when complete', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Role assignment is valid')
})
it('shows error alert when empty', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.validateAndEmit()
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('At least one role must be selected')
})
})
describe('Event Emission', () => {
it('emits update:modelValue with selected roles', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
wrapper.vm.toggleRole('viewer')
wrapper.vm.validateAndEmit()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeTruthy()
expect(emitted[0][0].selectedRoleIds).toContain('operator')
expect(emitted[0][0].selectedRoleIds).toContain('viewer')
})
it('emits primary role in update', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('manager')
wrapper.vm.selectedPrimaryRole = 'manager'
wrapper.vm.validateAndEmit()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted[0][0].primaryRoleId).toBe('manager')
})
it('emits null when no roles selected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.validateAndEmit()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted[0][0]).toBeNull()
})
})
describe('Props Updates', () => {
it('loads roles from modelValue prop', () => {
const props = {
modelValue: {
selectedRoleIds: ['manager', 'operator'],
primaryRoleId: 'manager'
}
}
const wrapper = mount(RoleField, { props })
expect(wrapper.vm.selectedRoleIds).toEqual(['manager', 'operator'])
expect(wrapper.vm.selectedPrimaryRole).toBe('manager')
})
})
describe('Accessibility', () => {
it('shows required indicators', () => {
const wrapper = mount(RoleField, { props: defaultProps })
const labels = wrapper.findAll('label')
const requiredLabels = labels.filter((l) => l.text().includes('*'))
expect(requiredLabels.length).toBeGreaterThan(0)
})
it('shows disabled primary role message when no roles selected', () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.text()).toContain('Select at least one role first')
})
it('has proper label associations', () => {
const wrapper = mount(RoleField, { props: defaultProps })
const checkboxes = wrapper.findAll('input[type="checkbox"]')
expect(checkboxes.length).toBe(6)
})
})
describe('Selected Roles Summary', () => {
it('shows summary when roles selected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('operator')
wrapper.vm.toggleRole('manager')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('2 Roles')
expect(wrapper.text()).toContain('Operator')
expect(wrapper.text()).toContain('Manager')
})
it('does not show summary when no roles selected', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
expect(wrapper.vm.selectedRoles.length).toBe(0)
})
it('uses singular "Role" for single selection', async () => {
const wrapper = mount(RoleField, { props: defaultProps })
wrapper.vm.toggleRole('viewer')
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('1 Role')
})
})
})
@@ -1,77 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import RoleField from './RoleField.vue'
const meta: Meta<typeof RoleField> = {
title: 'Fields/Domain/RoleField',
component: RoleField
}
export default meta
type Story = StoryObj<typeof RoleField>
const Template = (args: any) => ({
components: { RoleField },
setup() {
return { args }
},
template: `
<div>
<RoleField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Roles:</strong>
<p>Selected: {{ args.modelValue.selectedRoleIds.join(', ') }}</p>
<p v-if="args.modelValue.primaryRoleId">Primary: {{ args.modelValue.primaryRoleId }}</p>
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: null
}
}
export const Operator: Story = {
render: Template,
args: {
modelValue: {
selectedRoleIds: ['operator'],
primaryRoleId: 'operator'
}
}
}
export const ManagerWithOperator: Story = {
render: Template,
args: {
modelValue: {
selectedRoleIds: ['manager', 'operator'],
primaryRoleId: 'manager'
}
}
}
export const AdminFull: Story = {
render: Template,
args: {
modelValue: {
selectedRoleIds: ['admin', 'manager', 'operator'],
primaryRoleId: 'admin'
}
}
}
export const MultipleRoles: Story = {
render: Template,
args: {
modelValue: {
selectedRoleIds: ['operator', 'auditor', 'warehouse'],
primaryRoleId: 'operator'
}
}
}
@@ -1,403 +0,0 @@
<template>
<div class="role-field">
<div class="role-group">
<!-- Role Selection (Multi-select) -->
<div class="form-group col-full">
<label class="form-label">
Roles
<span class="text-danger">*</span>
</label>
<div class="role-checkboxes">
<div v-for="role in availableRoles" :key="role.id" class="form-check">
<input
:id="`role-${role.id}`"
type="checkbox"
class="form-check-input"
:checked="isRoleSelected(role.id)"
@change="toggleRole(role.id)"
/>
<label :for="`role-${role.id}`" class="form-check-label">
{{ role.name }}
<small class="text-muted d-block">{{ role.description }}</small>
</label>
</div>
</div>
<div v-if="roleError" class="invalid-feedback d-block mt-2">
{{ roleError }}
</div>
</div>
<!-- Primary Role (Optional) -->
<div class="form-group">
<label class="form-label">Primary Role</label>
<select v-model="selectedPrimaryRole" class="form-control">
<option value="">-- Select Primary --</option>
<option v-for="role in selectedRoles" :key="role.id" :value="role.id">
{{ role.name }}
</option>
</select>
<small v-if="selectedRoles.length === 0" class="text-muted d-block mt-1">
Select at least one role first
</small>
</div>
<!-- Permission Level (Read-Only Display) -->
<div class="form-group">
<label class="form-label">Permission Level</label>
<div class="permission-level">
<span v-if="maxPermissionLevel === 'ADMIN'" class="badge bg-danger">
Administrator
</span>
<span v-else-if="maxPermissionLevel === 'MANAGER'" class="badge bg-warning">
📊 Manager
</span>
<span v-else-if="maxPermissionLevel === 'OPERATOR'" class="badge bg-info">
🔧 Operator
</span>
<span v-else class="badge bg-secondary">
👁 Viewer
</span>
</div>
</div>
</div>
<!-- Selected Roles Summary -->
<div v-if="selectedRoles.length > 0" class="roles-summary mt-2">
<small class="text-muted">
👥 <strong>{{ selectedRoles.length }} Role{{ selectedRoles.length !== 1 ? 's' : '' }}</strong>
<span v-for="(role, idx) in selectedRoles" :key="role.id">
{{ role.name }}<span v-if="idx < selectedRoles.length - 1">, </span>
</span>
</small>
</div>
<!-- Validation Status -->
<div v-if="roleError" class="alert alert-danger mt-2">
{{ roleError }}
</div>
<div v-if="isComplete && !roleError" class="alert alert-success mt-2">
Role assignment is valid
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface Role {
id: string
name: string
description: string
permissionLevel: 'ADMIN' | 'MANAGER' | 'OPERATOR' | 'VIEWER'
}
interface RoleAssignment {
selectedRoleIds: string[]
primaryRoleId?: string
}
const props = defineProps<{
modelValue: RoleAssignment | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: RoleAssignment | null]
}>()
// Available roles
const availableRoles: Role[] = [
{
id: 'admin',
name: 'Administrator',
description: 'Full system access, user management, settings',
permissionLevel: 'ADMIN'
},
{
id: 'manager',
name: 'Manager',
description: 'Manage orders, inventory, users in assigned area',
permissionLevel: 'MANAGER'
},
{
id: 'operator',
name: 'Operator',
description: 'Create/edit orders, inventory transactions',
permissionLevel: 'OPERATOR'
},
{
id: 'viewer',
name: 'Viewer',
description: 'Read-only access to dashboards and reports',
permissionLevel: 'VIEWER'
},
{
id: 'auditor',
name: 'Auditor',
description: 'Read-only access to audit logs and compliance reports',
permissionLevel: 'VIEWER'
},
{
id: 'warehouse',
name: 'Warehouse Staff',
description: 'Warehouse operations, inventory, shipping',
permissionLevel: 'OPERATOR'
}
]
// State
const selectedRoleIds = ref<string[]>(
props.modelValue?.selectedRoleIds || []
)
const selectedPrimaryRole = ref<string>(
props.modelValue?.primaryRoleId || ''
)
const roleError = ref<string | null>(null)
// Computed
const selectedRoles = computed(() => {
return availableRoles.filter((role) => selectedRoleIds.value.includes(role.id))
})
const maxPermissionLevel = computed(() => {
if (selectedRoles.value.length === 0) return ''
const levels = ['ADMIN', 'MANAGER', 'OPERATOR', 'VIEWER']
for (const level of levels) {
if (selectedRoles.value.some((role) => role.permissionLevel === level)) {
return level
}
}
return 'VIEWER'
})
const isComplete = computed(() => {
return selectedRoleIds.value.length > 0 && !roleError.value
})
// Methods
const isRoleSelected = (roleId: string): boolean => {
return selectedRoleIds.value.includes(roleId)
}
const toggleRole = (roleId: string) => {
roleError.value = null
const index = selectedRoleIds.value.indexOf(roleId)
if (index > -1) {
selectedRoleIds.value.splice(index, 1)
} else {
selectedRoleIds.value.push(roleId)
}
// Reset primary role if not in selected
if (
selectedPrimaryRole.value &&
!selectedRoleIds.value.includes(selectedPrimaryRole.value)
) {
selectedPrimaryRole.value = ''
}
validateAndEmit()
}
const validateAndEmit = () => {
roleError.value = null
if (selectedRoleIds.value.length === 0) {
roleError.value = 'At least one role must be selected'
emit('update:modelValue', null)
return
}
emit('update:modelValue', {
selectedRoleIds: selectedRoleIds.value,
primaryRoleId: selectedPrimaryRole.value || selectedRoleIds.value[0]
})
}
</script>
<style scoped>
.role-field {
margin-bottom: 1.5rem;
}
.role-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.col-full {
grid-column: 1 / -1;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-family: inherit;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control:disabled {
background-color: #e9ecef;
opacity: 1;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.text-muted {
color: #6c757d;
font-size: 0.875rem;
}
.role-checkboxes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #fff;
}
.form-check {
display: flex;
gap: 0.5rem;
}
.form-check-input {
width: 1rem;
height: 1rem;
margin-top: 0.25rem;
cursor: pointer;
accent-color: #0d6efd;
}
.form-check-label {
cursor: pointer;
margin-bottom: 0;
user-select: none;
font-size: 0.875rem;
}
.form-check-label small {
margin-top: 0.25rem;
display: block;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.d-block {
display: block;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.permission-level {
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 4px;
display: flex;
align-items: center;
}
.badge {
font-size: 0.75rem;
padding: 0.375rem 0.75rem;
font-weight: 500;
}
.bg-danger {
background-color: #dc3545;
color: #fff;
}
.bg-warning {
background-color: #ffc107;
color: #000;
}
.bg-info {
background-color: #17a2b8;
color: #fff;
}
.bg-secondary {
background-color: #6c757d;
color: #fff;
}
.roles-summary {
padding: 0.75rem;
background-color: #e7f3ff;
border-left: 3px solid #0d6efd;
border-radius: 4px;
font-size: 0.875rem;
line-height: 1.6;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
small {
display: block;
}
strong {
font-weight: 600;
}
</style>
@@ -1,379 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import StockTransferField from './StockTransferField.vue'
describe('StockTransferField (Domain Field)', () => {
const defaultProps = {
modelValue: null
}
describe('Rendering', () => {
it('renders from warehouse dropdown', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('From Warehouse')
expect(wrapper.text()).toContain('Main Warehouse')
})
it('renders to warehouse dropdown', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('To Warehouse')
})
it('renders product SKU input', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Product SKU')
})
it('renders quantity input', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Quantity')
})
it('renders transfer unit dropdown', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Unit')
expect(wrapper.text()).toContain('Pieces (PCS)')
expect(wrapper.text()).toContain('PALLET')
})
it('renders reason dropdown', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Reason')
})
it('renders priority dropdown', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Priority')
})
it('renders notes textarea', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Notes')
})
})
describe('Warehouse Validation', () => {
it('requires from warehouse', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = ''
wrapper.vm.validateFromWarehouse()
expect(wrapper.vm.fromError).not.toBeNull()
expect(wrapper.vm.fromError).toContain('Source warehouse')
})
it('requires to warehouse', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.toWarehouse = ''
wrapper.vm.validateToWarehouse()
expect(wrapper.vm.toError).not.toBeNull()
expect(wrapper.vm.toError).toContain('Destination warehouse')
})
it('prevents same warehouse for source and destination', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = 'WH-001'
wrapper.vm.transfer.toWarehouse = 'WH-001'
wrapper.vm.validateFromWarehouse()
expect(wrapper.vm.fromError).not.toBeNull()
expect(wrapper.vm.fromError).toContain('cannot be the same')
})
it('accepts different warehouses', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = 'WH-001'
wrapper.vm.transfer.toWarehouse = 'WH-002'
wrapper.vm.validateFromWarehouse()
wrapper.vm.validateToWarehouse()
expect(wrapper.vm.fromError).toBeNull()
expect(wrapper.vm.toError).toBeNull()
})
it('supports 4 warehouses', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Main Warehouse')
expect(wrapper.text()).toContain('Regional Center')
expect(wrapper.text()).toContain('Distribution Hub')
expect(wrapper.text()).toContain('Express Depot')
})
})
describe('SKU Validation', () => {
it('requires SKU', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.productSku = ''
wrapper.vm.validateSku()
expect(wrapper.vm.skuError).not.toBeNull()
expect(wrapper.vm.skuError).toContain('required')
})
it('validates minimum length', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.productSku = 'SK'
wrapper.vm.validateSku()
expect(wrapper.vm.skuError).not.toBeNull()
expect(wrapper.vm.skuError).toContain('at least 3 characters')
})
it('accepts valid SKU', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.productSku = 'SKU-12345'
wrapper.vm.validateSku()
expect(wrapper.vm.skuError).toBeNull()
})
})
describe('Quantity Validation', () => {
it('requires quantity', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.quantity = 0
wrapper.vm.validateQuantity()
expect(wrapper.vm.quantityError).not.toBeNull()
expect(wrapper.vm.quantityError).toContain('greater than 0')
})
it('validates whole number', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.quantity = 10.5
wrapper.vm.validateQuantity()
expect(wrapper.vm.quantityError).not.toBeNull()
expect(wrapper.vm.quantityError).toContain('whole number')
})
it('accepts valid quantity', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.quantity = 100
wrapper.vm.validateQuantity()
expect(wrapper.vm.quantityError).toBeNull()
})
it('accepts large quantities', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.quantity = 10000
wrapper.vm.validateQuantity()
expect(wrapper.vm.quantityError).toBeNull()
})
})
describe('Completion Status', () => {
it('requires all mandatory fields', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = 'WH-001'
wrapper.vm.transfer.toWarehouse = 'WH-002'
wrapper.vm.transfer.productSku = 'SKU-123'
wrapper.vm.transfer.quantity = 100
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(true)
})
it('is not complete when from warehouse missing', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.toWarehouse = 'WH-002'
wrapper.vm.transfer.productSku = 'SKU-123'
wrapper.vm.transfer.quantity = 100
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(false)
})
it('is not complete when quantity is 0', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = 'WH-001'
wrapper.vm.transfer.toWarehouse = 'WH-002'
wrapper.vm.transfer.productSku = 'SKU-123'
wrapper.vm.transfer.quantity = 0
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(false)
})
it('shows success alert when complete', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = 'WH-001'
wrapper.vm.transfer.toWarehouse = 'WH-002'
wrapper.vm.transfer.productSku = 'SKU-123'
wrapper.vm.transfer.quantity = 50
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Stock transfer is configured')
})
})
describe('Optional Fields', () => {
it('allows reason selection', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('STOCK_REBALANCE')
expect(wrapper.text()).toContain('CUSTOMER_REQUEST')
})
it('allows priority selection', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Normal')
expect(wrapper.text()).toContain('High')
expect(wrapper.text()).toContain('Urgent')
})
it('allows transit days input', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.transitDays = 3
await wrapper.vm.$nextTick()
expect(wrapper.vm.transfer.transitDays).toBe(3)
})
it('allows notes input', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.notes = 'Special handling required'
await wrapper.vm.$nextTick()
expect(wrapper.vm.transfer.notes).toBe('Special handling required')
})
})
describe('Transfer Summary Display', () => {
it('shows summary when complete', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = 'WH-001'
wrapper.vm.transfer.toWarehouse = 'WH-002'
wrapper.vm.transfer.productSku = 'SKU-12345'
wrapper.vm.transfer.quantity = 100
wrapper.vm.transfer.unit = 'PCS'
wrapper.vm.transfer.reason = 'STOCK_REBALANCE'
wrapper.vm.transfer.priority = 'NORMAL'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('100 PCS')
expect(wrapper.text()).toContain('SKU-12345')
expect(wrapper.text()).toContain('Main Warehouse')
expect(wrapper.text()).toContain('Regional Center')
})
it('displays priority badge', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = 'WH-001'
wrapper.vm.transfer.toWarehouse = 'WH-002'
wrapper.vm.transfer.productSku = 'SKU-123'
wrapper.vm.transfer.quantity = 50
wrapper.vm.transfer.priority = 'URGENT'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('URGENT')
})
})
describe('Props Updates', () => {
it('loads transfer from modelValue prop', () => {
const props = {
modelValue: {
fromWarehouse: 'WH-002',
toWarehouse: 'WH-003',
productSku: 'SKU-99999',
quantity: 500,
unit: 'KG',
reason: 'QUALITY_ISSUE',
transitDays: 5,
priority: 'HIGH',
notes: 'Return for inspection'
}
}
const wrapper = mount(StockTransferField, { props })
expect(wrapper.vm.transfer.fromWarehouse).toBe('WH-002')
expect(wrapper.vm.transfer.quantity).toBe(500)
expect(wrapper.vm.transfer.unit).toBe('KG')
expect(wrapper.vm.transfer.notes).toBe('Return for inspection')
})
})
describe('Event Emission', () => {
it('emits update:modelValue when complete', async () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
wrapper.vm.transfer.fromWarehouse = 'WH-001'
wrapper.vm.transfer.toWarehouse = 'WH-002'
wrapper.vm.transfer.productSku = 'SKU-123'
wrapper.vm.transfer.quantity = 100
wrapper.vm.transfer.unit = 'BOX'
wrapper.vm.transfer.priority = 'HIGH'
await wrapper.vm.$nextTick()
wrapper.vm.emitUpdate()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeTruthy()
expect(emitted[0][0].quantity).toBe(100)
expect(emitted[0][0].unit).toBe('BOX')
expect(emitted[0][0].priority).toBe('HIGH')
})
})
describe('Accessibility', () => {
it('shows required indicators', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
const labels = wrapper.findAll('label')
const requiredLabels = labels.filter((l) => l.text().includes('*'))
expect(requiredLabels.length).toBeGreaterThan(0)
})
it('shows unit options', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.text()).toContain('Kilogram (KG)')
expect(wrapper.text()).toContain('Liter (L)')
})
})
describe('Warehouse Name Formatting', () => {
it('formats warehouse names correctly', () => {
const wrapper = mount(StockTransferField, { props: defaultProps })
expect(wrapper.vm.getWarehouseName('WH-001')).toBe('Main Warehouse')
expect(wrapper.vm.getWarehouseName('WH-002')).toBe('Regional Center')
expect(wrapper.vm.getWarehouseName('WH-003')).toBe('Distribution Hub')
expect(wrapper.vm.getWarehouseName('WH-004')).toBe('Express Depot')
})
})
})
@@ -1,105 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import StockTransferField from './StockTransferField.vue'
const meta: Meta<typeof StockTransferField> = {
title: 'Fields/Domain/StockTransferField',
component: StockTransferField
}
export default meta
type Story = StoryObj<typeof StockTransferField>
const Template = (args: any) => ({
components: { StockTransferField },
setup() {
return { args }
},
template: `
<div>
<StockTransferField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Transfer:</strong>
<p>From: {{ args.modelValue.fromWarehouse }} → To: {{ args.modelValue.toWarehouse }}</p>
<p>{{ args.modelValue.quantity }} {{ args.modelValue.unit }} of {{ args.modelValue.productSku }}</p>
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: null
}
}
export const RebalanceTransfer: Story = {
render: Template,
args: {
modelValue: {
fromWarehouse: 'WH-001',
toWarehouse: 'WH-002',
productSku: 'SKU-12345',
quantity: 100,
unit: 'PCS',
reason: 'STOCK_REBALANCE',
transitDays: 2,
priority: 'NORMAL',
notes: 'Regular rebalancing'
}
}
}
export const UrgentTransfer: Story = {
render: Template,
args: {
modelValue: {
fromWarehouse: 'WH-003',
toWarehouse: 'WH-004',
productSku: 'SKU-67890',
quantity: 50,
unit: 'BOX',
reason: 'CUSTOMER_REQUEST',
transitDays: 1,
priority: 'URGENT',
notes: 'Emergency stock shortage - customer waiting'
}
}
}
export const PalletTransfer: Story = {
render: Template,
args: {
modelValue: {
fromWarehouse: 'WH-001',
toWarehouse: 'WH-003',
productSku: 'SKU-11111',
quantity: 20,
unit: 'PALLET',
reason: 'DEMAND_FORECAST',
transitDays: 3,
priority: 'HIGH',
notes: 'Seasonal demand spike expected'
}
}
}
export const BulkTransfer: Story = {
render: Template,
args: {
modelValue: {
fromWarehouse: 'WH-002',
toWarehouse: 'WH-001',
productSku: 'SKU-99999',
quantity: 500,
unit: 'KG',
reason: 'QUALITY_ISSUE',
transitDays: 5,
priority: 'HIGH',
notes: 'Return for quality inspection and reprocessing'
}
}
}
@@ -1,458 +0,0 @@
<template>
<div class="stock-transfer-field">
<div class="transfer-group">
<!-- From Warehouse (Source) -->
<div class="form-group">
<label class="form-label">
From Warehouse
<span class="text-danger">*</span>
</label>
<select
v-model="transfer.fromWarehouse"
class="form-control"
:class="{ 'is-invalid': fromError }"
@blur="validateFromWarehouse"
>
<option value="">-- Select Source --</option>
<option value="WH-001">Main Warehouse (WH-001)</option>
<option value="WH-002">Regional Center (WH-002)</option>
<option value="WH-003">Distribution Hub (WH-003)</option>
<option value="WH-004">Express Depot (WH-004)</option>
</select>
<div v-if="fromError" class="invalid-feedback d-block">
{{ fromError }}
</div>
</div>
<!-- To Warehouse (Destination) -->
<div class="form-group">
<label class="form-label">
To Warehouse
<span class="text-danger">*</span>
</label>
<select
v-model="transfer.toWarehouse"
class="form-control"
:class="{ 'is-invalid': toError }"
@blur="validateToWarehouse"
>
<option value="">-- Select Destination --</option>
<option value="WH-001">Main Warehouse (WH-001)</option>
<option value="WH-002">Regional Center (WH-002)</option>
<option value="WH-003">Distribution Hub (WH-003)</option>
<option value="WH-004">Express Depot (WH-004)</option>
</select>
<div v-if="toError" class="invalid-feedback d-block">
{{ toError }}
</div>
</div>
<!-- Product SKU -->
<div class="form-group col-full">
<label class="form-label">
Product SKU
<span class="text-danger">*</span>
</label>
<input
v-model="transfer.productSku"
type="text"
class="form-control"
:class="{ 'is-invalid': skuError }"
placeholder="e.g., SKU-12345"
@blur="validateSku"
/>
<div v-if="skuError" class="invalid-feedback d-block">
{{ skuError }}
</div>
</div>
<!-- Quantity to Transfer -->
<div class="form-group">
<label class="form-label">
Quantity
<span class="text-danger">*</span>
</label>
<input
v-model.number="transfer.quantity"
type="number"
class="form-control"
:class="{ 'is-invalid': quantityError }"
placeholder="0"
min="1"
@blur="validateQuantity"
/>
<div v-if="quantityError" class="invalid-feedback d-block">
{{ quantityError }}
</div>
</div>
<!-- Transfer Unit -->
<div class="form-group">
<label class="form-label">Unit</label>
<select v-model="transfer.unit" class="form-control">
<option value="PCS">Pieces (PCS)</option>
<option value="BOX">Box</option>
<option value="PALLET">Pallet</option>
<option value="KG">Kilogram (KG)</option>
<option value="L">Liter (L)</option>
</select>
</div>
<!-- Transfer Reason -->
<div class="form-group col-full">
<label class="form-label">Reason</label>
<select v-model="transfer.reason" class="form-control">
<option value="">-- Select Reason --</option>
<option value="STOCK_REBALANCE">Stock Rebalancing</option>
<option value="DEMAND_FORECAST">Demand Forecast</option>
<option value="QUALITY_ISSUE">Quality Issue</option>
<option value="REPAIR">Repair/Maintenance</option>
<option value="CUSTOMER_REQUEST">Customer Request</option>
<option value="OTHER">Other</option>
</select>
</div>
<!-- Estimated Transit Days -->
<div class="form-group">
<label class="form-label">Transit Days (Est.)</label>
<input
v-model.number="transfer.transitDays"
type="number"
class="form-control"
placeholder="0"
min="1"
max="30"
/>
</div>
<!-- Priority -->
<div class="form-group">
<label class="form-label">Priority</label>
<select v-model="transfer.priority" class="form-control">
<option value="NORMAL">🟢 Normal</option>
<option value="HIGH">🟡 High</option>
<option value="URGENT">🔴 Urgent</option>
</select>
</div>
<!-- Notes -->
<div class="form-group col-full">
<label class="form-label">Notes (Optional)</label>
<textarea
v-model="transfer.notes"
class="form-control"
rows="3"
placeholder="Add any special instructions or notes..."
/>
</div>
</div>
<!-- Transfer Summary -->
<div v-if="isComplete" class="transfer-summary mt-2">
<small class="text-muted">
📦 <strong>{{ transfer.quantity }} {{ transfer.unit }}</strong> of {{ transfer.productSku }}
from <strong>{{ getWarehouseName(transfer.fromWarehouse) }}</strong>
to <strong>{{ getWarehouseName(transfer.toWarehouse) }}</strong>
<span v-if="transfer.reason"> {{ transfer.reason }}</span>
<span :class="getPriorityBadgeClass()">{{ transfer.priority }}</span>
</small>
</div>
<!-- Validation Status -->
<div v-if="hasErrors" class="alert alert-danger mt-2">
Please complete all required fields
</div>
<div v-if="isComplete && !hasErrors" class="alert alert-success mt-2">
Stock transfer is configured
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface StockTransfer {
fromWarehouse: string
toWarehouse: string
productSku: string
quantity: number
unit: string
reason?: string
transitDays?: number
priority: string
notes?: string
}
const props = defineProps<{
modelValue: StockTransfer | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: StockTransfer | null]
}>()
// State
const transfer = ref<StockTransfer>({
fromWarehouse: props.modelValue?.fromWarehouse || '',
toWarehouse: props.modelValue?.toWarehouse || '',
productSku: props.modelValue?.productSku || '',
quantity: props.modelValue?.quantity || 0,
unit: props.modelValue?.unit || 'PCS',
reason: props.modelValue?.reason || '',
transitDays: props.modelValue?.transitDays || 1,
priority: props.modelValue?.priority || 'NORMAL',
notes: props.modelValue?.notes || ''
})
const fromError = ref<string | null>(null)
const toError = ref<string | null>(null)
const skuError = ref<string | null>(null)
const quantityError = ref<string | null>(null)
// Computed
const isComplete = computed(() => {
return (
transfer.value.fromWarehouse.length > 0 &&
transfer.value.toWarehouse.length > 0 &&
transfer.value.productSku.trim().length > 0 &&
transfer.value.quantity > 0 &&
!hasErrors.value
)
})
const hasErrors = computed(() => {
return (
fromError.value !== null ||
toError.value !== null ||
skuError.value !== null ||
quantityError.value !== null
)
})
// Methods
const validateFromWarehouse = () => {
fromError.value = null
if (!transfer.value.fromWarehouse) {
fromError.value = 'Source warehouse is required'
return
}
if (transfer.value.fromWarehouse === transfer.value.toWarehouse) {
fromError.value = 'Source and destination cannot be the same'
return
}
emitUpdate()
}
const validateToWarehouse = () => {
toError.value = null
if (!transfer.value.toWarehouse) {
toError.value = 'Destination warehouse is required'
return
}
if (transfer.value.fromWarehouse === transfer.value.toWarehouse) {
toError.value = 'Destination and source cannot be the same'
return
}
emitUpdate()
}
const validateSku = () => {
skuError.value = null
if (!transfer.value.productSku.trim()) {
skuError.value = 'Product SKU is required'
return
}
if (transfer.value.productSku.trim().length < 3) {
skuError.value = 'SKU must be at least 3 characters'
return
}
emitUpdate()
}
const validateQuantity = () => {
quantityError.value = null
if (!transfer.value.quantity || transfer.value.quantity <= 0) {
quantityError.value = 'Quantity must be greater than 0'
return
}
if (!Number.isInteger(transfer.value.quantity)) {
quantityError.value = 'Quantity must be a whole number'
return
}
emitUpdate()
}
const emitUpdate = () => {
if (isComplete.value) {
emit('update:modelValue', { ...transfer.value })
}
}
const getWarehouseName = (code: string): string => {
const warehouses: Record<string, string> = {
'WH-001': 'Main Warehouse',
'WH-002': 'Regional Center',
'WH-003': 'Distribution Hub',
'WH-004': 'Express Depot'
}
return warehouses[code] || code
}
const getPriorityBadgeClass = (): string => {
const priority = transfer.value.priority
if (priority === 'URGENT') return 'badge bg-danger'
if (priority === 'HIGH') return 'badge bg-warning'
return 'badge bg-success'
}
</script>
<style scoped>
.stock-transfer-field {
margin-bottom: 1.5rem;
}
.transfer-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.col-full {
grid-column: 1 / -1;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-family: inherit;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
textarea.form-control {
resize: vertical;
min-height: 80px;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.text-muted {
color: #6c757d;
font-size: 0.875rem;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.d-block {
display: block;
}
.mt-2 {
margin-top: 0.5rem;
}
.transfer-summary {
padding: 0.75rem;
background-color: #e7f3ff;
border-left: 3px solid #0d6efd;
border-radius: 4px;
font-size: 0.875rem;
line-height: 1.6;
}
.badge {
font-size: 0.7rem;
padding: 0.375rem 0.75rem;
margin-left: 0.5rem;
font-weight: 500;
}
.bg-danger {
background-color: #dc3545;
color: #fff;
}
.bg-warning {
background-color: #ffc107;
color: #000;
}
.bg-success {
background-color: #28a745;
color: #fff;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
small {
display: block;
}
strong {
font-weight: 600;
}
</style>
@@ -1,378 +0,0 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import SupplierField from './SupplierField.vue'
describe('SupplierField (Domain Field)', () => {
const defaultProps = {
modelValue: ''
}
describe('Rendering', () => {
it('renders supplier search input', () => {
const wrapper = mount(SupplierField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
expect(input.exists()).toBe(true)
expect(input.attributes('placeholder')).toContain('Search')
})
it('displays supplier details when selected', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
// Simulate selection
wrapper.vm.selectedSupplier = {
id: 'SUP-001',
name: 'Premium Electronics Co.',
email: 'sales@premium-elec.com',
phone: '010-1111-2222',
city: 'Seoul',
status: 'ACTIVE',
rating: 5,
joinedDate: '2023-01-15',
leadTimeDays: 3,
minOrderAmount: 500000,
paymentTerms: 'Net 30',
onTimeDeliveryRate: 98
}
await wrapper.vm.$nextTick()
expect(wrapper.find('.card').exists()).toBe(true)
expect(wrapper.text()).toContain('Premium Electronics Co.')
})
})
describe('Supplier Search', () => {
it('performs search on input change', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('Premium')
await new Promise((resolve) => setTimeout(resolve, 400))
expect(wrapper.vm.searchResults.length).toBeGreaterThan(0)
})
it('clears results when search empty', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('Test')
await new Promise((resolve) => setTimeout(resolve, 400))
expect(wrapper.vm.searchResults.length).toBeGreaterThan(0)
await input.setValue('')
expect(wrapper.vm.searchResults).toHaveLength(0)
})
it('shows loading state during search', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
input.element.value = 'Search'
input.trigger('input')
expect(wrapper.vm.isSearching).toBe(true)
await new Promise((resolve) => setTimeout(resolve, 400))
expect(wrapper.vm.isSearching).toBe(false)
})
})
describe('Supplier Selection', () => {
it('selects supplier and shows details', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('Premium')
await new Promise((resolve) => setTimeout(resolve, 400))
if (wrapper.vm.searchResults.length > 0) {
await wrapper.vm.selectSupplier(wrapper.vm.searchResults[0])
expect(wrapper.vm.selectedSupplier).not.toBeNull()
expect(wrapper.emitted('update:modelValue')).toBeDefined()
}
})
it('emits select event with supplier data', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
const input = wrapper.find('input[type="text"]')
await input.setValue('Standard')
await new Promise((resolve) => setTimeout(resolve, 400))
if (wrapper.vm.searchResults.length > 0) {
await wrapper.vm.selectSupplier(wrapper.vm.searchResults[0])
const emitted = wrapper.emitted('select')
expect(emitted).toBeDefined()
}
})
})
describe('Supplier Details Display', () => {
beforeEach(() => {
const supplier = {
id: 'SUP-002',
name: 'Standard Parts Supplier',
email: 'contact@standard-parts.com',
phone: '010-3333-4444',
city: 'Busan',
status: 'ACTIVE',
rating: 4,
joinedDate: '2022-06-20',
leadTimeDays: 7,
minOrderAmount: 300000,
paymentTerms: 'Net 45',
onTimeDeliveryRate: 92
}
})
it('displays supplier information', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
const supplier = {
id: 'SUP-002',
name: 'Standard Parts Supplier',
email: 'contact@standard-parts.com',
phone: '010-3333-4444',
city: 'Busan',
status: 'ACTIVE',
rating: 4,
joinedDate: '2022-06-20',
leadTimeDays: 7,
minOrderAmount: 300000,
paymentTerms: 'Net 45',
onTimeDeliveryRate: 92
}
wrapper.vm.selectedSupplier = supplier
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Standard Parts Supplier')
expect(wrapper.text()).toContain('contact@standard-parts.com')
expect(wrapper.text()).toContain('Busan')
})
it('displays terms and conditions', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
wrapper.vm.selectedSupplier = {
id: 'SUP-002',
name: 'Standard Parts Supplier',
email: 'contact@standard-parts.com',
phone: '010-3333-4444',
city: 'Busan',
status: 'ACTIVE',
rating: 4,
joinedDate: '2022-06-20',
leadTimeDays: 7,
minOrderAmount: 300000,
paymentTerms: 'Net 45',
onTimeDeliveryRate: 92
}
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Lead Time')
expect(wrapper.text()).toContain('7 days')
expect(wrapper.text()).toContain('Payment Terms')
expect(wrapper.text()).toContain('Net 45')
})
})
describe('Status Display', () => {
it('shows correct status badge for ACTIVE supplier', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
wrapper.vm.selectedSupplier = {
id: 'SUP-001',
name: 'Premium Electronics',
email: 'sales@premium.com',
phone: '010-1111-2222',
city: 'Seoul',
status: 'ACTIVE',
rating: 5,
joinedDate: '2023-01-15',
leadTimeDays: 3,
minOrderAmount: 500000,
paymentTerms: 'Net 30',
onTimeDeliveryRate: 98
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.statusClass).toContain('bg-success')
})
it('shows correct status badge for TRIAL supplier', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
wrapper.vm.selectedSupplier = {
id: 'SUP-004',
name: 'International Imports',
email: 'trade@intl.com',
phone: '010-7777-8888',
city: 'Seoul',
status: 'TRIAL',
rating: 4,
joinedDate: '2024-08-01',
leadTimeDays: 21,
minOrderAmount: 1000000,
paymentTerms: 'Prepaid',
onTimeDeliveryRate: 88
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.statusClass).toContain('bg-warning')
})
})
describe('Delivery Reliability', () => {
it('shows warning for low delivery rate', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
wrapper.vm.selectedSupplier = {
id: 'SUP-003',
name: 'Budget Components',
email: 'procurement@budget.com',
phone: '010-5555-6666',
city: 'Incheon',
status: 'ACTIVE',
rating: 3,
joinedDate: '2021-11-10',
leadTimeDays: 14,
minOrderAmount: 200000,
paymentTerms: 'COD',
onTimeDeliveryRate: 78
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.reliabilityClass).toContain('text-danger')
expect(wrapper.text()).toContain('Delivery Reliability')
})
})
describe('Lead Time Display', () => {
it('shows green for fast lead time (< 7 days)', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
wrapper.vm.selectedSupplier = {
id: 'SUP-001',
name: 'Premium Electronics',
email: 'sales@premium.com',
phone: '010-1111-2222',
city: 'Seoul',
status: 'ACTIVE',
rating: 5,
joinedDate: '2023-01-15',
leadTimeDays: 3,
minOrderAmount: 500000,
paymentTerms: 'Net 30',
onTimeDeliveryRate: 98
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.leadTimeClass).toContain('text-success')
})
it('shows warning for medium lead time (7-14 days)', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
wrapper.vm.selectedSupplier = {
id: 'SUP-002',
name: 'Standard Parts',
email: 'contact@standard.com',
phone: '010-3333-4444',
city: 'Busan',
status: 'ACTIVE',
rating: 4,
joinedDate: '2022-06-20',
leadTimeDays: 7,
minOrderAmount: 300000,
paymentTerms: 'Net 45',
onTimeDeliveryRate: 92
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.leadTimeClass).toContain('text-warning')
})
it('shows danger for long lead time (> 14 days)', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
wrapper.vm.selectedSupplier = {
id: 'SUP-004',
name: 'International Imports',
email: 'trade@intl.com',
phone: '010-7777-8888',
city: 'Seoul',
status: 'TRIAL',
rating: 4,
joinedDate: '2024-08-01',
leadTimeDays: 21,
minOrderAmount: 1000000,
paymentTerms: 'Prepaid',
onTimeDeliveryRate: 88
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.leadTimeClass).toContain('text-danger')
})
})
describe('Clear Functionality', () => {
it('clears selection when clear button clicked', async () => {
const wrapper = mount(SupplierField, { props: defaultProps })
wrapper.vm.selectedSupplier = {
id: 'SUP-001',
name: 'Premium Electronics',
email: 'sales@premium.com',
phone: '010-1111-2222',
city: 'Seoul',
status: 'ACTIVE',
rating: 5,
joinedDate: '2023-01-15',
leadTimeDays: 3,
minOrderAmount: 500000,
paymentTerms: 'Net 30',
onTimeDeliveryRate: 98
}
await wrapper.vm.$nextTick()
expect(wrapper.vm.selectedSupplier).not.toBeNull()
wrapper.vm.clearSelection()
expect(wrapper.vm.selectedSupplier).toBeNull()
expect(wrapper.vm.searchQuery).toBe('')
})
})
describe('Accessibility', () => {
it('has proper label', () => {
const wrapper = mount(SupplierField, { props: defaultProps })
expect(wrapper.text()).toContain('Supplier')
})
it('shows required indicator', () => {
const wrapper = mount(SupplierField, { props: defaultProps })
expect(wrapper.text()).toContain('*')
})
})
})
@@ -1,147 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import SupplierField from './SupplierField.vue'
const meta: Meta<typeof SupplierField> = {
title: 'Fields/Domain/SupplierField',
component: SupplierField,
argTypes: {
modelValue: {
control: 'text',
description: 'Selected supplier ID'
}
}
}
export default meta
type Story = StoryObj<typeof SupplierField>
const Template = (args: any) => ({
components: { SupplierField },
setup() {
return { args }
},
template: `
<div>
<SupplierField
v-bind="args"
@update:modelValue="args.modelValue = $event"
@select="console.log('Supplier selected:', $event)"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Selected Supplier ID:</strong> {{ args.modelValue }}
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: ''
}
}
export const PremiumSupplier: Story = {
render: Template,
args: {
modelValue: 'SUP-001'
}
}
export const StandardSupplier: Story = {
render: Template,
args: {
modelValue: 'SUP-002'
}
}
export const BudgetSupplier: Story = {
render: Template,
args: {
modelValue: 'SUP-003'
},
parameters: {
docs: {
description: {
story: 'Shows supplier with lower delivery reliability (78%)'
}
}
}
}
export const NewSupplier: Story = {
render: Template,
args: {
modelValue: 'SUP-004'
},
parameters: {
docs: {
description: {
story: 'New trial supplier with extended lead time'
}
}
}
}
export const SupplierComparison: Story = {
render: (args: any) => ({
components: { SupplierField },
setup() {
return { args }
},
template: `
<div>
<h5>Supplier Comparison</h5>
<div class="mb-4">
<h6>Premium (98% on-time, 3-day lead)</h6>
<SupplierField modelValue="SUP-001" />
</div>
<div class="mb-4">
<h6>Standard (92% on-time, 7-day lead)</h6>
<SupplierField modelValue="SUP-002" />
</div>
<div class="mb-4">
<h6>Budget (78% on-time, 14-day lead)</h6>
<SupplierField modelValue="SUP-003" />
</div>
</div>
`
})
}
export const SearchDemo: Story = {
render: (args: any) => ({
components: { SupplierField },
setup() {
const tips = [
'Try: "Premium" for premium supplier',
'Try: "Standard" for standard supplier',
'Try: "Seoul" to find suppliers in Seoul',
'Try: "Budget" for budget option',
'Lead time color: Green <7 days, Yellow 7-14 days, Red >14 days',
'Delivery reliability warning if <85%'
]
return { args, tips }
},
template: `
<div>
<SupplierField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div class="alert alert-info mt-3">
<strong>💡 Search Tips:</strong>
<ul>
<li v-for="tip in tips" :key="tip">{{ tip }}</li>
</ul>
</div>
</div>
`
}),
args: {
modelValue: ''
}
}
@@ -1,537 +0,0 @@
<template>
<div class="supplier-field">
<!-- Supplier Search/Select -->
<div class="form-group">
<label class="form-label">
Supplier
<span class="text-danger">*</span>
</label>
<div class="input-group">
<input
v-model="searchQuery"
type="text"
class="form-control"
placeholder="Search by company name..."
@input="handleSearch"
/>
<button
v-if="isSearching"
class="btn btn-outline-secondary"
disabled
>
<span class="spinner-border spinner-border-sm"></span>
</button>
</div>
<!-- Search Results Dropdown -->
<div v-if="searchResults.length > 0" class="supplier-dropdown">
<div
v-for="supplier in searchResults"
:key="supplier.id"
class="dropdown-item"
@click="selectSupplier(supplier)"
>
<strong>{{ supplier.name }}</strong>
<small class="text-muted d-block">{{ supplier.email }} | {{ supplier.phone }}</small>
</div>
</div>
<div v-if="searchError" class="invalid-feedback d-block">
{{ searchError }}
</div>
</div>
<!-- Selected Supplier Details -->
<div v-if="selectedSupplier" class="card mt-2">
<div class="card-body">
<h5 class="card-title">{{ selectedSupplier.name }}</h5>
<div class="row mb-3">
<div class="col-md-6">
<small>
<strong>Email:</strong> {{ selectedSupplier.email }}<br>
<strong>Phone:</strong> {{ selectedSupplier.phone }}<br>
<strong>City:</strong> {{ selectedSupplier.city }}<br>
</small>
</div>
<div class="col-md-6">
<small>
<strong>Status:</strong>
<span :class="statusClass">{{ selectedSupplier.status }}</span><br>
<strong>Rating:</strong>
<span class="rating-stars">{{ renderStars(selectedSupplier.rating) }}</span><br>
<strong>Joined:</strong> {{ formatDate(selectedSupplier.joinedDate) }}<br>
</small>
</div>
</div>
<!-- Delivery & Payment Terms -->
<div class="terms-section mt-3 pt-3 border-top">
<h6>Terms & Conditions</h6>
<div class="row">
<div class="col-md-6">
<small>
<strong>Lead Time:</strong>
<span :class="leadTimeClass">{{ selectedSupplier.leadTimeDays }} days</span><br>
<strong>Min Order:</strong> {{ formatCurrency(selectedSupplier.minOrderAmount) }}<br>
</small>
</div>
<div class="col-md-6">
<small>
<strong>Payment Terms:</strong>
<span class="badge bg-info">{{ selectedSupplier.paymentTerms }}</span><br>
<strong>Reliability:</strong>
<span :class="reliabilityClass">{{ selectedSupplier.onTimeDeliveryRate }}% on-time</span><br>
</small>
</div>
</div>
</div>
<!-- Reliability Alert -->
<div v-if="selectedSupplier.onTimeDeliveryRate < 85" class="alert alert-warning mt-2">
Delivery Reliability: {{ selectedSupplier.onTimeDeliveryRate }}%
(Below 85% standard)
</div>
<!-- Clear Button -->
<button
class="btn btn-sm btn-outline-secondary mt-2"
@click="clearSelection"
>
Clear
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useFormatting } from '@/composables/useFormatting'
interface Supplier {
id: string
name: string
email: string
phone: string
city: string
status: string
rating: number // 1-5
joinedDate: string
leadTimeDays: number
minOrderAmount: number
paymentTerms: string
onTimeDeliveryRate: number // 0-100
}
const props = defineProps<{
modelValue: string // supplier ID
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
select: [supplier: Supplier]
}>()
const { formatCurrency, formatDate } = useFormatting()
// State
const searchQuery = ref('')
const searchResults = ref<Supplier[]>([])
const selectedSupplier = ref<Supplier | null>(null)
const isSearching = ref(false)
const searchError = ref<string | null>(null)
// Computed
const statusClass = computed(() => {
if (!selectedSupplier.value) return ''
switch (selectedSupplier.value.status) {
case 'ACTIVE':
return 'badge bg-success'
case 'INACTIVE':
return 'badge bg-secondary'
case 'SUSPENDED':
return 'badge bg-danger'
case 'TRIAL':
return 'badge bg-warning'
default:
return 'badge bg-secondary'
}
})
const leadTimeClass = computed(() => {
if (!selectedSupplier.value) return ''
if (selectedSupplier.value.leadTimeDays > 14) return 'text-danger'
if (selectedSupplier.value.leadTimeDays > 7) return 'text-warning'
return 'text-success'
})
const reliabilityClass = computed(() => {
if (!selectedSupplier.value) return ''
if (selectedSupplier.value.onTimeDeliveryRate < 85) return 'text-danger'
if (selectedSupplier.value.onTimeDeliveryRate < 95) return 'text-warning'
return 'text-success'
})
// Methods
const renderStars = (rating: number): string => {
return '★'.repeat(Math.round(rating)) + '☆'.repeat(5 - Math.round(rating))
}
const handleSearch = async (e: Event) => {
const input = e.target as HTMLInputElement
searchQuery.value = input.value
if (!searchQuery.value) {
searchResults.value = []
searchError.value = null
return
}
isSearching.value = true
searchError.value = null
try {
// Mock API call - would be: await suppliersApi.searchSuppliers(searchQuery.value)
const results = await mockSearchSuppliers(searchQuery.value)
searchResults.value = results
} catch (error) {
searchError.value = 'Failed to search suppliers'
} finally {
isSearching.value = false
}
}
const selectSupplier = async (supplier: Supplier) => {
selectedSupplier.value = supplier
searchQuery.value = supplier.name
searchResults.value = []
emit('update:modelValue', supplier.id)
emit('select', supplier)
}
const clearSelection = () => {
selectedSupplier.value = null
searchQuery.value = ''
searchResults.value = []
emit('update:modelValue', '')
}
// Mock API - would be replaced with real API call
const mockSearchSuppliers = async (query: string): Promise<Supplier[]> => {
return new Promise((resolve) => {
setTimeout(() => {
const mockSuppliers: Supplier[] = [
{
id: 'SUP-001',
name: 'Premium Electronics Co.',
email: 'sales@premium-elec.com',
phone: '010-1111-2222',
city: 'Seoul',
status: 'ACTIVE',
rating: 5,
joinedDate: '2023-01-15',
leadTimeDays: 3,
minOrderAmount: 500000,
paymentTerms: 'Net 30',
onTimeDeliveryRate: 98
},
{
id: 'SUP-002',
name: 'Standard Parts Supplier',
email: 'contact@standard-parts.com',
phone: '010-3333-4444',
city: 'Busan',
status: 'ACTIVE',
rating: 4,
joinedDate: '2022-06-20',
leadTimeDays: 7,
minOrderAmount: 300000,
paymentTerms: 'Net 45',
onTimeDeliveryRate: 92
},
{
id: 'SUP-003',
name: 'Budget Components Ltd',
email: 'procurement@budget-comp.com',
phone: '010-5555-6666',
city: 'Incheon',
status: 'ACTIVE',
rating: 3,
joinedDate: '2021-11-10',
leadTimeDays: 14,
minOrderAmount: 200000,
paymentTerms: 'COD',
onTimeDeliveryRate: 78
},
{
id: 'SUP-004',
name: 'International Imports Inc',
email: 'trade@intl-imports.com',
phone: '010-7777-8888',
city: 'Seoul',
status: 'TRIAL',
rating: 4,
joinedDate: '2024-08-01',
leadTimeDays: 21,
minOrderAmount: 1000000,
paymentTerms: 'Prepaid',
onTimeDeliveryRate: 88
}
]
resolve(
mockSuppliers.filter((s) =>
s.name.toLowerCase().includes(query.toLowerCase()) ||
s.city.toLowerCase().includes(query.toLowerCase())
)
)
}, 300)
})
}
</script>
<style scoped>
.supplier-field {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
position: relative;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
display: block;
font-size: 0.875rem;
}
.input-group {
display: flex;
position: relative;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
flex: 1;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.btn {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 0 4px 4px 0;
border: 1px solid #dee2e6;
border-left: 0;
cursor: pointer;
}
.btn-outline-secondary {
color: #6c757d;
border-color: #6c757d;
}
.btn-outline-secondary:disabled {
opacity: 0.65;
}
.spinner-border {
width: 1rem;
height: 1rem;
border-width: 0.2em;
}
.supplier-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: white;
border: 1px solid #dee2e6;
border-top: 0;
border-radius: 0 0 4px 4px;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
z-index: 1000;
max-height: 250px;
overflow-y: auto;
}
.dropdown-item {
padding: 0.5rem 0.75rem;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.15s ease-in-out;
}
.dropdown-item:hover {
background-color: #f8f9fa;
}
.dropdown-item:last-child {
border-bottom: none;
}
.card {
border: 1px solid #dee2e6;
border-radius: 4px;
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
}
.card-body {
padding: 1rem;
}
.card-title {
margin-bottom: 1rem;
font-weight: 600;
font-size: 1rem;
}
.row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
.col-md-6 {
flex: 1;
}
small {
line-height: 1.8;
display: block;
}
.text-muted {
color: #6c757d;
}
.badge {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
border-radius: 4px;
color: white;
}
.bg-success {
background-color: #28a745 !important;
}
.bg-secondary {
background-color: #6c757d !important;
}
.bg-danger {
background-color: #dc3545 !important;
}
.bg-warning {
background-color: #ffc107 !important;
color: #212529 !important;
}
.bg-info {
background-color: #17a2b8 !important;
}
.text-success {
color: #28a745;
font-weight: 600;
}
.text-warning {
color: #ffc107;
font-weight: 600;
}
.text-danger {
color: #dc3545;
font-weight: 600;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.rating-stars {
font-size: 1rem;
color: #ffc107;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-warning {
background-color: #fff3cd;
color: #856404;
border-color: #ffeaa7;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.d-block {
display: block;
}
.border-top {
border-top: 1px solid #dee2e6;
}
.mt-2 {
margin-top: 0.5rem;
}
.mt-3 {
margin-top: 1rem;
}
.mb-3 {
margin-bottom: 1rem;
}
.pt-3 {
padding-top: 1rem;
}
h6 {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.75rem;
}
.terms-section {
background-color: #f8f9fa;
padding: 1rem;
border-radius: 4px;
}
.btn-outline-secondary:hover:not(:disabled) {
background-color: #6c757d;
color: white;
}
</style>
@@ -1,325 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import TaxIDField from './TaxIDField.vue'
describe('TaxIDField (Domain Field)', () => {
const defaultProps = {
modelValue: null
}
describe('Rendering', () => {
it('renders country selection dropdown', () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
expect(wrapper.text()).toContain('Country')
const selects = wrapper.findAll('select')
expect(selects.length).toBeGreaterThan(0)
})
it('renders tax id input field', () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
expect(wrapper.text()).toContain('Tax ID Number')
})
it('renders optional entity type field', () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
expect(wrapper.text()).toContain('Type')
})
it('renders optional verification status field', () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
expect(wrapper.text()).toContain('Verification Status')
})
})
describe('Country Validation', () => {
it('requires country selection', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
const selects = wrapper.findAll('select')
await selects[0].setValue('')
await selects[0].trigger('blur')
expect(wrapper.vm.countryError).not.toBeNull()
expect(wrapper.vm.countryError).toContain('Please select a country')
})
it('accepts country selection', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
const selects = wrapper.findAll('select')
await selects[0].setValue('KR')
await selects[0].trigger('blur')
expect(wrapper.vm.countryError).toBeNull()
})
it('supports multiple countries', () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
expect(wrapper.text()).toContain('South Korea')
expect(wrapper.text()).toContain('United States')
expect(wrapper.text()).toContain('Japan')
expect(wrapper.text()).toContain('China')
expect(wrapper.text()).toContain('Singapore')
expect(wrapper.text()).toContain('Taiwan')
})
})
describe('Korea Tax ID Validation', () => {
it('requires tax id number', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'KR'
wrapper.vm.taxId.taxIdNumber = ''
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).not.toBeNull()
expect(wrapper.vm.taxIdError).toContain('required')
})
it('validates Korean format with hyphens', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'KR'
wrapper.vm.taxId.taxIdNumber = '123-45-67890'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).toBeNull()
})
it('validates Korean format without hyphens', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'KR'
wrapper.vm.taxId.taxIdNumber = '1234567890'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).toBeNull()
})
it('rejects invalid Korean format', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'KR'
wrapper.vm.taxId.taxIdNumber = '123-456-789'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).not.toBeNull()
expect(wrapper.vm.taxIdError).toContain('XXX-XX-XXXXX')
})
})
describe('USA Tax ID Validation', () => {
it('validates US EIN with hyphen', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'US'
wrapper.vm.taxId.taxIdNumber = '12-3456789'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).toBeNull()
})
it('validates US EIN without hyphen', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'US'
wrapper.vm.taxId.taxIdNumber = '123456789'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).toBeNull()
})
it('rejects invalid US format', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'US'
wrapper.vm.taxId.taxIdNumber = '12-34567'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).not.toBeNull()
expect(wrapper.vm.taxIdError).toContain('XX-XXXXXXX')
})
})
describe('Japan Tax ID Validation', () => {
it('validates Japan tax id', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'JP'
wrapper.vm.taxId.taxIdNumber = '1234567890123'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).toBeNull()
})
it('rejects too short Japan format', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'JP'
wrapper.vm.taxId.taxIdNumber = '123456'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).not.toBeNull()
})
})
describe('China Tax ID Validation', () => {
it('validates China 18-digit format', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'CN'
wrapper.vm.taxId.taxIdNumber = '123456789012345678'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).toBeNull()
})
it('rejects wrong digit count', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'CN'
wrapper.vm.taxId.taxIdNumber = '12345678'
await wrapper.vm.$nextTick()
wrapper.vm.validateTaxId()
expect(wrapper.vm.taxIdError).not.toBeNull()
expect(wrapper.vm.taxIdError).toContain('18 digits')
})
})
describe('Tax ID Summary Display', () => {
it('shows summary when complete', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'KR'
wrapper.vm.taxId.taxIdNumber = '123-45-67890'
wrapper.vm.taxId.verificationStatus = 'VERIFIED'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('South Korea')
expect(wrapper.text()).toContain('123-45-67890')
expect(wrapper.text()).toContain('VERIFIED')
})
it('displays verification status badge', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'US'
wrapper.vm.taxId.taxIdNumber = '12-3456789'
wrapper.vm.taxId.verificationStatus = 'VERIFIED'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('VERIFIED')
})
})
describe('Completion Status', () => {
it('is not complete when required fields missing', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'KR'
wrapper.vm.taxId.taxIdNumber = ''
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(false)
})
it('is complete when all required fields filled', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'US'
wrapper.vm.taxId.taxIdNumber = '12-3456789'
await wrapper.vm.$nextTick()
expect(wrapper.vm.isComplete).toBe(true)
})
it('shows success alert when complete', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'JP'
wrapper.vm.taxId.taxIdNumber = '1234567890123'
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Tax ID information is valid')
})
it('shows error alert when incomplete', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = ''
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('Please complete all required fields')
})
})
describe('Props Updates', () => {
it('loads tax id from modelValue prop', () => {
const props = {
modelValue: {
country: 'KR',
taxIdNumber: '123-45-67890',
entityType: 'BUSINESS',
verificationStatus: 'VERIFIED'
}
}
const wrapper = mount(TaxIDField, { props })
expect(wrapper.vm.taxId.country).toBe('KR')
expect(wrapper.vm.taxId.taxIdNumber).toBe('123-45-67890')
expect(wrapper.vm.taxId.entityType).toBe('BUSINESS')
expect(wrapper.vm.taxId.verificationStatus).toBe('VERIFIED')
})
})
describe('Event Emission', () => {
it('emits update:modelValue when complete', async () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'SG'
wrapper.vm.taxId.taxIdNumber = '123456789'
await wrapper.vm.$nextTick()
await wrapper.vm.emitUpdate()
const emitted = wrapper.emitted('update:modelValue')
expect(emitted).toBeTruthy()
expect(emitted[0][0].country).toBe('SG')
expect(emitted[0][0].taxIdNumber).toBe('123456789')
})
})
describe('Accessibility', () => {
it('shows required indicators', () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
const labels = wrapper.findAll('label')
const requiredLabels = labels.filter((l) => l.text().includes('*'))
expect(requiredLabels.length).toBeGreaterThan(0)
})
it('shows format hints', () => {
const wrapper = mount(TaxIDField, { props: defaultProps })
wrapper.vm.taxId.country = 'KR'
expect(wrapper.vm.getFormatHint()).toContain('XXX-XX-XXXXX')
})
})
})
@@ -1,84 +0,0 @@
import { Meta, StoryObj } from '@storybook/vue3'
import TaxIDField from './TaxIDField.vue'
const meta: Meta<typeof TaxIDField> = {
title: 'Fields/Domain/TaxIDField',
component: TaxIDField
}
export default meta
type Story = StoryObj<typeof TaxIDField>
const Template = (args: any) => ({
components: { TaxIDField },
setup() {
return { args }
},
template: `
<div>
<TaxIDField
v-bind="args"
@update:modelValue="args.modelValue = $event"
/>
<div v-if="args.modelValue" class="mt-3">
<strong>Tax ID:</strong>
<p>{{ args.modelValue.country }} - {{ args.modelValue.taxIdNumber }}</p>
</div>
</div>
`
})
export const Empty: Story = {
render: Template,
args: {
modelValue: null
}
}
export const Korea: Story = {
render: Template,
args: {
modelValue: {
country: 'KR',
taxIdNumber: '123-45-67890',
entityType: 'BUSINESS',
verificationStatus: 'VERIFIED'
}
}
}
export const USA: Story = {
render: Template,
args: {
modelValue: {
country: 'US',
taxIdNumber: '12-3456789',
entityType: 'CORPORATION',
verificationStatus: 'VERIFIED'
}
}
}
export const Japan: Story = {
render: Template,
args: {
modelValue: {
country: 'JP',
taxIdNumber: '1234567890123',
entityType: 'BUSINESS',
verificationStatus: 'PENDING'
}
}
}
export const China: Story = {
render: Template,
args: {
modelValue: {
country: 'CN',
taxIdNumber: '123456789012345678',
entityType: 'CORPORATION',
verificationStatus: 'VERIFIED'
}
}
}
@@ -1,395 +0,0 @@
<template>
<div class="tax-id-field">
<div class="tax-id-group">
<!-- Country Selection -->
<div class="form-group">
<label class="form-label">
Country
<span class="text-danger">*</span>
</label>
<select
v-model="taxId.country"
class="form-control"
:class="{ 'is-invalid': countryError }"
@blur="validateCountry"
>
<option value="">-- Select Country --</option>
<option value="KR">South Korea (사업자등록번호)</option>
<option value="US">United States (EIN)</option>
<option value="JP">Japan (法人番号)</option>
<option value="CN">China (统一社会信用代码)</option>
<option value="SG">Singapore (UEN)</option>
<option value="TW">Taiwan (統一編號)</option>
</select>
<div v-if="countryError" class="invalid-feedback d-block">
{{ countryError }}
</div>
</div>
<!-- Tax ID Number -->
<div class="form-group col-full">
<label class="form-label">
{{ getFieldLabel() }}
<span class="text-danger">*</span>
</label>
<input
v-model="taxId.taxIdNumber"
type="text"
class="form-control"
:class="{ 'is-invalid': taxIdError }"
:placeholder="getPlaceholder()"
@blur="validateTaxId"
/>
<small class="text-muted d-block mt-1">
{{ getFormatHint() }}
</small>
<div v-if="taxIdError" class="invalid-feedback d-block">
{{ taxIdError }}
</div>
</div>
<!-- Company/Individual Type (optional) -->
<div class="form-group">
<label class="form-label">Type</label>
<select v-model="taxId.entityType" class="form-control">
<option value="">-- Select Type --</option>
<option value="INDIVIDUAL">Individual</option>
<option value="BUSINESS">Business</option>
<option value="CORPORATION">Corporation</option>
<option value="PARTNERSHIP">Partnership</option>
</select>
</div>
<!-- Verification Status (optional) -->
<div class="form-group">
<label class="form-label">Verification Status</label>
<select v-model="taxId.verificationStatus" class="form-control">
<option value="">-- Not Verified --</option>
<option value="PENDING">Pending</option>
<option value="VERIFIED">Verified</option>
<option value="FAILED">Failed</option>
</select>
</div>
</div>
<!-- Tax ID Summary -->
<div v-if="isComplete" class="tax-id-summary mt-2">
<small class="text-muted">
📋 <strong>{{ getCountryName(taxId.country) }}</strong>
{{ formatTaxId() }}
<span v-if="taxId.verificationStatus" class="badge" :class="getStatusBadgeClass()">
{{ taxId.verificationStatus }}
</span>
</small>
</div>
<!-- Validation Status -->
<div v-if="hasErrors" class="alert alert-danger mt-2">
Please complete all required fields
</div>
<div v-if="isComplete && !hasErrors" class="alert alert-success mt-2">
Tax ID information is valid
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
interface TaxID {
country: string
taxIdNumber: string
entityType?: string
verificationStatus?: string
}
const props = defineProps<{
modelValue: TaxID | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: TaxID | null]
}>()
// State
const taxId = ref<TaxID>({
country: props.modelValue?.country || '',
taxIdNumber: props.modelValue?.taxIdNumber || '',
entityType: props.modelValue?.entityType || '',
verificationStatus: props.modelValue?.verificationStatus || ''
})
const countryError = ref<string | null>(null)
const taxIdError = ref<string | null>(null)
// Computed
const isComplete = computed(() => {
return (
taxId.value.country.length > 0 &&
taxId.value.taxIdNumber.trim().length > 0 &&
!hasErrors.value
)
})
const hasErrors = computed(() => {
return countryError.value !== null || taxIdError.value !== null
})
// Methods
const validateCountry = () => {
countryError.value = null
if (!taxId.value.country) {
countryError.value = 'Please select a country'
return
}
emitUpdate()
}
const validateTaxId = () => {
taxIdError.value = null
if (!taxId.value.taxIdNumber.trim()) {
taxIdError.value = 'Tax ID is required'
return
}
// Validate based on country
const country = taxId.value.country
const value = taxId.value.taxIdNumber.trim()
if (country === 'KR') {
// Korean: 사업자등록번호 (10 digits with optional hyphens: XXX-XX-XXXXX)
if (!/^(\d{3}-\d{2}-\d{5}|\d{10})$/.test(value)) {
taxIdError.value = 'Korean Tax ID format: XXX-XX-XXXXX or 10 digits'
return
}
} else if (country === 'US') {
// US: EIN (9 digits with optional hyphen: XX-XXXXXXX)
if (!/^(\d{2}-\d{7}|\d{9})$/.test(value)) {
taxIdError.value = 'US EIN format: XX-XXXXXXX or 9 digits'
return
}
} else if (country === 'JP') {
// Japan: 12 digits (XXXXXXXXXXXX)
if (!/^\d{12,13}$/.test(value)) {
taxIdError.value = 'Japan Tax ID format: 12-13 digits'
return
}
} else if (country === 'CN') {
// China: 18 digits (统一社会信用代码)
if (!/^\d{18}$/.test(value)) {
taxIdError.value = 'China Tax ID format: 18 digits'
return
}
} else if (country === 'SG') {
// Singapore: UEN (9 digits with optional hyphen: XXXXXXXXX or XXX-XXXXXX)
if (!/^(\d{9}|\d{3}-\d{6})$/.test(value)) {
taxIdError.value = 'Singapore UEN format: 9 digits or XXX-XXXXXX'
return
}
} else if (country === 'TW') {
// Taiwan: 8 digits (統一編號)
if (!/^\d{8}$/.test(value)) {
taxIdError.value = 'Taiwan Tax ID format: 8 digits'
return
}
}
emitUpdate()
}
const emitUpdate = () => {
if (isComplete.value) {
emit('update:modelValue', { ...taxId.value })
}
}
const getFieldLabel = (): string => {
const labels: Record<string, string> = {
KR: 'Business Registration Number (사업자등록번호)',
US: 'Employer Identification Number (EIN)',
JP: 'Corporate Number (法人番号)',
CN: 'Unified Social Credit Code (统一社会信用代码)',
SG: 'Unique Entity Number (UEN)',
TW: 'Uniform Number (統一編號)'
}
return labels[taxId.value.country] || 'Tax ID Number'
}
const getPlaceholder = (): string => {
const placeholders: Record<string, string> = {
KR: 'e.g., 123-45-67890',
US: 'e.g., 12-3456789',
JP: 'e.g., 1234567890123',
CN: 'e.g., 123456789012345678',
SG: 'e.g., 123456789 or 123-456789',
TW: 'e.g., 12345678'
}
return placeholders[taxId.value.country] || ''
}
const getFormatHint = (): string => {
const hints: Record<string, string> = {
KR: 'Format: XXX-XX-XXXXX (10 digits)',
US: 'Format: XX-XXXXXXX (9 digits)',
JP: 'Format: 12-13 digits',
CN: 'Format: 18 digits',
SG: 'Format: 9 digits or XXX-XXXXXX',
TW: 'Format: 8 digits'
}
return hints[taxId.value.country] || 'Format: country-specific'
}
const getCountryName = (code: string): string => {
const countries: Record<string, string> = {
KR: 'South Korea',
US: 'United States',
JP: 'Japan',
CN: 'China',
SG: 'Singapore',
TW: 'Taiwan'
}
return countries[code] || 'Country'
}
const formatTaxId = (): string => {
const value = taxId.value.taxIdNumber
const country = taxId.value.country
// Format with country indicator
return `${getCountryName(country)}: ${value}`
}
const getStatusBadgeClass = (): string => {
const status = taxId.value.verificationStatus
if (status === 'VERIFIED') return 'bg-success'
if (status === 'PENDING') return 'bg-warning'
if (status === 'FAILED') return 'bg-danger'
return 'bg-secondary'
}
</script>
<style scoped>
.tax-id-field {
margin-bottom: 1.5rem;
}
.tax-id-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 4px;
background-color: #f8f9fa;
}
.col-full {
grid-column: 1 / -1;
}
.form-group {
display: flex;
flex-direction: column;
}
.form-label {
font-weight: 500;
margin-bottom: 0.5rem;
font-size: 0.875rem;
}
.form-control {
border-radius: 4px;
border: 1px solid #dee2e6;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
font-family: inherit;
}
.form-control:focus {
border-color: #80bdff;
outline: 0;
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
}
.form-control.is-invalid {
border-color: #dc3545;
}
.text-danger {
color: #dc3545;
margin-left: 0.25rem;
}
.text-muted {
color: #6c757d;
font-size: 0.875rem;
}
.invalid-feedback {
color: #dc3545;
font-size: 0.875rem;
margin-top: 0.25rem;
display: block;
}
.d-block {
display: block;
}
.mt-1 {
margin-top: 0.25rem;
}
.mt-2 {
margin-top: 0.5rem;
}
.tax-id-summary {
padding: 0.75rem;
background-color: #e7f3ff;
border-left: 3px solid #0d6efd;
border-radius: 4px;
font-size: 0.875rem;
line-height: 1.6;
}
.badge {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
margin-left: 0.5rem;
vertical-align: middle;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
font-size: 0.875rem;
border: 1px solid transparent;
margin-bottom: 1rem;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-color: #f5c6cb;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-color: #c3e6cb;
}
small {
display: block;
}
strong {
font-weight: 600;
}
</style>

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