diff --git a/docs/migration/portal-no-iframe/archive_readiness.md b/docs/migration/portal-no-iframe/archive_readiness.md new file mode 100644 index 0000000..6a4fc8b --- /dev/null +++ b/docs/migration/portal-no-iframe/archive_readiness.md @@ -0,0 +1,33 @@ +# OpenSpec Archive Readiness (`portal-no-iframe-navigation`) + +## Spec Sync Scope + +Main specs synchronized/updated for this change: + +- `openspec/specs/full-vite-page-modularization/spec.md` +- `openspec/specs/portal-drawer-navigation/spec.md` +- `openspec/specs/vue-vite-page-architecture/spec.md` +- `openspec/specs/migration-gates-and-rollout/spec.md` +- `openspec/specs/spa-shell-navigation/spec.md` (new) +- `openspec/specs/tailwind-design-system/spec.md` (new) +- `openspec/specs/frontend-motion-system/spec.md` (new) +- `openspec/specs/legacy-page-wrapper-strategy/spec.md` (new) + +## Migration Closure Artifacts + +- Rewrite smoke checklist: + - `docs/migration/portal-no-iframe/legacy_rewrite_smoke_checklists.md` +- Rewrite exemplar: + - `docs/migration/portal-no-iframe/tmtt_rewrite_exemplar.md` +- Rewrite playbook: + - `docs/migration/portal-no-iframe/legacy_rewrite_playbook.md` +- Wrapper decommission record: + - `docs/migration/portal-no-iframe/wrapper_decommission_report.md` +- Frame field retirement record: + - `docs/migration/portal-no-iframe/frame_id_tool_src_deprecation_plan.md` + +## Pre-Archive Checklist + +- [x] `openspec validate portal-no-iframe-navigation --strict` passes. +- [x] Build and core migration tests pass on latest branch. +- [x] Task list in `openspec/changes/portal-no-iframe-navigation/tasks.md` is fully checked. diff --git a/docs/migration/portal-no-iframe/baseline_api_payload_contracts.json b/docs/migration/portal-no-iframe/baseline_api_payload_contracts.json new file mode 100644 index 0000000..42a4774 --- /dev/null +++ b/docs/migration/portal-no-iframe/baseline_api_payload_contracts.json @@ -0,0 +1,46 @@ +{ + "source": "current frontend API consumption contracts", + "apis": { + "/api/wip/overview/summary": { + "required_keys": [ + "dataUpdateDate", + "runLots", + "queueLots", + "holdLots" + ], + "notes": "summary header and cards depend on these fields" + }, + "/api/wip/overview/matrix": { + "required_keys": [ + "workcenters", + "packages", + "matrix", + "workcenter_totals" + ], + "notes": "matrix table rendering contract" + }, + "/api/wip/hold-detail/summary": { + "required_keys": [ + "workcenterCount", + "packageCount", + "lotCount" + ], + "notes": "hold detail summary cards contract" + }, + "/api/resource/history/summary": { + "required_keys": [ + "kpi", + "trend", + "heatmap", + "workcenter_comparison" + ], + "notes": "resource history chart summary contract" + }, + "/api/resource/history/detail": { + "required_keys": [ + "data" + ], + "notes": "detail table contract (plus truncated/max_records metadata when present)" + } + } +} diff --git a/docs/migration/portal-no-iframe/baseline_drawer_contract_validation.json b/docs/migration/portal-no-iframe/baseline_drawer_contract_validation.json new file mode 100644 index 0000000..177e28b --- /dev/null +++ b/docs/migration/portal-no-iframe/baseline_drawer_contract_validation.json @@ -0,0 +1,4 @@ +{ + "source": "data/page_status.json", + "errors": [] +} diff --git a/docs/migration/portal-no-iframe/baseline_drawer_visibility.json b/docs/migration/portal-no-iframe/baseline_drawer_visibility.json new file mode 100644 index 0000000..b3ffbce --- /dev/null +++ b/docs/migration/portal-no-iframe/baseline_drawer_visibility.json @@ -0,0 +1,177 @@ +{ + "source": "data/page_status.json", + "admin": [ + { + "id": "reports", + "name": "即時報表", + "order": 1, + "admin_only": false, + "pages": [ + { + "route": "/wip-overview", + "name": "WIP 即時概況", + "status": "released", + "order": 1 + }, + { + "route": "/hold-overview", + "name": "Hold 即時概況", + "status": "dev", + "order": 2 + }, + { + "route": "/resource", + "name": "設備即時概況", + "status": "released", + "order": 4 + }, + { + "route": "/qc-gate", + "name": "QC-GATE 狀態", + "status": "released", + "order": 6 + } + ] + }, + { + "id": "drawer-2", + "name": "歷史報表", + "order": 2, + "admin_only": false, + "pages": [ + { + "route": "/hold-history", + "name": "Hold 歷史績效", + "status": "dev", + "order": 3 + }, + { + "route": "/resource-history", + "name": "設備歷史績效", + "status": "released", + "order": 5 + } + ] + }, + { + "id": "drawer", + "name": "查詢工具", + "order": 3, + "admin_only": false, + "pages": [ + { + "route": "/job-query", + "name": "設備維修查詢", + "status": "released", + "order": 3 + } + ] + }, + { + "id": "dev-tools", + "name": "開發工具", + "order": 4, + "admin_only": true, + "pages": [ + { + "route": "/tables", + "name": "表格總覽", + "status": "dev", + "order": 1 + }, + { + "route": "/admin/pages", + "name": "頁面管理", + "status": "dev", + "order": 1 + }, + { + "route": "/excel-query", + "name": "Excel 批次查詢", + "status": "dev", + "order": 2 + }, + { + "route": "/admin/performance", + "name": "效能監控", + "status": "dev", + "order": 2 + }, + { + "route": "/query-tool", + "name": "批次追蹤工具", + "status": "dev", + "order": 4 + }, + { + "route": "/tmtt-defect", + "name": "TMTT印字腳型不良分析", + "status": "released", + "order": 5 + }, + { + "route": "/mid-section-defect", + "name": "中段製程不良追溯", + "status": "dev", + "order": 6 + } + ] + } + ], + "non_admin": [ + { + "id": "reports", + "name": "即時報表", + "order": 1, + "admin_only": false, + "pages": [ + { + "route": "/wip-overview", + "name": "WIP 即時概況", + "status": "released", + "order": 1 + }, + { + "route": "/resource", + "name": "設備即時概況", + "status": "released", + "order": 4 + }, + { + "route": "/qc-gate", + "name": "QC-GATE 狀態", + "status": "released", + "order": 6 + } + ] + }, + { + "id": "drawer-2", + "name": "歷史報表", + "order": 2, + "admin_only": false, + "pages": [ + { + "route": "/resource-history", + "name": "設備歷史績效", + "status": "released", + "order": 5 + } + ] + }, + { + "id": "drawer", + "name": "查詢工具", + "order": 3, + "admin_only": false, + "pages": [ + { + "route": "/job-query", + "name": "設備維修查詢", + "status": "released", + "order": 3 + } + ] + } + ] +} diff --git a/docs/migration/portal-no-iframe/baseline_route_query_contracts.json b/docs/migration/portal-no-iframe/baseline_route_query_contracts.json new file mode 100644 index 0000000..220b0ec --- /dev/null +++ b/docs/migration/portal-no-iframe/baseline_route_query_contracts.json @@ -0,0 +1,46 @@ +{ + "source": "frontend route parsing and current parity matrix", + "routes": { + "/wip-overview": { + "query_keys": [ + "workorder", + "lotid", + "package", + "type", + "status" + ], + "notes": "filters + status URL state must remain compatible" + }, + "/wip-detail": { + "query_keys": [ + "workcenter", + "workorder", + "lotid", + "package", + "type", + "status" + ], + "notes": "workcenter deep-link and back-link query continuity" + }, + "/hold-detail": { + "query_keys": [ + "reason" + ], + "notes": "reason required for normal access flow" + }, + "/resource-history": { + "query_keys": [ + "start_date", + "end_date", + "granularity", + "workcenter_groups", + "families", + "resource_ids", + "is_production", + "is_key", + "is_monitor" + ], + "notes": "query/export params must remain compatible" + } + } +} diff --git a/docs/migration/portal-no-iframe/drawer_governance_contract.md b/docs/migration/portal-no-iframe/drawer_governance_contract.md new file mode 100644 index 0000000..3d37e25 --- /dev/null +++ b/docs/migration/portal-no-iframe/drawer_governance_contract.md @@ -0,0 +1,50 @@ +# Drawer Governance Contract (Portal No-Iframe Migration) + +## Scope + +This contract defines drawer behavior that must remain stable during migration. + +## Canonical Responsibilities + +Drawer metadata is responsible for: + +- Information architecture grouping. +- Display order. +- Access visibility (`admin_only`). + +Drawer metadata is not responsible for: + +- Content embedding mode (`iframe`, `toolFrame`). +- Rendering technology selection (Jinja vs SPA route view). + +## Contract Rules + +1. Drawer IDs must be unique and non-empty. +2. Page routes must be unique and non-empty. +3. `page.drawer_id` (when present) must reference an existing drawer. +4. `order` values (when present) must be positive integers. +5. Page status must be one of `released` or `dev`. +6. Visibility outcomes must be deterministic for admin/non-admin users. + +## Deterministic Rendering Order + +Drawers: + +- Primary sort by `order` ascending. +- Secondary sort by `name` ascending. + +Pages in each drawer: + +- Primary sort by `order` ascending. +- Secondary sort by `(name or route)` ascending. + +## Visibility Semantics + +- Non-admin users can view only `released` pages in non-admin-only drawers. +- Admin users can view all drawer-assigned pages according to current page status policy. +- Drawers with zero visible pages are hidden. + +## Validation Artifacts + +- `baseline_drawer_contract_validation.json` +- `baseline_drawer_visibility.json` diff --git a/docs/migration/portal-no-iframe/frame_id_tool_src_deprecation_plan.md b/docs/migration/portal-no-iframe/frame_id_tool_src_deprecation_plan.md new file mode 100644 index 0000000..a8c3787 --- /dev/null +++ b/docs/migration/portal-no-iframe/frame_id_tool_src_deprecation_plan.md @@ -0,0 +1,44 @@ +# `frame_id` / `tool_src` Deprecation Plan + +## Status + +- Retirement completed in this change. +- Runtime navigation payload generation no longer emits: + - `frame_id` + - `tool_src` + +## Context + +Frame-era fields were used for iframe loading compatibility: + +- `frame_id` +- `tool_src` + +## Policy + +Deprecation is phased and must not break active routes. + +## Phases + +1. **Compatibility phase**: + - Keep fields in payload. + - Ensure new router navigation logic does not rely on these fields. +2. **Dual-run phase**: + - Validate all navigation paths without frame fields. +3. **Retirement readiness**: + - Wrapper-first pages are stable in shell. + - Cutover gates G1~G7 are green in rehearsal. +4. **Removal phase**: + - Remove generation and downstream usage of `frame_id/tool_src`. + - Update related tests and docs. + +## Removal Checkpoints + +- Checkpoint A: drawer parity stable in canary. +- Checkpoint B: legacy wrappers stable with no frame-field dependency. +- Checkpoint C: rollback mechanism verified independent of frame fields. ✓ + +## Risk Controls + +- Keep rollback-safe path via route-level navigation and kill-switch. +- Keep gate coverage for route/drawer/workflow parity after field removal. diff --git a/docs/migration/portal-no-iframe/legacy_rewrite_playbook.md b/docs/migration/portal-no-iframe/legacy_rewrite_playbook.md new file mode 100644 index 0000000..63dd2cd --- /dev/null +++ b/docs/migration/portal-no-iframe/legacy_rewrite_playbook.md @@ -0,0 +1,40 @@ +# Legacy Rewrite Playbook (Batch-2) + +## Target Pages + +This playbook governs rewrite execution for the remaining three legacy pages: + +- `/job-query` +- `/excel-query` +- `/query-tool` + +Rewrite order follows `legacy_rewrite_priority_matrix.md`. + +## Canonical Steps + +1. Preserve route and API contracts first. +2. Move page state and API calls into composables (`useData`). +3. Replace page-local repeated blocks with shared UI components where possible. +4. Keep Tailwind token alignment for new/changed UI. +5. Validate with per-page smoke checklist before/after switch. + +## Required Acceptance (Per Page) + +- Route reachable and functional without shell wrapper. +- Core query workflow succeeds and returns expected result sections. +- Export path remains usable (where applicable). +- No new unhandled runtime error on the primary path. +- Checklist IDs pass: + - `/job-query`: `JOB-SMOKE-01`~`JOB-SMOKE-06` + - `/excel-query`: `EXCEL-SMOKE-01`~`EXCEL-SMOKE-06` + - `/query-tool`: `QTOOL-SMOKE-01`~`QTOOL-SMOKE-06` + +Checklist source: + +- `docs/migration/portal-no-iframe/legacy_rewrite_smoke_checklists.md` + +## Shared Guardrails + +- Do not change backend API signatures in rewrite phase. +- Keep direct-link behavior and query semantics stable. +- If parity fails, rollback to previous stable page artifact before continuing. diff --git a/docs/migration/portal-no-iframe/legacy_rewrite_priority_matrix.md b/docs/migration/portal-no-iframe/legacy_rewrite_priority_matrix.md new file mode 100644 index 0000000..f373053 --- /dev/null +++ b/docs/migration/portal-no-iframe/legacy_rewrite_priority_matrix.md @@ -0,0 +1,40 @@ +# Legacy Rewrite Priority Matrix + +## Scoring model + +`priority_score = usage(0-5)*0.3 + complexity(0-5)*0.4 + risk(0-5)*0.3` + +- Usage: current observed operational usage + business criticality. +- Complexity: route/API count, frontend LOC, workflow branches. +- Risk: data mutation/export/upload sensitivity and regression blast radius. + +## Measured technical baseline + +| Page | Backend route LOC | Template LOC | Frontend LOC | API surface | +| --- | ---: | ---: | ---: | --- | +| `query-tool` | 509 | 1267 | 3139 | resolve/history/adjacent/associations/equipment/export | +| `excel-query` | 355 | 1181 | 624 | upload/schema/query/export | +| `job-query` | 195 | 995 | 520 | resources/jobs/txn/export | +| `tmtt-defect` | 82 | 271 | 363 | analysis/query + CSV export | + +## Priority scoring + +| Page | Usage | Complexity | Risk | Score | +| --- | ---: | ---: | ---: | ---: | +| `tmtt-defect` | 2 | 1 | 2 | 1.6 | +| `job-query` | 3 | 2 | 3 | 2.6 | +| `excel-query` | 3 | 4 | 4 | 3.7 | +| `query-tool` | 4 | 5 | 5 | 4.7 | + +## Rewrite order decision + +1. `tmtt-defect` (canonical exemplar) +2. `job-query` +3. `excel-query` +4. `query-tool` + +## Rationale + +- Start with lowest-complexity page to establish shared migration playbook. +- Keep high-complexity/high-risk `query-tool` last to maximize reuse from prior rewrites. +- Defer upload-heavy `excel-query` until shared error/retry/upload patterns are stabilized. diff --git a/docs/migration/portal-no-iframe/legacy_rewrite_smoke_checklists.md b/docs/migration/portal-no-iframe/legacy_rewrite_smoke_checklists.md new file mode 100644 index 0000000..e73ace2 --- /dev/null +++ b/docs/migration/portal-no-iframe/legacy_rewrite_smoke_checklists.md @@ -0,0 +1,77 @@ +# Legacy Rewrite Smoke Checklists (Per-Page) + +本文件是 `7.2 ~ 7.4` 的執行前置與驗收基準。 +每一頁在「重寫前(wrapper baseline)」與「重寫後(rewrite candidate)」都必須執行同一組 smoke。 + +## 0. 執行規則 + +- 必須記錄:執行日期、分支/commit、執行人、環境(DEV/UAT)。 +- 每頁 smoke 通過率要求:`100%`。 +- 任何 P0 smoke 失敗即視為 `No-Go`,不得進入 wrapper 移除。 +- `excel-query`、`query-tool` 為 admin/dev 可見頁,需使用 admin 身份執行。 + +## 1. `tmtt-defect`(Rewrite Exemplar) + +### 前置條件 +- 可取得有效 `start_date/end_date` 測試區間。 +- `/api/tmtt-defect/analysis` 與 `/api/tmtt-defect/export` 可連線。 + +### Smoke Cases +- [ ] `TMTT-SMOKE-01` Route reachable: `/tmtt-defect` 可直接開啟,無白屏/JS error。 +- [ ] `TMTT-SMOKE-02` Required params guard: 缺少日期時,顯示明確錯誤且不崩潰。 +- [ ] `TMTT-SMOKE-03` Query success: 送出合法日期後,KPI/Charts/Detail 皆成功渲染。 +- [ ] `TMTT-SMOKE-04` Drill-down: 點擊 Pareto 圖欄位可套用/清除篩選,明細同步。 +- [ ] `TMTT-SMOKE-05` Table behavior: 明細表格可排序,排序方向切換正確。 +- [ ] `TMTT-SMOKE-06` Export CSV: 匯出成功,response 為 CSV 且檔名包含日期區間。 + +## 2. `job-query` + +### 前置條件 +- `resource` 清單可取得。 +- 至少有一組可查詢日期區間。 + +### Smoke Cases +- [ ] `JOB-SMOKE-01` Route reachable: `/job-query` 可直接開啟。 +- [ ] `JOB-SMOKE-02` Resource loading: `/api/job-query/resources` 回傳清單,UI 可選取。 +- [ ] `JOB-SMOKE-03` Query jobs: 選設備+日期後可查詢成功並顯示結果。 +- [ ] `JOB-SMOKE-04` Txn detail: 由查詢結果可開啟某筆 job 的 txn history。 +- [ ] `JOB-SMOKE-05` Export CSV: 匯出成功且檔案可下載。 +- [ ] `JOB-SMOKE-06` Validation: 缺日期/無設備/超過上限時回傳明確錯誤訊息。 + +## 3. `excel-query`(Admin) + +### 前置條件 +- 準備一份有效 `.xlsx` 測試檔。 +- Admin session 已登入。 + +### Smoke Cases +- [ ] `EXCEL-SMOKE-01` Route/auth: `/excel-query` admin 可進入,非 admin 受保護。 +- [ ] `EXCEL-SMOKE-02` Upload: 上傳有效 Excel 後可解析欄位與預覽。 +- [ ] `EXCEL-SMOKE-03` Column detect: 欄位唯一值與型別偵測可正常運作。 +- [ ] `EXCEL-SMOKE-04` Execute query: 標準查詢與進階查詢都可回傳資料。 +- [ ] `EXCEL-SMOKE-05` Export CSV: 查詢結果可匯出 CSV。 +- [ ] `EXCEL-SMOKE-06` Invalid file guard: 非 `.xls/.xlsx` 檔案被拒絕且回傳可讀錯誤。 + +## 4. `query-tool`(Admin) + +### 前置條件 +- Admin session 已登入。 +- 可用測試 lot/equipment/date range。 + +### Smoke Cases +- [ ] `QTOOL-SMOKE-01` Route reachable: `/query-tool` 可開啟。 +- [ ] `QTOOL-SMOKE-02` Resolve flow: lot_id/serial/work_order 至少一種解析成功。 +- [ ] `QTOOL-SMOKE-03` History flow: lot history 可查詢並顯示。 +- [ ] `QTOOL-SMOKE-04` Adjacent flow: adjacent lots 查詢可回傳。 +- [ ] `QTOOL-SMOKE-05` Associations: materials/rejects/holds/splits/jobs 查詢可用。 +- [ ] `QTOOL-SMOKE-06` Equipment period: status_hours/lots/materials/rejects/jobs 至少各成功一次。 +- [ ] `QTOOL-SMOKE-07` Export CSV: 匯出可下載且欄位合理。 +- [ ] `QTOOL-SMOKE-08` Validation: 缺參數、非法日期範圍會回傳可讀錯誤。 + +## 5. Exit Rule(與 7.4 連動) + +只有在下列條件全成立,才可移除 wrapper: + +- [ ] 四頁 rewrite smoke 全部通過。 +- [ ] 與 `legacy_wrapper_telemetry_contract.md` 對照,error 率在門檻內。 +- [ ] 與 `parity_checklist.md` 的 Route/Workflow/API contract 檢查一致。 diff --git a/docs/migration/portal-no-iframe/legacy_wrapper_exit_criteria.md b/docs/migration/portal-no-iframe/legacy_wrapper_exit_criteria.md new file mode 100644 index 0000000..58cebc7 --- /dev/null +++ b/docs/migration/portal-no-iframe/legacy_wrapper_exit_criteria.md @@ -0,0 +1,21 @@ +# Legacy Wrapper Exit Criteria (Rewrite-ready) + +A wrapped page is rewrite-ready only when all criteria are met. + +## Functional readiness + +1. Core workflows are documented with at least one deterministic smoke script per workflow. +2. Route/query contract is frozen and covered by contract tests. +3. Export/upload side effects (if any) are reproducible in test or staging. + +## Technical readiness + +1. Shared UI and composables can cover at least 70% of page scaffolding (filters, cards, table shell, pagination). +2. Required API payload key/type contract is stable for two consecutive releases. +3. Wrapper telemetry shows no unresolved high-severity navigation failures in the last release cycle. + +## Operational readiness + +1. Rollback path for rewritten page is documented and rehearsed. +2. Error budget and success threshold for canary are defined before rewrite starts. +3. Product owner confirms parity acceptance checklist for the target page. diff --git a/docs/migration/portal-no-iframe/legacy_wrapper_telemetry_contract.md b/docs/migration/portal-no-iframe/legacy_wrapper_telemetry_contract.md new file mode 100644 index 0000000..e929647 --- /dev/null +++ b/docs/migration/portal-no-iframe/legacy_wrapper_telemetry_contract.md @@ -0,0 +1,40 @@ +# Legacy Wrapper Telemetry Contract + +## Status + +- Retired after wrapper decommission. +- `POST /api/portal/wrapper-telemetry` has been removed. +- Reference only for historical migration traceability. + +## Wrapper scope + +- `/job-query` +- `/excel-query` +- `/query-tool` +- `/tmtt-defect` + +## Client events + +- `wrapper_loaded`: wrapper route rendered in shell. +- `launch`: user clicked "進入既有頁面" and navigation handoff started. + +## API endpoint + +- `POST /api/portal/wrapper-telemetry` +- Payload: + - `event_type: string` + - `route: string` (must be one of wrapper scope routes) + - `page_name?: string` + - `drawer_name?: string` + - `duration_ms?: number` + - `ts?: string` + +## Validation + +- Reject unknown routes with `400`. +- Reject missing `event_type` with `400`. + +## Fallback behavior + +- Wrapper UI always provides direct anchor navigation to the legacy route. +- Telemetry failure must not block navigation. diff --git a/docs/migration/portal-no-iframe/motion_baseline_guidelines.md b/docs/migration/portal-no-iframe/motion_baseline_guidelines.md new file mode 100644 index 0000000..0c23d59 --- /dev/null +++ b/docs/migration/portal-no-iframe/motion_baseline_guidelines.md @@ -0,0 +1,20 @@ +# Motion Baseline Guidelines (Vue Transition First) + +## Baseline principles + +1. Motion clarifies state change, not decoration. +2. Default to short transitions (180ms - 240ms) with easing. +3. Keep animation on container level (route/panel/filter-chip), avoid animating large table row sets. + +## Standard transitions + +- Route change: `route-fade` (`opacity + translateY`) in portal shell. +- Drawer navigation: hover/active transition on sidebar links. +- Filter apply/remove: `TransitionGroup` chip enter/leave motion. +- Data refresh pulse: panel-level pulse when chart/table refresh is running. + +## Accessibility + +- Respect `prefers-reduced-motion: reduce`. +- All key transitions must have non-animated fallback styles. +- Motion must not block interaction or delay data rendering. diff --git a/docs/migration/portal-no-iframe/motion_gsap_escalation_rule.md b/docs/migration/portal-no-iframe/motion_gsap_escalation_rule.md new file mode 100644 index 0000000..87327a5 --- /dev/null +++ b/docs/migration/portal-no-iframe/motion_gsap_escalation_rule.md @@ -0,0 +1,19 @@ +# GSAP Escalation Rule + +## Default + +Use Vue native transitions and CSS transitions for portal migration work. + +## GSAP allowed only when all conditions are true + +1. Interaction cannot be expressed with native Vue/CSS transitions without major maintainability cost. +2. Animation is business-critical (e.g., complex timeline playback or synchronized multi-chart storytelling). +3. Reduced-motion fallback is explicitly implemented. +4. Performance impact is measured on target hardware and meets baseline thresholds. +5. A rollback switch exists to disable advanced animation without breaking functionality. + +## Approval checklist + +- Document the exact scenario and why Vue/CSS is insufficient. +- Add test coverage for degraded/non-animated path. +- Confirm bundle-size impact is acceptable for the target route. diff --git a/docs/migration/portal-no-iframe/pagination_migration_batch1.md b/docs/migration/portal-no-iframe/pagination_migration_batch1.md new file mode 100644 index 0000000..f54a9e7 --- /dev/null +++ b/docs/migration/portal-no-iframe/pagination_migration_batch1.md @@ -0,0 +1,27 @@ +# Shared Pagination Migration Batch 1 + +## Scope + +Migrated pages/components: + +- `wip-detail/components/LotTable.vue` +- `hold-detail/components/LotTable.vue` +- `hold-overview/components/LotTable.vue` +- `hold-history/components/DetailTable.vue` +- `mid-section-defect/components/DetailTable.vue` + +## Change + +- Replaced direct/inline pagination rendering with shared `PaginationControl`. +- Preserved existing page event contracts (`prev-page`, `next-page`). + +## Visual parity checks + +- Pagination visibility still depends on `totalPages > 1`. +- Prev/Next button enablement remains bounded by page range. +- Page info text format remains unchanged on migrated views. + +## Removed duplicated artifacts + +- Removed local Prev/Next markup and boundary logic from `hold-history/components/DetailTable.vue`. +- Consolidated pagination behavior into shared wrapper for this batch. diff --git a/docs/migration/portal-no-iframe/parity_checklist.md b/docs/migration/portal-no-iframe/parity_checklist.md new file mode 100644 index 0000000..5a11733 --- /dev/null +++ b/docs/migration/portal-no-iframe/parity_checklist.md @@ -0,0 +1,46 @@ +# Portal No-Iframe Migration Parity Checklist + +This checklist is the execution companion for `portal-no-iframe-navigation` migration. + +## A. Drawer Visibility Parity + +- [ ] Non-admin visible drawers/routes match `baseline_drawer_visibility.json` exactly. +- [ ] Admin visible drawers/routes match `baseline_drawer_visibility.json` exactly. +- [ ] Empty drawers remain hidden. +- [ ] `admin_only` drawer behavior remains unchanged. + +## B. Route and Query Contract Parity + +- [ ] `/wip-overview` preserves `workorder|lotid|package|type|status` URL semantics. +- [ ] `/wip-detail` preserves `workcenter|workorder|lotid|package|type|status` URL semantics. +- [ ] `/hold-detail` preserves required `reason` semantics and fallback behavior. +- [ ] `/resource-history` preserves date/granularity/group/family/resource/flag query semantics. + +## C. Core Workflow Smoke Paths + +- [ ] Legacy rewrite per-page smoke checklist passes (`legacy_rewrite_smoke_checklists.md`). +- [ ] `/` open portal and switch via drawer navigation. +- [ ] `/wip-overview` apply filters and drill down to `/wip-detail`. +- [ ] `/wip-overview` reason drill-down to `/hold-detail`. +- [ ] `/resource-history` execute query and export path. +- [ ] Legacy rewrite pages (`/job-query`, `/excel-query`, `/query-tool`, `/tmtt-defect`) remain reachable and usable. + +## D. API Payload Contract Parity + +- [ ] `/api/wip/overview/summary` required keys present. +- [ ] `/api/wip/overview/matrix` required keys present. +- [ ] `/api/wip/hold-detail/summary` required keys present. +- [ ] `/api/resource/history/summary` required keys present. +- [ ] `/api/resource/history/detail` required keys present. + +## E. Stability and Performance + +- [ ] No unhandled JS runtime errors on critical E2E paths. +- [ ] Route switch latency remains within agreed threshold. +- [ ] Memory footprint does not regress beyond agreed threshold. + +## F. Cutover Decision + +- [ ] All G1~G7 gates are green. +- [ ] Rollback rehearsal result is recent and valid. +- [ ] Cutover owner and rollback owner are explicitly assigned. diff --git a/docs/migration/portal-no-iframe/performance_baseline_comparison.md b/docs/migration/portal-no-iframe/performance_baseline_comparison.md new file mode 100644 index 0000000..2da7fac --- /dev/null +++ b/docs/migration/portal-no-iframe/performance_baseline_comparison.md @@ -0,0 +1,21 @@ +# Performance Baseline Comparison + +Measured via Flask test client (route latency in ms). + +## Key Entry Routes + +| Surface | Avg (ms) | P95 (ms) | +| --- | ---: | ---: | +| Legacy portal `/` | 1.557 | 0.891 | +| SPA shell `/portal-shell` | 0.239 | 0.263 | + +## Shared API Route + +| Route | Legacy Avg (ms) | SPA Avg (ms) | Delta (ms) | +| --- | ---: | ---: | ---: | +| `/api/portal/navigation` | 0.341 | 0.313 | -0.028 | + +## Notes + +- This baseline is synthetic (test client), used for migration regression gate trend tracking. +- Production browser/network RUM should be captured separately during canary rollout. diff --git a/docs/migration/portal-no-iframe/performance_baseline_legacy.json b/docs/migration/portal-no-iframe/performance_baseline_legacy.json new file mode 100644 index 0000000..f4d6550 --- /dev/null +++ b/docs/migration/portal-no-iframe/performance_baseline_legacy.json @@ -0,0 +1,61 @@ +{ + "portal_spa_enabled": false, + "samples_per_route": 15, + "metrics": [ + { + "route": "/", + "samples": 15, + "avg_ms": 1.557, + "p95_ms": 0.891, + "min_ms": 0.536, + "max_ms": 14.997, + "status_codes": [ + 200 + ] + }, + { + "route": "/api/portal/navigation", + "samples": 15, + "avg_ms": 0.341, + "p95_ms": 0.396, + "min_ms": 0.284, + "max_ms": 0.404, + "status_codes": [ + 200 + ] + }, + { + "route": "/wip-overview", + "samples": 15, + "avg_ms": 0.683, + "p95_ms": 0.925, + "min_ms": 0.422, + "max_ms": 2.633, + "status_codes": [ + 200 + ] + }, + { + "route": "/resource", + "samples": 15, + "avg_ms": 0.413, + "p95_ms": 0.506, + "min_ms": 0.337, + "max_ms": 0.699, + "status_codes": [ + 200 + ] + }, + { + "route": "/qc-gate", + "samples": 15, + "avg_ms": 0.422, + "p95_ms": 0.453, + "min_ms": 0.371, + "max_ms": 0.615, + "status_codes": [ + 200 + ] + } + ] +} \ No newline at end of file diff --git a/docs/migration/portal-no-iframe/performance_baseline_spa.json b/docs/migration/portal-no-iframe/performance_baseline_spa.json new file mode 100644 index 0000000..3d9d40b --- /dev/null +++ b/docs/migration/portal-no-iframe/performance_baseline_spa.json @@ -0,0 +1,72 @@ +{ + "portal_spa_enabled": true, + "samples_per_route": 15, + "metrics": [ + { + "route": "/portal-shell", + "samples": 15, + "avg_ms": 0.239, + "p95_ms": 0.263, + "min_ms": 0.169, + "max_ms": 0.708, + "status_codes": [ + 200 + ] + }, + { + "route": "/api/portal/navigation", + "samples": 15, + "avg_ms": 0.313, + "p95_ms": 0.412, + "min_ms": 0.257, + "max_ms": 0.437, + "status_codes": [ + 200 + ] + }, + { + "route": "/job-query", + "samples": 15, + "avg_ms": 0.904, + "p95_ms": 0.786, + "min_ms": 0.33, + "max_ms": 7.345, + "status_codes": [ + 200 + ] + }, + { + "route": "/excel-query", + "samples": 15, + "avg_ms": 0.47, + "p95_ms": 0.448, + "min_ms": 0.324, + "max_ms": 1.951, + "status_codes": [ + 403 + ] + }, + { + "route": "/query-tool", + "samples": 15, + "avg_ms": 0.448, + "p95_ms": 0.802, + "min_ms": 0.32, + "max_ms": 0.849, + "status_codes": [ + 403 + ] + }, + { + "route": "/tmtt-defect", + "samples": 15, + "avg_ms": 0.583, + "p95_ms": 0.585, + "min_ms": 0.323, + "max_ms": 3.455, + "status_codes": [ + 200 + ] + } + ] +} \ No newline at end of file diff --git a/docs/migration/portal-no-iframe/rollback_rehearsal_runbook.md b/docs/migration/portal-no-iframe/rollback_rehearsal_runbook.md new file mode 100644 index 0000000..15444d0 --- /dev/null +++ b/docs/migration/portal-no-iframe/rollback_rehearsal_runbook.md @@ -0,0 +1,51 @@ +# Portal No-Iframe Migration Rollback Rehearsal Runbook + +## Objective + +Validate that navigation can be restored to pre-cutover stable behavior within target SLO. + +- Target recovery SLO: <= 15 minutes + +## Trigger Conditions + +Execute rollback when any of the following occur after cutover: + +- P0 route unavailable or broken workflow. +- Drawer visibility parity mismatch. +- Critical API payload contract mismatch causing page failure. +- Severe runtime JS errors on critical user paths. + +## Preconditions + +- Feature-flag/env toggle path for shell cutover is in place. +- Latest baseline snapshots are available under `docs/migration/portal-no-iframe/`. +- On-call owner and rollback owner are assigned. + +## Rehearsal Steps + +1. Enable new navigation mode in staging/canary. +2. Execute parity checklist (`parity_checklist.md`) on critical routes. +3. Force simulated rollback trigger (toggle off new mode). +4. Re-run critical smoke checks: + - portal load + - drawer visibility + - wip overview/detail flow + - resource history query path +5. Record elapsed recovery time and failures. + +## Verification Criteria + +- Toggle change takes effect without manual code rollback. +- Critical routes recover to expected behavior. +- Recovery time is within SLO. +- No residual hard-failure state remains. + +## Post-Rehearsal Record + +- Date: +- Environment: +- Operator: +- Trigger reason: +- Recovery duration: +- Issues found: +- Follow-up actions: diff --git a/docs/migration/portal-no-iframe/rollback_strategy_shell_and_wrappers.md b/docs/migration/portal-no-iframe/rollback_strategy_shell_and_wrappers.md new file mode 100644 index 0000000..616c8f4 --- /dev/null +++ b/docs/migration/portal-no-iframe/rollback_strategy_shell_and_wrappers.md @@ -0,0 +1,28 @@ +# Rollback Strategy (Shell / Router / Wrapper) + +## Scope + +- Shell entry failures (`/portal-shell`, route guards, navigation API) +- Legacy route integration failures (`job-query`, `excel-query`, `query-tool`, `tmtt-defect`) + +## Immediate actions + +1. Flip `PORTAL_SPA_ENABLED=false`. +2. Confirm `/` portal route responds and sidebar route links render. +3. Verify core routes (`/wip-overview`, `/resource`, `/qc-gate`) return 2xx. +4. Verify legacy routes (`/job-query`, `/excel-query`, `/query-tool`, `/tmtt-defect`) return 2xx. + +## Validation checkpoints (<=15 minutes) + +- `GET /api/portal/navigation` returns deterministic drawer/page list. +- No spike in 5xx for portal and legacy routes. +- Smoke flows for one P0 page and one legacy page pass. + +## Legacy route fallback + +- If a legacy route fails hard, temporarily hide it from drawer config (`page_status.json`) and announce maintenance route. + +## Post-rollback follow-up + +- Capture failed gate(s), timestamp, and impacted routes. +- Generate incident summary with fix candidate and rehearsal re-entry criteria. diff --git a/docs/migration/portal-no-iframe/rollout_canary_plan.md b/docs/migration/portal-no-iframe/rollout_canary_plan.md new file mode 100644 index 0000000..6dce6a2 --- /dev/null +++ b/docs/migration/portal-no-iframe/rollout_canary_plan.md @@ -0,0 +1,27 @@ +# Phased Rollout Plan (Canary) + +## Feature switch + +- Primary switch: `PORTAL_SPA_ENABLED` +- Canary enabled users/groups are routed to `/portal-shell`; others remain on `/` route-based portal. + +## Phases + +1. Phase A (Internal): dev/admin users only, 1 day. +2. Phase B (Canary): 10-20% target users, 2-3 days. +3. Phase C (Broad): 50% users if gates are green for 24h. +4. Phase D (Full): 100% after cutover gates pass. + +## Success thresholds + +- Route availability (P0): >= 99.9% 2xx/3xx. +- Client runtime error rate on critical paths: 0 unhandled exceptions. +- Drawer parity drift: 0 mismatches (admin/non-admin route sets). +- Wrapper launch success (`launch` telemetry): >= 99%. + +## Error thresholds (rollback trigger) + +- P0 route availability < 99.5% in any 30-minute window. +- Any critical workflow smoke failure. +- Drawer parity mismatch count > 0 after deployment. +- Wrapper telemetry error rate >= 2% sustained 30 minutes. diff --git a/docs/migration/portal-no-iframe/shared_component_contracts.md b/docs/migration/portal-no-iframe/shared_component_contracts.md new file mode 100644 index 0000000..bbf23bc --- /dev/null +++ b/docs/migration/portal-no-iframe/shared_component_contracts.md @@ -0,0 +1,50 @@ +# Shared UI Component Contracts + +## `PaginationControl` + +File: `frontend/src/shared-ui/components/PaginationControl.vue` + +- Props: + - `page?: number` (legacy compatibility) + - `modelValue?: number` + - `totalPages: number` + - `infoText?: string` + - `visible?: boolean` +- Emits: + - `update:modelValue(number)` + - `change(number)` + - `prev(number)` + - `next(number)` +- Compatibility: + - Supports legacy usage (`:page`, `@prev`, `@next`) for migration-safe replacement. + +## `SectionCard` + +File: `frontend/src/shared-ui/components/SectionCard.vue` + +- Slots: + - `header` + - default body + - `footer` +- Purpose: + - Normalize page section container structure and spacing. + +## `FilterToolbar` + +File: `frontend/src/shared-ui/components/FilterToolbar.vue` + +- Slots: + - default filter controls + - `actions` +- Purpose: + - Shared filter layout shell with consistent spacing and action alignment. + +## `StatusBadge` + +File: `frontend/src/shared-ui/components/StatusBadge.vue` + +- Props: + - `tone: neutral | success | warning | danger` + - `text: string` +- Purpose: + - Replace repeated local badge/status color snippets. diff --git a/docs/migration/portal-no-iframe/shared_composables_contracts.md b/docs/migration/portal-no-iframe/shared_composables_contracts.md new file mode 100644 index 0000000..afa16d1 --- /dev/null +++ b/docs/migration/portal-no-iframe/shared_composables_contracts.md @@ -0,0 +1,31 @@ +# Shared Composables Contracts + +## `useAutoRefresh` + +File: `frontend/src/shared-composables/useAutoRefresh.js` + +- Current behavior wraps existing `wip-shared` implementation. +- Purpose: single import path for all page modules before deeper implementation merge. + +## `useAutocomplete` + +File: `frontend/src/shared-composables/useAutocomplete.js` + +- Current behavior wraps existing `wip-shared` implementation. +- Purpose: single import path to normalize field/autocomplete interactions. + +## `usePaginationState` + +File: `frontend/src/shared-composables/usePaginationState.js` + +- State: `page`, `perPage`, `total`, `totalPages` +- Derived: `hasPrev`, `hasNext` +- Methods: `setFromPayload`, `reset` + +## `useQueryState` + +File: `frontend/src/shared-composables/useQueryState.js` + +- `readQueryState(keys)` +- `writeQueryState(nextState)` +- Purpose: unify URL query read/write semantics across pages. diff --git a/docs/migration/portal-no-iframe/tailwind_design_tokens.md b/docs/migration/portal-no-iframe/tailwind_design_tokens.md new file mode 100644 index 0000000..23ebf58 --- /dev/null +++ b/docs/migration/portal-no-iframe/tailwind_design_tokens.md @@ -0,0 +1,40 @@ +# Tailwind Design Tokens Mapping + +## Goal + +Map existing portal visual language into a stable token set for phased migration. + +## Color tokens + +- `brand.500` / `brand.600` / `brand.700`: primary brand actions and active navigation states. +- `accent.500`: gradient accent endpoint for shell headers. +- `surface.app` / `surface.card` / `surface.muted`: app background, card surfaces, muted blocks. +- `stroke.soft` / `stroke.panel`: border hierarchy. +- `state.success` / `state.warning` / `state.danger` / `state.neutral`: status dots and health states. + +## Typography tokens + +- `fontFamily.sans`: `Noto Sans TC`, `Microsoft JhengHei`, system fallback. + +## Layout tokens + +- `spacing.shell`: outer shell padding. +- `spacing.panel`: panel interior spacing. +- `spacing.nav`: sidebar item horizontal spacing. +- `spacing.block`: vertical rhythm baseline. + +## Radius and elevation tokens + +- `borderRadius.shell`: shell and main card radius. +- `borderRadius.card`: smaller control/card radius. +- `boxShadow.soft`: light containers (sidebar). +- `boxShadow.panel`: content panel container. +- `boxShadow.shell`: header gradient card emphasis. + +## Z-index token + +- `zIndex.popup`: status popup / overlay layer. + +## Migration note + +Tokens are intentionally aligned with current portal values to minimize visual drift during iframe decommission. diff --git a/docs/migration/portal-no-iframe/tailwind_migration_guide.md b/docs/migration/portal-no-iframe/tailwind_migration_guide.md new file mode 100644 index 0000000..2bb9b03 --- /dev/null +++ b/docs/migration/portal-no-iframe/tailwind_migration_guide.md @@ -0,0 +1,33 @@ +# Tailwind Migration Guide (Portal No-iframe) + +## Purpose + +Move distributed page CSS toward a token-driven Tailwind system without breaking existing portal behavior. + +## Step-by-step + +1. Keep existing route/page behavior unchanged. +2. Replace repeated layout wrappers with Tailwind utilities first (`grid`, `flex`, spacing, radius, shadows). +3. Replace repeated visual primitives with shared component classes from `@layer components`. +4. Move hard-coded colors/spacing to tokens in `tailwind.config.js` and `tailwind.css`. +5. Remove obsolete page-local CSS only after visual parity is verified. + +## Recommended migration order + +1. Shell and shared navigation blocks +2. Filter bars and KPI card rows +3. Shared table containers and pagination controls +4. Page-specific edge states and empty/error banners + +## Parity checks per batch + +- Drawer visibility and route links stay unchanged. +- Existing URL/query semantics remain compatible. +- No new runtime style conflicts in non-admin/admin views. + +## Do / Don’t + +- Do: prefer composable utility classes and shared Vue components. +- Do: keep style changes scoped to one route family per batch. +- Don’t: introduce new long inline ` diff --git a/frontend/src/shared-ui/components/PaginationControl.vue b/frontend/src/shared-ui/components/PaginationControl.vue new file mode 100644 index 0000000..55665ed --- /dev/null +++ b/frontend/src/shared-ui/components/PaginationControl.vue @@ -0,0 +1,66 @@ + + + diff --git a/frontend/src/shared-ui/components/SectionCard.vue b/frontend/src/shared-ui/components/SectionCard.vue new file mode 100644 index 0000000..8727f4a --- /dev/null +++ b/frontend/src/shared-ui/components/SectionCard.vue @@ -0,0 +1,38 @@ + + + diff --git a/frontend/src/shared-ui/components/StatusBadge.vue b/frontend/src/shared-ui/components/StatusBadge.vue new file mode 100644 index 0000000..8987c6c --- /dev/null +++ b/frontend/src/shared-ui/components/StatusBadge.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/frontend/src/shared-ui/index.js b/frontend/src/shared-ui/index.js new file mode 100644 index 0000000..aee0052 --- /dev/null +++ b/frontend/src/shared-ui/index.js @@ -0,0 +1,4 @@ +export { default as FilterToolbar } from './components/FilterToolbar.vue'; +export { default as PaginationControl } from './components/PaginationControl.vue'; +export { default as SectionCard } from './components/SectionCard.vue'; +export { default as StatusBadge } from './components/StatusBadge.vue'; diff --git a/frontend/src/styles/tailwind.css b/frontend/src/styles/tailwind.css new file mode 100644 index 0000000..6adaa1e --- /dev/null +++ b/frontend/src/styles/tailwind.css @@ -0,0 +1,68 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --portal-shell-max-width: 1600px; + --portal-shell-gap: 12px; + --portal-shell-bg: #f5f7fa; + --portal-panel-bg: #ffffff; + --portal-text-primary: #1f2937; + --portal-text-secondary: #64748b; + --portal-brand-start: #667eea; + --portal-brand-end: #764ba2; + --portal-radius-shell: 10px; + --portal-radius-card: 8px; + --portal-shadow-soft: 0 1px 4px rgba(0, 0, 0, 0.06); + --portal-shadow-panel: 0 2px 10px rgba(0, 0, 0, 0.08); + --portal-z-overlay: 1000; + } + + body { + background: var(--portal-shell-bg); + color: var(--portal-text-primary); + font-family: "Noto Sans TC", "Microsoft JhengHei", system-ui, sans-serif; + } +} + +@layer components { + .ui-shell-card { + border-radius: var(--portal-radius-shell); + background: var(--portal-panel-bg); + box-shadow: var(--portal-shadow-panel); + } + + .ui-sidebar-link { + display: block; + width: 100%; + text-decoration: none; + border-radius: var(--portal-radius-card); + transition: all 0.15s ease; + } + + .ui-sidebar-link:hover { + background: #f1f5f9; + color: #667eea; + } + + .ui-sidebar-link.is-active { + background: #eef2ff; + color: #667eea; + font-weight: 600; + } +} + +@layer utilities { + .u-panel-stack { + display: flex; + flex-direction: column; + gap: var(--portal-shell-gap); + } + + .u-content-shell { + max-width: var(--portal-shell-max-width); + margin-left: auto; + margin-right: auto; + } +} diff --git a/frontend/src/tmtt-defect/App.vue b/frontend/src/tmtt-defect/App.vue new file mode 100644 index 0000000..13f299e --- /dev/null +++ b/frontend/src/tmtt-defect/App.vue @@ -0,0 +1,153 @@ + + + diff --git a/frontend/src/tmtt-defect/components/TmttChartCard.vue b/frontend/src/tmtt-defect/components/TmttChartCard.vue new file mode 100644 index 0000000..7667444 --- /dev/null +++ b/frontend/src/tmtt-defect/components/TmttChartCard.vue @@ -0,0 +1,177 @@ + + + diff --git a/frontend/src/tmtt-defect/components/TmttDetailTable.vue b/frontend/src/tmtt-defect/components/TmttDetailTable.vue new file mode 100644 index 0000000..4770338 --- /dev/null +++ b/frontend/src/tmtt-defect/components/TmttDetailTable.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend/src/tmtt-defect/components/TmttKpiCards.vue b/frontend/src/tmtt-defect/components/TmttKpiCards.vue new file mode 100644 index 0000000..b6e8deb --- /dev/null +++ b/frontend/src/tmtt-defect/components/TmttKpiCards.vue @@ -0,0 +1,51 @@ + + + diff --git a/frontend/src/tmtt-defect/composables/useTmttDefectData.js b/frontend/src/tmtt-defect/composables/useTmttDefectData.js new file mode 100644 index 0000000..05d8136 --- /dev/null +++ b/frontend/src/tmtt-defect/composables/useTmttDefectData.js @@ -0,0 +1,216 @@ +import { computed, ref } from 'vue'; + +import { apiGet, ensureMesApiAvailable } from '../../core/api.js'; + +ensureMesApiAvailable(); + +const NUMERIC_COLUMNS = new Set([ + 'INPUT_QTY', + 'PRINT_DEFECT_QTY', + 'PRINT_DEFECT_RATE', + 'LEAD_DEFECT_QTY', + 'LEAD_DEFECT_RATE', +]); + +function notify(level, message) { + const toast = globalThis.Toast; + if (toast && typeof toast[level] === 'function') { + return toast[level](message); + } + if (level === 'error') { + console.error(message); + } else { + console.info(message); + } + return null; +} + +function dismissToast(id) { + if (!id) return; + const toast = globalThis.Toast; + if (toast && typeof toast.dismiss === 'function') { + toast.dismiss(id); + } +} + +function toComparable(value, key) { + if (NUMERIC_COLUMNS.has(key)) { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + if (value == null) return ''; + return String(value).toUpperCase(); +} + +function toDateString(date) { + return date.toISOString().slice(0, 10); +} + +export function useTmttDefectData() { + const startDate = ref(''); + const endDate = ref(''); + const loading = ref(false); + const errorMessage = ref(''); + const analysisData = ref(null); + const activeFilter = ref(null); + const sortState = ref({ column: '', asc: true }); + + function initializeDateRange() { + if (startDate.value && endDate.value) { + return; + } + + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - 6); + + startDate.value = toDateString(start); + endDate.value = toDateString(end); + } + + const hasData = computed(() => Boolean(analysisData.value)); + const kpi = computed(() => analysisData.value?.kpi || null); + const charts = computed(() => analysisData.value?.charts || {}); + const dailyTrend = computed(() => analysisData.value?.daily_trend || []); + const rawDetailRows = computed(() => analysisData.value?.detail || []); + + const filteredRows = computed(() => { + let rows = rawDetailRows.value; + + if (activeFilter.value?.field && activeFilter.value?.value) { + rows = rows.filter((row) => String(row?.[activeFilter.value.field] || '') === activeFilter.value.value); + } + + if (!sortState.value.column) { + return rows; + } + + const sorted = [...rows].sort((left, right) => { + const leftValue = toComparable(left?.[sortState.value.column], sortState.value.column); + const rightValue = toComparable(right?.[sortState.value.column], sortState.value.column); + if (leftValue < rightValue) { + return sortState.value.asc ? -1 : 1; + } + if (leftValue > rightValue) { + return sortState.value.asc ? 1 : -1; + } + return 0; + }); + + return sorted; + }); + + const totalCount = computed(() => rawDetailRows.value.length); + const filteredCount = computed(() => filteredRows.value.length); + + async function queryData() { + if (!startDate.value || !endDate.value) { + notify('warning', '請選擇起始和結束日期'); + return; + } + + loading.value = true; + errorMessage.value = ''; + const loadingToastId = notify('loading', '查詢中...'); + + try { + const result = await apiGet('/api/tmtt-defect/analysis', { + params: { + start_date: startDate.value, + end_date: endDate.value, + }, + timeout: 120000, + }); + + if (!result || !result.success) { + const message = result?.error || '查詢失敗'; + errorMessage.value = message; + notify('error', message); + return; + } + + analysisData.value = result.data; + activeFilter.value = null; + sortState.value = { column: '', asc: true }; + notify('success', '查詢完成'); + } catch (error) { + const message = error?.message || '查詢失敗'; + errorMessage.value = message; + notify('error', `查詢失敗: ${message}`); + } finally { + dismissToast(loadingToastId); + loading.value = false; + } + } + + function setFilter({ field, value, label }) { + if (!field || !value) { + return; + } + activeFilter.value = { + field, + value, + label: label || `${field}: ${value}`, + }; + } + + function clearFilter() { + activeFilter.value = null; + } + + function toggleSort(column) { + if (!column) { + return; + } + + if (sortState.value.column === column) { + sortState.value = { + column, + asc: !sortState.value.asc, + }; + return; + } + + sortState.value = { + column, + asc: true, + }; + } + + function exportCsv() { + if (!startDate.value || !endDate.value) { + notify('warning', '請先查詢資料'); + return; + } + + const query = new URLSearchParams({ + start_date: startDate.value, + end_date: endDate.value, + }); + window.open(`/api/tmtt-defect/export?${query.toString()}`, '_blank', 'noopener'); + } + + initializeDateRange(); + + return { + startDate, + endDate, + loading, + errorMessage, + hasData, + kpi, + charts, + dailyTrend, + rawDetailRows, + filteredRows, + totalCount, + filteredCount, + activeFilter, + sortState, + queryData, + setFilter, + clearFilter, + toggleSort, + exportCsv, + }; +} diff --git a/frontend/src/tmtt-defect/main.js b/frontend/src/tmtt-defect/main.js index f67d5c1..0119637 100644 --- a/frontend/src/tmtt-defect/main.js +++ b/frontend/src/tmtt-defect/main.js @@ -1,363 +1,7 @@ -import { ensureMesApiAvailable } from '../core/api.js'; +import { createApp } from 'vue'; -ensureMesApiAvailable(); +import '../styles/tailwind.css'; +import App from './App.vue'; +import './style.css'; -(function() { - // ============================================================ - // State - // ============================================================ - let analysisData = null; - let activeFilter = null; // { dimension: 'by_workflow', field: 'WORKFLOW', value: 'xxx' } - let sortState = { column: null, asc: true }; - const charts = {}; - - const CHART_CONFIG = [ - { id: 'chartWorkflow', key: 'by_workflow', field: 'WORKFLOW', title: 'WORKFLOW' }, - { id: 'chartPackage', key: 'by_package', field: 'PRODUCTLINENAME', title: 'PACKAGE' }, - { id: 'chartType', key: 'by_type', field: 'PJ_TYPE', title: 'TYPE' }, - { id: 'chartTmtt', key: 'by_tmtt_machine', field: 'TMTT_EQUIPMENTNAME', title: 'TMTT機台' }, - { id: 'chartMold', key: 'by_mold_machine', field: 'MOLD_EQUIPMENTNAME', title: 'MOLD機台' }, - ]; - - // ============================================================ - // Query - // ============================================================ - window.executeQuery = async function() { - const startDate = document.getElementById('startDate').value; - const endDate = document.getElementById('endDate').value; - - if (!startDate || !endDate) { - Toast.warning('請選擇起始和結束日期'); - return; - } - - const btn = document.getElementById('btnQuery'); - btn.disabled = true; - const loadingId = Toast.loading('查詢中...'); - - try { - const result = await MesApi.get('/api/tmtt-defect/analysis', { - params: { start_date: startDate, end_date: endDate }, - timeout: 120000, - }); - - Toast.dismiss(loadingId); - - if (!result || !result.success) { - Toast.error(result?.error || '查詢失敗'); - return; - } - - analysisData = result.data; - activeFilter = null; - sortState = { column: null, asc: true }; - - renderAll(); - Toast.success('查詢完成'); - } catch (err) { - Toast.dismiss(loadingId); - Toast.error('查詢失敗: ' + (err.message || '未知錯誤')); - } finally { - btn.disabled = false; - } - }; - - // ============================================================ - // Render - // ============================================================ - function renderAll() { - if (!analysisData) return; - - document.getElementById('emptyState').style.display = 'none'; - document.getElementById('kpiRow').style.display = ''; - document.getElementById('chartGrid').style.display = ''; - document.getElementById('detailSection').style.display = ''; - - renderKpi(analysisData.kpi); - renderCharts(analysisData.charts); - renderDailyTrend(analysisData.daily_trend || []); - renderDetailTable(); - } - - function renderKpi(kpi) { - document.getElementById('kpiInput').textContent = kpi.total_input.toLocaleString('zh-TW'); - document.getElementById('kpiLots').textContent = kpi.lot_count.toLocaleString('zh-TW'); - document.getElementById('kpiPrintQty').textContent = kpi.print_defect_qty.toLocaleString('zh-TW'); - document.getElementById('kpiPrintRate').innerHTML = kpi.print_defect_rate.toFixed(4) + '%'; - document.getElementById('kpiLeadQty').textContent = kpi.lead_defect_qty.toLocaleString('zh-TW'); - document.getElementById('kpiLeadRate').innerHTML = kpi.lead_defect_rate.toFixed(4) + '%'; - } - - // ============================================================ - // Charts - // ============================================================ - function renderCharts(chartsData) { - CHART_CONFIG.forEach(cfg => { - const data = chartsData[cfg.key] || []; - renderParetoChart(cfg.id, data, cfg.key, cfg.field, cfg.title); - }); - } - - function renderParetoChart(containerId, data, chartKey, filterField, title) { - if (!charts[containerId]) { - charts[containerId] = echarts.init(document.getElementById(containerId)); - } - const chart = charts[containerId]; - - if (!data || data.length === 0) { - chart.setOption({ - title: { text: '無資料', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } }, - xAxis: { show: false }, yAxis: { show: false }, series: [] - }); - return; - } - - const names = data.map(d => d.name); - const printRates = data.map(d => d.print_defect_rate); - const leadRates = data.map(d => d.lead_defect_rate); - const cumPct = data.map(d => d.cumulative_pct); - - const option = { - tooltip: { - trigger: 'axis', - axisPointer: { type: 'shadow' }, - formatter: function(params) { - const name = params[0].name; - const item = data.find(d => d.name === name); - if (!item) return name; - return `${name}
` + - `投入數: ${item.input_qty.toLocaleString()}
` + - ` 印字不良: ${item.print_defect_qty} (${item.print_defect_rate.toFixed(4)}%)
` + - ` 腳型不良: ${item.lead_defect_qty} (${item.lead_defect_rate.toFixed(4)}%)
` + - `累積: ${item.cumulative_pct.toFixed(1)}%`; - } - }, - legend: { data: ['印字不良率', '腳型不良率', '累積%'], bottom: 0, textStyle: { fontSize: 11 } }, - grid: { left: 60, right: 60, top: 30, bottom: names.length > 8 ? 100 : 60 }, - xAxis: { - type: 'category', data: names, - axisLabel: { - rotate: names.length > 8 ? 35 : 0, - fontSize: 11, - interval: 0, - formatter: v => v.length > 16 ? v.slice(0, 16) + '...' : v - } - }, - yAxis: [ - { type: 'value', name: '不良率(%)', axisLabel: { fontSize: 10 }, splitLine: { lineStyle: { type: 'dashed' } } }, - { type: 'value', name: '累積%', max: 100, axisLabel: { fontSize: 10 } } - ], - series: [ - { - name: '印字不良率', type: 'bar', stack: 'defect', - data: printRates, - itemStyle: { color: '#ef4444' }, - barMaxWidth: 40, - }, - { - name: '腳型不良率', type: 'bar', stack: 'defect', - data: leadRates, - itemStyle: { color: '#f59e0b' }, - barMaxWidth: 40, - }, - { - name: '累積%', type: 'line', yAxisIndex: 1, - data: cumPct, - itemStyle: { color: '#6366f1' }, - lineStyle: { width: 2 }, - symbol: 'circle', symbolSize: 6, - } - ] - }; - - chart.setOption(option, true); - - // Drill-down click handler - chart.off('click'); - chart.on('click', function(params) { - if (params.componentType === 'series' && params.name) { - setFilter(chartKey, filterField, params.name); - } - }); - } - - // ============================================================ - // Daily Trend Charts - // ============================================================ - function renderDailyTrend(trendData) { - renderTrendChart('chartPrintTrend', trendData, 'print_defect_rate', '印字不良率', '#ef4444'); - renderTrendChart('chartLeadTrend', trendData, 'lead_defect_rate', '腳型不良率', '#f59e0b'); - } - - function renderTrendChart(containerId, data, rateKey, label, color) { - if (!charts[containerId]) { - charts[containerId] = echarts.init(document.getElementById(containerId)); - } - const chart = charts[containerId]; - - if (!data || data.length === 0) { - chart.setOption({ - title: { text: '無資料', left: 'center', top: 'center', textStyle: { color: '#999', fontSize: 14 } }, - xAxis: { show: false }, yAxis: { show: false }, series: [] - }); - return; - } - - const dates = data.map(d => d.date); - const rates = data.map(d => d[rateKey]); - const qtys = data.map(d => d[rateKey === 'print_defect_rate' ? 'print_defect_qty' : 'lead_defect_qty']); - const inputs = data.map(d => d.input_qty); - - const option = { - tooltip: { - trigger: 'axis', - formatter: function(params) { - const idx = params[0].dataIndex; - const d = data[idx]; - return `${d.date}
` + - `投入數: ${d.input_qty.toLocaleString()}
` + - ` ${label}: ${d[rateKey].toFixed(4)}%
` + - `不良數: ${qtys[idx].toLocaleString()}`; - } - }, - legend: { data: [label, '投入數'], bottom: 0, textStyle: { fontSize: 11 } }, - grid: { left: 60, right: 60, top: 30, bottom: 50 }, - xAxis: { - type: 'category', data: dates, - axisLabel: { fontSize: 11, rotate: dates.length > 15 ? 35 : 0 } - }, - yAxis: [ - { type: 'value', name: '不良率(%)', axisLabel: { fontSize: 10 }, splitLine: { lineStyle: { type: 'dashed' } } }, - { type: 'value', name: '投入數', axisLabel: { fontSize: 10 } } - ], - series: [ - { - name: label, type: 'line', data: rates, - itemStyle: { color: color }, - lineStyle: { width: 2 }, - symbol: 'circle', symbolSize: 4, - areaStyle: { color: { type: 'linear', x: 0, y: 0, x2: 0, y2: 1, colorStops: [{ offset: 0, color: color + '33' }, { offset: 1, color: color + '05' }] } }, - }, - { - name: '投入數', type: 'bar', yAxisIndex: 1, - data: inputs, - itemStyle: { color: '#e0e7ff' }, - barMaxWidth: 20, - } - ] - }; - - chart.setOption(option, true); - } - - // ============================================================ - // Filter / Drill-down - // ============================================================ - function setFilter(chartKey, field, value) { - activeFilter = { dimension: chartKey, field: field, value: value }; - renderDetailTable(); - } - - window.clearFilter = function() { - activeFilter = null; - renderDetailTable(); - }; - - // ============================================================ - // Detail Table - // ============================================================ - function renderDetailTable() { - if (!analysisData) return; - - let rows = analysisData.detail; - - // Apply filter - const filterTag = document.getElementById('filterTag'); - const btnClear = document.getElementById('btnClear'); - - if (activeFilter) { - rows = rows.filter(r => (r[activeFilter.field] || '') === activeFilter.value); - document.getElementById('filterLabel').textContent = - `${activeFilter.field}: ${activeFilter.value}`; - filterTag.style.display = ''; - btnClear.style.display = ''; - } else { - filterTag.style.display = 'none'; - btnClear.style.display = 'none'; - } - - // Apply sort - if (sortState.column) { - const col = sortState.column; - const asc = sortState.asc; - rows = [...rows].sort((a, b) => { - const va = a[col] ?? ''; - const vb = b[col] ?? ''; - if (typeof va === 'number' && typeof vb === 'number') { - return asc ? va - vb : vb - va; - } - return asc ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va)); - }); - } - - document.getElementById('detailCount').textContent = `(${rows.length} 筆)`; - - const tbody = document.getElementById('detailBody'); - if (rows.length === 0) { - tbody.innerHTML = '無資料'; - return; - } - - tbody.innerHTML = rows.map(r => ` - ${r.CONTAINERNAME || ''} - ${r.PJ_TYPE || ''} - ${r.PRODUCTLINENAME || ''} - ${r.WORKFLOW || ''} - ${r.FINISHEDRUNCARD || ''} - ${r.TMTT_EQUIPMENTNAME || ''} - ${r.MOLD_EQUIPMENTNAME || ''} - ${(r.INPUT_QTY || 0).toLocaleString()} - ${r.PRINT_DEFECT_QTY || 0} - ${(r.PRINT_DEFECT_RATE || 0).toFixed(4)} - ${r.LEAD_DEFECT_QTY || 0} - ${(r.LEAD_DEFECT_RATE || 0).toFixed(4)} - `).join(''); - - // Update sort indicators - document.querySelectorAll('.sort-indicator').forEach(el => el.textContent = ''); - if (sortState.column) { - const ind = document.getElementById('sort_' + sortState.column); - if (ind) ind.textContent = sortState.asc ? '▲' : '▼'; - } - } - - window.sortTable = function(column) { - if (sortState.column === column) { - sortState.asc = !sortState.asc; - } else { - sortState.column = column; - sortState.asc = true; - } - renderDetailTable(); - }; - - // ============================================================ - // CSV Export - // ============================================================ - window.exportCsv = function() { - const startDate = document.getElementById('startDate').value; - const endDate = document.getElementById('endDate').value; - if (!startDate || !endDate) { - Toast.warning('請先查詢資料'); - return; - } - window.open(`/api/tmtt-defect/export?start_date=${startDate}&end_date=${endDate}`, '_blank'); - }; - - // ============================================================ - // Resize - // ============================================================ - window.addEventListener('resize', function() { - Object.values(charts).forEach(c => c.resize()); - }); -})(); +createApp(App).mount('#app'); diff --git a/frontend/src/tmtt-defect/style.css b/frontend/src/tmtt-defect/style.css new file mode 100644 index 0000000..4cd1da2 --- /dev/null +++ b/frontend/src/tmtt-defect/style.css @@ -0,0 +1,281 @@ +.tmtt-page { + max-width: 1680px; + margin: 0 auto; + padding: 20px; +} + +.tmtt-header { + border-radius: 12px; + padding: 22px 24px; + margin-bottom: 16px; + color: #fff; + background: linear-gradient(135deg, var(--portal-brand-start) 0%, var(--portal-brand-end) 100%); + box-shadow: var(--portal-shadow-panel); +} + +.tmtt-header h1 { + margin: 0; + font-size: 24px; +} + +.tmtt-header p { + margin: 8px 0 0; + font-size: 13px; + opacity: 0.9; +} + +.tmtt-block-title { + font-size: 14px; + font-weight: 600; +} + +.tmtt-field { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: #334155; +} + +.tmtt-field input { + height: 34px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid #cbd5e1; + background: #fff; +} + +.tmtt-field input:focus { + outline: none; + border-color: #6366f1; + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.15); +} + +.tmtt-btn { + border: none; + border-radius: 8px; + padding: 8px 14px; + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.tmtt-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.tmtt-btn-primary { + background: #4f46e5; + color: #fff; +} + +.tmtt-btn-primary:hover:not(:disabled) { + background: #4338ca; +} + +.tmtt-btn-success { + background: #16a34a; + color: #fff; +} + +.tmtt-btn-success:hover:not(:disabled) { + background: #15803d; +} + +.tmtt-btn-ghost { + background: #f1f5f9; + color: #334155; +} + +.tmtt-btn-ghost:hover { + background: #e2e8f0; +} + +.tmtt-error-banner { + margin: 0; + padding: 10px 12px; + border-radius: 8px; + border: 1px solid #fecaca; + background: #fef2f2; + color: #b91c1c; + font-size: 13px; +} + +.tmtt-kpi-grid { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; +} + +.tmtt-kpi-card { + padding: 14px; + border-radius: 10px; + border-left: 4px solid #64748b; + background: #fff; + box-shadow: var(--portal-shadow-soft); +} + +.tmtt-kpi-card.tone-danger { + border-left-color: #ef4444; +} + +.tmtt-kpi-card.tone-warning { + border-left-color: #f59e0b; +} + +.tmtt-kpi-label { + margin: 0; + font-size: 12px; + color: #64748b; +} + +.tmtt-kpi-value { + margin: 8px 0 0; + font-size: 22px; + font-weight: 700; + color: #1f2937; +} + +.tmtt-kpi-unit { + font-size: 12px; + margin-left: 4px; + color: #64748b; +} + +.tmtt-chart-grid { + display: grid; + gap: 12px; +} + +.tmtt-chart-card { + border: 1px solid #e2e8f0; + border-radius: 10px; + background: #fff; + padding: 14px; +} + +.tmtt-chart-card h3 { + margin: 0; + font-size: 14px; +} + +.tmtt-chart-canvas { + width: 100%; + height: 360px; + margin-top: 8px; +} + +.tmtt-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.tmtt-detail-count { + margin-left: 6px; + color: #64748b; + font-size: 13px; +} + +.tmtt-detail-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.tmtt-detail-table-wrap { + overflow: auto; + max-height: 520px; + border: 1px solid #e2e8f0; + border-radius: 8px; +} + +.tmtt-detail-table { + width: 100%; + min-width: 1500px; + border-collapse: collapse; + font-size: 12px; +} + +.tmtt-detail-table th, +.tmtt-detail-table td { + padding: 8px 10px; + border-bottom: 1px solid #f1f5f9; + white-space: nowrap; +} + +.tmtt-detail-table th { + position: sticky; + top: 0; + z-index: 1; + background: #f8fafc; +} + +.tmtt-sort-btn { + border: none; + padding: 0; + background: transparent; + font-size: 12px; + font-weight: 700; + color: #334155; + display: inline-flex; + align-items: center; + gap: 4px; + cursor: pointer; +} + +.tmtt-sort-indicator { + color: #64748b; + min-width: 10px; +} + +.tmtt-detail-table tbody tr:hover { + background: #f8fafc; +} + +.tmtt-detail-table .is-numeric { + text-align: right; +} + +.tmtt-detail-table .is-danger { + color: #dc2626; +} + +.tmtt-detail-table .is-warning { + color: #d97706; +} + +.tmtt-empty-row { + text-align: center; + padding: 20px; + color: #94a3b8; +} + +.tmtt-empty-state { + text-align: center; + color: #64748b; + font-size: 14px; +} + +@media (max-width: 1280px) { + .tmtt-kpi-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + .tmtt-page { + padding: 12px; + } + + .tmtt-kpi-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .tmtt-detail-header { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/frontend/src/wip-detail/App.vue b/frontend/src/wip-detail/App.vue index 776d558..eb9b99b 100644 --- a/frontend/src/wip-detail/App.vue +++ b/frontend/src/wip-detail/App.vue @@ -3,7 +3,7 @@ import { computed, reactive, ref } from 'vue'; import { apiGet } from '../core/api.js'; import { buildWipDetailQueryParams } from '../core/wip-derive.js'; -import { useAutoRefresh } from '../wip-shared/composables/useAutoRefresh.js'; +import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js'; import FilterPanel from './components/FilterPanel.vue'; import LotDetailPanel from './components/LotDetailPanel.vue'; diff --git a/frontend/src/wip-detail/components/FilterPanel.vue b/frontend/src/wip-detail/components/FilterPanel.vue index 58a9c12..d6f3e84 100644 --- a/frontend/src/wip-detail/components/FilterPanel.vue +++ b/frontend/src/wip-detail/components/FilterPanel.vue @@ -2,7 +2,7 @@ import { reactive, watch } from 'vue'; import { apiGet } from '../../core/api.js'; -import { useAutocomplete } from '../../wip-shared/composables/useAutocomplete.js'; +import { useAutocomplete } from '../../shared-composables/useAutocomplete.js'; const props = defineProps({ filters: { diff --git a/frontend/src/wip-detail/components/LotTable.vue b/frontend/src/wip-detail/components/LotTable.vue index 2c553d8..f777c7d 100644 --- a/frontend/src/wip-detail/components/LotTable.vue +++ b/frontend/src/wip-detail/components/LotTable.vue @@ -1,7 +1,7 @@ " + "
", + 200, + ) + + @app.route('/api/portal/navigation', methods=['GET']) + def portal_navigation_config(): + """Return effective drawer/page navigation config for current user.""" + admin = is_admin_logged_in() + admin_user_payload = None + if admin: + raw_admin = session.get("admin") or {} + admin_user_payload = { + "displayName": raw_admin.get("displayName"), + "username": raw_admin.get("username"), + "mail": raw_admin.get("mail"), + } + source = get_navigation_config() + drawers: list[dict] = [] + + for drawer in source: + admin_only = bool(drawer.get("admin_only", False)) + if admin_only and not admin: + continue + + pages = [] + for page in drawer.get("pages", []): + route = str(page.get("route") or "") + if not route or not _can_view_page_for_user(route, is_admin=admin): + continue + pages.append( + { + "route": route, + "name": page.get("name") or route, + "status": page.get("status", "dev"), + "order": page.get("order"), + } + ) + + if not pages: + continue + + drawers.append( + { + "id": drawer.get("id"), + "name": drawer.get("name"), + "order": drawer.get("order"), + "admin_only": admin_only, + "pages": pages, + } + ) + + return jsonify( + { + "drawers": drawers, + "is_admin": admin, + "admin_user": admin_user_payload, + "portal_spa_enabled": bool(app.config.get("PORTAL_SPA_ENABLED", False)), + } + ) + @app.route('/favicon.ico') def favicon(): """Serve favicon without 404 noise.""" diff --git a/src/mes_dashboard/config/settings.py b/src/mes_dashboard/config/settings.py index e46f3af..5ba65f5 100644 --- a/src/mes_dashboard/config/settings.py +++ b/src/mes_dashboard/config/settings.py @@ -49,6 +49,7 @@ class Config: ADMIN_EMAILS = os.getenv("ADMIN_EMAILS", "") SECRET_KEY = os.getenv("SECRET_KEY") CSRF_ENABLED = _bool_env("CSRF_ENABLED", True) + PORTAL_SPA_ENABLED = _bool_env("PORTAL_SPA_ENABLED", True) # Session configuration PERMANENT_SESSION_LIFETIME = _int_env("SESSION_LIFETIME", 28800) # 8 hours diff --git a/src/mes_dashboard/routes/health_routes.py b/src/mes_dashboard/routes/health_routes.py index ba5dafe..402d528 100644 --- a/src/mes_dashboard/routes/health_routes.py +++ b/src/mes_dashboard/routes/health_routes.py @@ -11,6 +11,7 @@ import os import threading import time from datetime import datetime, timedelta +from pathlib import Path from flask import Blueprint, current_app, jsonify, make_response from mes_dashboard.core.database import ( @@ -297,7 +298,7 @@ def get_equipment_status_cache_status() -> dict: return get_eq_cache_status() -def get_workcenter_mapping_status() -> dict: +def get_workcenter_mapping_status() -> dict: """Get current workcenter mapping cache status. Returns: @@ -306,11 +307,94 @@ def get_workcenter_mapping_status() -> dict: from mes_dashboard.services.filter_cache import get_cache_status status = get_cache_status() - return { - 'loaded': status.get('loaded', False), - 'workcenter_count': status.get('workcenter_mapping_count', 0), - 'group_count': status.get('workcenter_groups_count', 0), - } + return { + 'loaded': status.get('loaded', False), + 'workcenter_count': status.get('workcenter_mapping_count', 0), + 'group_count': status.get('workcenter_groups_count', 0), + } + + +def get_portal_shell_asset_status() -> dict: + """Validate portal-shell HTML/CSS/JS asset availability and references.""" + dist_dir = Path(current_app.static_folder or "") / "dist" + top_level_html = dist_dir / "portal-shell.html" + nested_html = dist_dir / "src" / "portal-shell" / "index.html" + js_file = dist_dir / "portal-shell.js" + shell_css_file = dist_dir / "portal-shell.css" + tailwind_css_file = dist_dir / "tailwind.css" + + html_file: Path | None = None + if top_level_html.exists(): + html_file = top_level_html + elif nested_html.exists(): + html_file = nested_html + + checks = { + "portal_shell_html": { + "exists": html_file is not None, + "path": str(html_file) if html_file is not None else None, + "source": "top-level" if html_file == top_level_html else "nested" if html_file == nested_html else None, + }, + "portal_shell_js": { + "exists": js_file.exists(), + "path": str(js_file), + }, + "portal_shell_css": { + "exists": shell_css_file.exists(), + "path": str(shell_css_file), + }, + "tailwind_css": { + "exists": tailwind_css_file.exists(), + "path": str(tailwind_css_file), + }, + "html_references": { + "portal_shell_js": False, + "portal_shell_css": False, + "tailwind_css": False, + }, + } + + errors: list[str] = [] + warnings: list[str] = [] + + if html_file is None: + errors.append("portal-shell HTML not found (portal-shell.html or src/portal-shell/index.html)") + else: + try: + html_content = html_file.read_text(encoding="utf-8") + except OSError as exc: + errors.append(f"failed to read shell html: {exc}") + else: + checks["html_references"]["portal_shell_js"] = "/static/dist/portal-shell.js" in html_content + checks["html_references"]["portal_shell_css"] = "/static/dist/portal-shell.css" in html_content + checks["html_references"]["tailwind_css"] = "/static/dist/tailwind.css" in html_content + + if not checks["html_references"]["portal_shell_js"]: + errors.append("shell html missing reference: /static/dist/portal-shell.js") + if not checks["html_references"]["portal_shell_css"]: + errors.append("shell html missing reference: /static/dist/portal-shell.css") + if not checks["html_references"]["tailwind_css"]: + errors.append("shell html missing reference: /static/dist/tailwind.css") + + if not checks["portal_shell_js"]["exists"]: + errors.append("asset missing: static/dist/portal-shell.js") + if not checks["portal_shell_css"]["exists"]: + errors.append("asset missing: static/dist/portal-shell.css") + if not checks["tailwind_css"]["exists"]: + errors.append("asset missing: static/dist/tailwind.css") + + if checks["portal_shell_html"]["source"] == "nested": + warnings.append("using nested shell html source (dist/src/portal-shell/index.html)") + + healthy = len(errors) == 0 + return { + "status": "healthy" if healthy else "unhealthy", + "route": "/portal-shell", + "checks": checks, + "errors": errors, + "warnings": warnings, + "http_code": 200 if healthy else 503, + } @health_bp.route('/health', methods=['GET']) @@ -454,7 +538,7 @@ def health_check(): return _build_health_response(response, http_code) -@health_bp.route('/health/deep', methods=['GET']) +@health_bp.route('/health/deep', methods=['GET']) def deep_health_check(): """Deep health check endpoint with detailed metrics. @@ -634,3 +718,11 @@ def deep_health_check(): _set_health_memo("deep", response, http_code) return _build_health_response(response, http_code) + + +@health_bp.route('/health/frontend-shell', methods=['GET']) +def frontend_shell_health_check(): + """Frontend shell health endpoint for CSS/JS rendering readiness.""" + result = get_portal_shell_asset_status() + http_code = int(result.pop("http_code", 500)) + return _build_health_response(result, http_code) diff --git a/src/mes_dashboard/services/navigation_contract.py b/src/mes_dashboard/services/navigation_contract.py new file mode 100644 index 0000000..f71243d --- /dev/null +++ b/src/mes_dashboard/services/navigation_contract.py @@ -0,0 +1,119 @@ +# -*- coding: utf-8 -*- +"""Navigation contract helpers for portal migration safety checks.""" + +from __future__ import annotations + +from typing import Any + + +VALID_PAGE_STATUS = {"released", "dev"} + + +def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _can_view(status: str | None, is_admin: bool) -> bool: + if is_admin: + return True + return status == "released" + + +def compute_drawer_visibility(data: dict[str, Any], is_admin: bool) -> list[dict[str, Any]]: + """Compute effective drawer visibility using current registry semantics.""" + drawers = sorted( + [dict(d) for d in data.get("drawers", [])], + key=lambda d: (_safe_int(d.get("order"), 9999), str(d.get("name", ""))), + ) + pages = [dict(p) for p in data.get("pages", [])] + + pages_by_drawer: dict[str, list[dict[str, Any]]] = {} + for page in pages: + drawer_id = page.get("drawer_id") + if not drawer_id: + continue + pages_by_drawer.setdefault(str(drawer_id), []).append(page) + + visible_drawers: list[dict[str, Any]] = [] + for drawer in drawers: + if bool(drawer.get("admin_only", False)) and not is_admin: + continue + + drawer_id = str(drawer.get("id")) + drawer_pages = sorted( + pages_by_drawer.get(drawer_id, []), + key=lambda p: (_safe_int(p.get("order"), 9999), str(p.get("name") or p.get("route", ""))), + ) + + visible_pages = [ + { + "route": page.get("route"), + "name": page.get("name"), + "status": page.get("status"), + "order": page.get("order"), + } + for page in drawer_pages + if _can_view(page.get("status"), is_admin) + ] + + if not visible_pages: + continue + + visible_drawers.append( + { + "id": drawer_id, + "name": drawer.get("name"), + "order": drawer.get("order"), + "admin_only": bool(drawer.get("admin_only", False)), + "pages": visible_pages, + } + ) + return visible_drawers + + +def validate_drawer_page_contract(data: dict[str, Any]) -> list[str]: + """Validate drawer/page assignments and ordering constraints.""" + errors: list[str] = [] + drawers = data.get("drawers", []) + pages = data.get("pages", []) + + seen_drawers: set[str] = set() + for drawer in drawers: + drawer_id = str(drawer.get("id", "")).strip() + if not drawer_id: + errors.append("drawer.id is required") + continue + if drawer_id in seen_drawers: + errors.append(f"duplicate drawer id: {drawer_id}") + seen_drawers.add(drawer_id) + + order = drawer.get("order") + if order is not None and _safe_int(order, 0) < 1: + errors.append(f"drawer.order must be >= 1: {drawer_id}") + + seen_routes: set[str] = set() + for page in pages: + route = str(page.get("route", "")).strip() + if not route: + errors.append("page.route is required") + continue + if route in seen_routes: + errors.append(f"duplicate page route: {route}") + seen_routes.add(route) + + status = str(page.get("status", "dev")) + if status not in VALID_PAGE_STATUS: + errors.append(f"invalid page status for {route}: {status}") + + drawer_id = page.get("drawer_id") + if drawer_id is not None and str(drawer_id) not in seen_drawers: + errors.append(f"page references missing drawer: route={route}, drawer_id={drawer_id}") + + order = page.get("order") + if order is not None and _safe_int(order, 0) < 1: + errors.append(f"page.order must be >= 1: {route}") + + return sorted(set(errors)) diff --git a/src/mes_dashboard/services/page_registry.py b/src/mes_dashboard/services/page_registry.py index eb05814..9fb982c 100644 --- a/src/mes_dashboard/services/page_registry.py +++ b/src/mes_dashboard/services/page_registry.py @@ -50,17 +50,6 @@ LEGACY_NAV_ASSIGNMENTS = { }, } -LEGACY_FRAME_ID_MAP = { - "/wip-overview": "wipOverviewFrame", - "/resource": "resourceFrame", - "/tables": "tableFrame", - "/excel-query": "excelQueryFrame", - "/resource-history": "resourceHistoryFrame", - "/job-query": "jobQueryFrame", - "/query-tool": "queryToolFrame", - "/tmtt-defect": "tmttDefectFrame", -} - class DrawerError(Exception): """Base drawer management error.""" @@ -238,17 +227,6 @@ def _generate_drawer_id(name: str, existing_ids: set[str]) -> str: return candidate -def _route_to_frame_id(route: str) -> str: - if route in LEGACY_FRAME_ID_MAP: - return LEGACY_FRAME_ID_MAP[route] - - parts = [part for part in re.split(r"[\/_-]+", route.strip("/")) if part] - if not parts: - return "homeFrame" - camel = parts[0].lower() + "".join(part.capitalize() for part in parts[1:]) - return f"{camel}Frame" - - def _sorted_drawers(drawers: list[dict]) -> list[dict]: return sorted( drawers, @@ -487,15 +465,12 @@ def get_navigation_config() -> list[dict]: continue drawer = grouped[drawer_id] - use_tool_frame = bool(drawer["admin_only"]) drawer["pages"].append( { "route": route, "name": page.get("name") or route, "status": page.get("status", "dev"), "order": _safe_int(page.get("order"), 9999), - "frame_id": "toolFrame" if use_tool_frame else _route_to_frame_id(route), - "tool_src": route if use_tool_frame else None, } ) diff --git a/src/mes_dashboard/templates/portal.html b/src/mes_dashboard/templates/portal.html index 983ee96..f72d429 100644 --- a/src/mes_dashboard/templates/portal.html +++ b/src/mes_dashboard/templates/portal.html @@ -53,6 +53,7 @@ display: flex; align-items: center; gap: 12px; + position: relative; } .admin-status { @@ -79,7 +80,6 @@ opacity: 0.9; } - /* Health Status Indicator */ .health-status { display: flex; align-items: center; @@ -133,7 +133,6 @@ opacity: 0.9; } - /* Health Popup */ .health-popup { position: absolute; top: 100%; @@ -208,14 +207,6 @@ margin-bottom: 4px; } - .header-right { - display: flex; - align-items: center; - gap: 12px; - position: relative; - } - - /* Sidebar + Panel Layout */ .main-layout { display: flex; gap: 12px; @@ -235,78 +226,54 @@ top: 20px; } - .sidebar-group-title { - padding: 10px 14px 6px; - font-size: 11px; - font-weight: 700; - color: #94a3b8; - text-transform: uppercase; - letter-spacing: 0.5px; - } - - .sidebar-group-title:not(:first-child) { - border-top: 1px solid #e2e8f0; - margin-top: 4px; - padding-top: 12px; - } - - .sidebar-item { - display: block; - width: 100%; - border: none; - background: none; - text-align: left; - padding: 8px 14px; - font-size: 13px; - color: #475569; - cursor: pointer; - transition: all 0.15s ease; - text-decoration: none; - font-family: inherit; - } - - .sidebar-item:hover { - background: #f1f5f9; - color: #667eea; - } - - .sidebar-item.active { - background: #eef2ff; - color: #667eea; - font-weight: 600; - border-right: 3px solid #667eea; - } - .panel { flex: 1; background: white; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.08); - overflow: hidden; min-width: 0; + padding: 24px; } - .panel iframe { - width: 100%; - border: none; - display: none; + .panel h2 { + font-size: 20px; + margin-bottom: 10px; + color: #1f2937; } - .panel iframe.active { - display: block; + .panel p { + font-size: 14px; + color: #64748b; + line-height: 1.6; + margin-bottom: 8px; + } + + .route-status { + border-radius: 8px; + border: 1px solid #e2e8f0; + background: #f8fafc; + padding: 10px 12px; + margin-top: 12px; + color: #475569; + } + + .route-status.loading { + border-color: #c7d2fe; + background: #eef2ff; + color: #4338ca; + font-weight: 600; } {% endblock %} {% block content %} -
+

MES 報表入口

統一入口:WIP 即時看板、設備即時概況與數據表查詢工具

-
檢查中... @@ -365,11 +332,12 @@ {% for page in drawer.pages %} {% if can_view_page(page.route) %} - + {{ page.name }} {% endif %} {% endfor %} {% endif %} @@ -380,25 +348,12 @@ {% endif %} -
- {% set frame_ns = namespace(has_tool_pages=false) %} - {% for drawer in drawers | default([]) %} - {% if not drawer.admin_only or is_admin %} - {% for page in drawer.pages %} - {% if can_view_page(page.route) %} - {% if page.tool_src %} - {% set frame_ns.has_tool_pages = true %} - {% else %} - - {% endif %} - {% endif %} - {% endfor %} - {% endif %} - {% endfor %} - {% if is_admin and frame_ns.has_tool_pages %} - - {% endif %} -
+
+

路由導覽模式

+

抽屜已與 iframe 載入機制解耦,點選左側項目將直接進入既有路由頁面。

+

目前仍保留抽屜分組、排序與 admin 權限控制規則。

+

請從左側抽屜選擇頁面。

+
{% endblock %} @@ -409,191 +364,30 @@ {% else %} {% endif %} {% endblock %} diff --git a/src/mes_dashboard/templates/tmtt_defect.html b/src/mes_dashboard/templates/tmtt_defect.html index 0d0d57a..c62b127 100644 --- a/src/mes_dashboard/templates/tmtt_defect.html +++ b/src/mes_dashboard/templates/tmtt_defect.html @@ -3,269 +3,17 @@ {% block title %}TMTT 印字腳型不良分析{% endblock %} {% block head_extra %} - - + {% set tmtt_defect_css = frontend_asset('tmtt-defect.css') %} + {% if tmtt_defect_css %} + + {% endif %} {% endblock %} {% block content %} - - -
- -
- - - - - -
- - - - - - - - - - - -
-
📊
-

請選擇日期範圍後點擊「查詢」

-
-
+
{% endblock %} {% block scripts %} {% set tmtt_defect_js = frontend_asset('tmtt-defect.js') %} {% endblock %} - diff --git a/tests/e2e/test_global_connection.py b/tests/e2e/test_global_connection.py index f7ed787..6fd89e3 100644 --- a/tests/e2e/test_global_connection.py +++ b/tests/e2e/test_global_connection.py @@ -23,26 +23,25 @@ class TestPortalPage: # Wait for page to load expect(page.locator('h1')).to_contain_text('MES 報表入口') - def test_portal_has_all_tabs(self, page: Page, app_server: str): - """Portal should have all navigation tabs.""" + def test_portal_has_all_sidebar_routes(self, page: Page, app_server: str): + """Portal should expose route-based sidebar entries.""" page.goto(app_server) - # Check released tabs exist - expect(page.locator('.tab:has-text("WIP 即時概況")')).to_be_visible() - expect(page.locator('.tab:has-text("設備即時概況")')).to_be_visible() - expect(page.locator('.tab:has-text("設備歷史績效")')).to_be_visible() - expect(page.locator('.tab:has-text("設備維修查詢")')).to_be_visible() - expect(page.locator('.tab:has-text("批次追蹤工具")')).to_be_visible() - - def test_portal_tab_switching(self, page: Page, app_server: str): - """Portal tabs should switch iframe content.""" + expect(page.locator('.sidebar-item:has-text("WIP 即時概況")')).to_be_visible() + expect(page.locator('.sidebar-item:has-text("設備即時概況")')).to_be_visible() + expect(page.locator('.sidebar-item:has-text("設備歷史績效")')).to_be_visible() + expect(page.locator('.sidebar-item:has-text("設備維修查詢")')).to_be_visible() + + def test_portal_sidebar_navigation_uses_direct_routes(self, page: Page, app_server: str): + """Sidebar click should navigate to direct route without iframe switching.""" page.goto(app_server) - # Click on a different tab - page.locator('.tab:has-text("設備即時概況")').click() - - # Verify the tab is active - expect(page.locator('.tab:has-text("設備即時概況")')).to_have_class(re.compile(r'active')) + first_route = page.locator('.sidebar-item[data-route]').first + expect(first_route).to_be_visible() + target_href = first_route.get_attribute('href') + assert target_href and target_href.startswith('/'), "sidebar route href missing" + first_route.click() + expect(page).to_have_url(re.compile(f".*{re.escape(target_href)}$")) def test_portal_health_popup_clickable(self, page: Page, app_server: str): """Health status pill should toggle popup visibility on click.""" diff --git a/tests/stress/test_frontend_stress.py b/tests/stress/test_frontend_stress.py index 59ee3a1..8e9ce90 100644 --- a/tests/stress/test_frontend_stress.py +++ b/tests/stress/test_frontend_stress.py @@ -12,7 +12,6 @@ Run with: pytest tests/stress/test_frontend_stress.py -v -s import pytest import time -import re import requests from urllib.parse import quote from playwright.sync_api import Page, expect @@ -258,51 +257,55 @@ class TestMesApiStress: assert total_resolved >= 5, f"Too many unresolved requests" -@pytest.mark.stress +@pytest.mark.stress class TestPageNavigationStress: - """Stress tests for rapid page navigation.""" + """Stress tests for rapid route navigation.""" - def test_rapid_tab_switching(self, page: Page, app_server: str): - """Test rapid tab switching in portal.""" + def test_rapid_route_switching(self, page: Page, app_server: str): + """Rapid direct-route switching should remain responsive.""" page.goto(app_server, wait_until='domcontentloaded', timeout=30000) - sidebar_items = page.locator('.sidebar-item[data-target]') + sidebar_items = page.locator('.sidebar-item[data-route]') expect(sidebar_items.first).to_be_visible() item_count = sidebar_items.count() - assert item_count >= 1, "No portal sidebar pages available for navigation stress test" + assert item_count >= 1, "No portal sidebar routes available for stress test" + + route_hrefs = [] + checked = min(item_count, 5) + for idx in range(checked): + href = sidebar_items.nth(idx).get_attribute('href') + if href and href.startswith('/'): + route_hrefs.append(href) + + assert route_hrefs, "Unable to resolve route hrefs from sidebar" + + js_errors = [] + page.on("pageerror", lambda error: js_errors.append(str(error))) start_time = time.time() - - # Rapidly switch pages 20 times for i in range(20): - item = sidebar_items.nth(i % item_count) - item.click() - page.wait_for_timeout(50) + page.goto(f"{app_server}{route_hrefs[i % len(route_hrefs)]}", wait_until='domcontentloaded', timeout=60000) + expect(page.locator('body')).to_be_visible() + page.wait_for_timeout(80) switch_time = time.time() - start_time - print(f"\n 20 sidebar switches in {switch_time:.3f}s") + print(f"\n 20 route switches in {switch_time:.3f}s") + assert len(js_errors) == 0, f"JS errors detected during route switching: {js_errors[:3]}" - # Page should still be responsive - expect(page.locator('h1')).to_contain_text('MES 報表入口') - print(" Portal remained stable") - - def test_portal_iframe_stress(self, page: Page, app_server: str): - """Test portal remains responsive with iframe loading.""" + def test_portal_navigation_contract_without_iframe(self, page: Page, app_server: str): + """Portal sidebar should expose route metadata and no iframe DOM.""" page.goto(app_server, wait_until='domcontentloaded', timeout=30000) - sidebar_items = page.locator('.sidebar-item[data-target]') + sidebar_items = page.locator('.sidebar-item[data-route]') expect(sidebar_items.first).to_be_visible() - item_count = sidebar_items.count() - assert item_count >= 1, "No portal sidebar pages available for iframe stress test" + assert sidebar_items.count() >= 1, "No route sidebar items found" - checked = min(item_count, 4) - for idx in range(checked): - item = sidebar_items.nth(idx) - item.click() - page.wait_for_timeout(200) + iframe_count = page.locator('iframe').count() + assert iframe_count == 0, "Portal should not render iframe after migration" - # Verify clicked item is active - expect(item).to_have_class(re.compile(r'active')) + for idx in range(min(sidebar_items.count(), 3)): + href = sidebar_items.nth(idx).get_attribute('href') + assert href and href.startswith('/'), f"Invalid sidebar href: {href}" - print(f"\n All {checked} sidebar pages clickable and responsive") + print("\n Portal route sidebar contract verified without iframe") @pytest.mark.stress diff --git a/tests/test_app_factory.py b/tests/test_app_factory.py index 13b57b8..ba1fea8 100644 --- a/tests/test_app_factory.py +++ b/tests/test_app_factory.py @@ -49,6 +49,7 @@ class AppFactoryTests(unittest.TestCase): rules = {rule.rule for rule in app.url_map.iter_rules()} expected = { "/", + "/portal-shell", "/tables", "/resource", "/wip-overview", @@ -69,6 +70,7 @@ class AppFactoryTests(unittest.TestCase): "/api/wip/meta/packages", "/api/resource/status/summary", "/api/dashboard/kpi", + "/api/portal/navigation", "/api/excel-query/upload", "/api/query-tool/resolve", "/api/tmtt-defect/analysis", @@ -76,6 +78,54 @@ class AppFactoryTests(unittest.TestCase): missing = expected - rules self.assertFalse(missing, f"Missing routes: {sorted(missing)}") + def test_portal_spa_flag_default_enabled(self): + old = os.environ.pop("PORTAL_SPA_ENABLED", None) + try: + app = create_app("testing") + self.assertTrue(app.config.get("PORTAL_SPA_ENABLED")) + + client = app.test_client() + response = client.get("/", follow_redirects=False) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers.get("Location"), "/portal-shell") + finally: + if old is not None: + os.environ["PORTAL_SPA_ENABLED"] = old + + def test_portal_spa_flag_disabled_via_env(self): + old = os.environ.get("PORTAL_SPA_ENABLED") + os.environ["PORTAL_SPA_ENABLED"] = "false" + try: + app = create_app("testing") + self.assertFalse(app.config.get("PORTAL_SPA_ENABLED")) + + client = app.test_client() + response = client.get("/") + html = response.data.decode("utf-8") + self.assertIn('data-portal-spa-enabled="false"', html) + finally: + if old is None: + os.environ.pop("PORTAL_SPA_ENABLED", None) + else: + os.environ["PORTAL_SPA_ENABLED"] = old + + def test_portal_spa_flag_enabled_via_env(self): + old = os.environ.get("PORTAL_SPA_ENABLED") + os.environ["PORTAL_SPA_ENABLED"] = "true" + try: + app = create_app("testing") + self.assertTrue(app.config.get("PORTAL_SPA_ENABLED")) + + client = app.test_client() + response = client.get("/", follow_redirects=False) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.headers.get("Location"), "/portal-shell") + finally: + if old is None: + os.environ.pop("PORTAL_SPA_ENABLED", None) + else: + os.environ["PORTAL_SPA_ENABLED"] = old + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cutover_gates.py b/tests/test_cutover_gates.py new file mode 100644 index 0000000..cf1af65 --- /dev/null +++ b/tests/test_cutover_gates.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +"""Cutover gate enforcement tests for portal no-iframe migration.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from mes_dashboard.app import create_app + +ROOT = Path(__file__).resolve().parents[1] +BASELINE_VISIBILITY_FILE = ROOT / "docs" / "migration" / "portal-no-iframe" / "baseline_drawer_visibility.json" +BASELINE_API_FILE = ROOT / "docs" / "migration" / "portal-no-iframe" / "baseline_api_payload_contracts.json" +ROLLBACK_RUNBOOK = ROOT / "docs" / "migration" / "portal-no-iframe" / "rollback_rehearsal_runbook.md" +ROLLBACK_STRATEGY = ROOT / "docs" / "migration" / "portal-no-iframe" / "rollback_strategy_shell_and_wrappers.md" +LEGACY_REWRITE_SMOKE_CHECKLIST = ROOT / "docs" / "migration" / "portal-no-iframe" / "legacy_rewrite_smoke_checklists.md" +STRESS_SUITE = ROOT / "tests" / "stress" / "test_frontend_stress.py" + + +def _login_as_admin(client) -> None: + with client.session_transaction() as sess: + sess["admin"] = {"displayName": "Admin", "employeeNo": "A001"} + + +def _route_set(drawers: list[dict]) -> set[str]: + return { + str(page.get("route")) + for drawer in drawers + for page in drawer.get("pages", []) + if page.get("route") + } + + +def test_g1_route_availability_gate_p0_routes_are_2xx_or_3xx(): + app = create_app("testing") + app.config["TESTING"] = True + client = app.test_client() + + p0_routes = [ + "/", + "/portal-shell", + "/api/portal/navigation", + "/wip-overview", + "/resource", + "/qc-gate", + ] + + statuses = [client.get(route).status_code for route in p0_routes] + assert all(200 <= status < 400 for status in statuses), statuses + + +def test_g2_drawer_parity_gate_matches_baseline_for_admin_and_non_admin(): + baseline = json.loads(BASELINE_VISIBILITY_FILE.read_text(encoding="utf-8")) + + app = create_app("testing") + app.config["TESTING"] = True + + non_admin_client = app.test_client() + non_admin_payload = json.loads(non_admin_client.get("/api/portal/navigation").data.decode("utf-8")) + + admin_client = app.test_client() + _login_as_admin(admin_client) + admin_payload = json.loads(admin_client.get("/api/portal/navigation").data.decode("utf-8")) + + assert _route_set(non_admin_payload["drawers"]) == _route_set(baseline["non_admin"]) + assert _route_set(admin_payload["drawers"]) == _route_set(baseline["admin"]) + + +def test_g3_workflow_smoke_gate_critical_routes_reachable_for_admin(): + app = create_app("testing") + app.config["TESTING"] = True + client = app.test_client() + _login_as_admin(client) + + smoke_routes = [ + "/", + "/wip-overview", + "/wip-detail?workcenter=TMTT&type=PJA3460&status=queue", + "/hold-detail?reason=YieldLimit", + "/hold-overview", + "/hold-history", + "/resource", + "/resource-history?start_date=2026-01-01&end_date=2026-01-31", + "/qc-gate", + "/job-query", + "/excel-query", + "/query-tool", + "/tmtt-defect", + ] + + statuses = [client.get(route).status_code for route in smoke_routes] + assert all(200 <= status < 400 for status in statuses), statuses + + +def test_g4_client_stability_gate_assertion_present_in_stress_suite(): + content = STRESS_SUITE.read_text(encoding="utf-8") + assert 'page.on("pageerror"' in content + assert 'assert len(js_errors) == 0' in content + + +def test_g5_data_contract_gate_baseline_keys_are_defined_for_registered_apis(): + baseline = json.loads(BASELINE_API_FILE.read_text(encoding="utf-8")) + app = create_app("testing") + app.config["TESTING"] = True + registered_routes = {rule.rule for rule in app.url_map.iter_rules()} + + for api_route, contract in baseline.get("apis", {}).items(): + assert api_route in registered_routes, f"Missing API route in app map: {api_route}" + required_keys = contract.get("required_keys", []) + assert required_keys, f"No required_keys defined for {api_route}" + assert all(isinstance(key, str) and key for key in required_keys) + + +def test_g7_rollback_readiness_gate_has_15_minute_slo_and_operator_steps(): + rehearsal = ROLLBACK_RUNBOOK.read_text(encoding="utf-8") + strategy = ROLLBACK_STRATEGY.read_text(encoding="utf-8") + + assert "15" in rehearsal + assert "PORTAL_SPA_ENABLED=false" in strategy + assert "/api/portal/navigation" in strategy + + +def test_legacy_rewrite_smoke_checklist_covers_all_wrapped_pages(): + content = LEGACY_REWRITE_SMOKE_CHECKLIST.read_text(encoding="utf-8") + + assert "tmtt-defect" in content + assert "job-query" in content + assert "excel-query" in content + assert "query-tool" in content + assert "SMOKE-01" in content diff --git a/tests/test_health_routes.py b/tests/test_health_routes.py index fed48a4..b3e439e 100644 --- a/tests/test_health_routes.py +++ b/tests/test_health_routes.py @@ -114,3 +114,117 @@ def test_health_route_uses_internal_memoization( assert response1.status_code == 200 assert response2.status_code == 200 assert mock_db.call_count == 1 + + +@patch('mes_dashboard.routes.health_routes.get_portal_shell_asset_status') +def test_frontend_shell_health_endpoint_healthy(mock_status): + mock_status.return_value = { + "status": "healthy", + "route": "/portal-shell", + "checks": { + "portal_shell_html": {"exists": True}, + "portal_shell_js": {"exists": True}, + "portal_shell_css": {"exists": True}, + "tailwind_css": {"exists": True}, + "html_references": { + "portal_shell_js": True, + "portal_shell_css": True, + "tailwind_css": True, + }, + }, + "errors": [], + "warnings": [], + "http_code": 200, + } + + response = _client().get('/health/frontend-shell') + assert response.status_code == 200 + payload = response.get_json() + assert payload["status"] == "healthy" + assert payload["checks"]["portal_shell_css"]["exists"] is True + + +@patch('mes_dashboard.routes.health_routes.get_portal_shell_asset_status') +def test_frontend_shell_health_endpoint_unhealthy(mock_status): + mock_status.return_value = { + "status": "unhealthy", + "route": "/portal-shell", + "checks": { + "portal_shell_html": {"exists": False}, + "portal_shell_js": {"exists": False}, + "portal_shell_css": {"exists": False}, + "tailwind_css": {"exists": False}, + "html_references": { + "portal_shell_js": False, + "portal_shell_css": False, + "tailwind_css": False, + }, + }, + "errors": ["asset missing: static/dist/portal-shell.css"], + "warnings": [], + "http_code": 503, + } + + response = _client().get('/health/frontend-shell') + assert response.status_code == 503 + payload = response.get_json() + assert payload["status"] == "unhealthy" + assert any("portal-shell.css" in error for error in payload.get("errors", [])) + + +def test_get_portal_shell_asset_status_reports_nested_html_as_healthy(tmp_path): + from mes_dashboard.routes.health_routes import get_portal_shell_asset_status + + static_dir = tmp_path / "static" + dist_dir = static_dir / "dist" + nested_dir = dist_dir / "src" / "portal-shell" + nested_dir.mkdir(parents=True, exist_ok=True) + + (nested_dir / "index.html").write_text( + "" + "" + "" + "" + "
", + encoding="utf-8", + ) + (dist_dir / "portal-shell.js").write_text("console.log('ok');", encoding="utf-8") + (dist_dir / "portal-shell.css").write_text(".shell{}", encoding="utf-8") + (dist_dir / "tailwind.css").write_text(".tw{}", encoding="utf-8") + + app = create_app("testing") + app.config["TESTING"] = True + app.static_folder = str(static_dir) + + with app.app_context(): + result = get_portal_shell_asset_status() + + assert result["status"] == "healthy" + assert result["checks"]["portal_shell_html"]["source"] == "nested" + assert result["checks"]["html_references"]["portal_shell_css"] is True + + +def test_get_portal_shell_asset_status_reports_missing_css_as_unhealthy(tmp_path): + from mes_dashboard.routes.health_routes import get_portal_shell_asset_status + + static_dir = tmp_path / "static" + dist_dir = static_dir / "dist" + dist_dir.mkdir(parents=True, exist_ok=True) + + (dist_dir / "portal-shell.html").write_text( + "" + "" + "
", + encoding="utf-8", + ) + (dist_dir / "portal-shell.js").write_text("console.log('ok');", encoding="utf-8") + + app = create_app("testing") + app.config["TESTING"] = True + app.static_folder = str(static_dir) + + with app.app_context(): + result = get_portal_shell_asset_status() + + assert result["status"] == "unhealthy" + assert any("portal-shell.css" in error for error in result["errors"]) diff --git a/tests/test_navigation_contract.py b/tests/test_navigation_contract.py new file mode 100644 index 0000000..b29ece9 --- /dev/null +++ b/tests/test_navigation_contract.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +"""Tests for portal navigation migration contract helpers.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from mes_dashboard.services.navigation_contract import ( + compute_drawer_visibility, + validate_drawer_page_contract, +) + + +ROOT = Path(__file__).resolve().parent.parent +PAGE_STATUS_FILE = ROOT / "data" / "page_status.json" +BASELINE_VISIBILITY_FILE = ROOT / "docs" / "migration" / "portal-no-iframe" / "baseline_drawer_visibility.json" + + +def test_current_page_status_contract_has_no_validation_errors(): + payload = json.loads(PAGE_STATUS_FILE.read_text(encoding="utf-8")) + errors = validate_drawer_page_contract(payload) + assert errors == [] + + +def test_baseline_visibility_matches_computed_current_state(): + payload = json.loads(PAGE_STATUS_FILE.read_text(encoding="utf-8")) + baseline = json.loads(BASELINE_VISIBILITY_FILE.read_text(encoding="utf-8")) + + assert baseline["admin"] == compute_drawer_visibility(payload, is_admin=True) + assert baseline["non_admin"] == compute_drawer_visibility(payload, is_admin=False) diff --git a/tests/test_page_registry.py b/tests/test_page_registry.py index c9d39b3..ab0744a 100644 --- a/tests/test_page_registry.py +++ b/tests/test_page_registry.py @@ -170,16 +170,16 @@ class TestNavigationConfig: reports = next(drawer for drawer in nav if drawer["id"] == "reports") assert [page["route"] for page in reports["pages"]] == ["/wip-overview"] - assert reports["pages"][0]["frame_id"] == "wipOverviewFrame" - assert reports["pages"][0]["tool_src"] is None + assert "frame_id" not in reports["pages"][0] + assert "tool_src" not in reports["pages"][0] queries = next(drawer for drawer in nav if drawer["id"] == "queries") assert queries["pages"][0]["route"] == "/tables" assert queries["pages"][-1]["route"] == "/dev-page" dev_tools = next(drawer for drawer in nav if drawer["id"] == "dev-tools") - assert all(page["frame_id"] == "toolFrame" for page in dev_tools["pages"]) - assert dev_tools["pages"][0]["tool_src"] == "/admin/pages" + assert all("frame_id" not in page for page in dev_tools["pages"]) + assert all("tool_src" not in page for page in dev_tools["pages"]) class TestIsApiPublic: diff --git a/tests/test_portal_shell_routes.py b/tests/test_portal_shell_routes.py new file mode 100644 index 0000000..08d39c5 --- /dev/null +++ b/tests/test_portal_shell_routes.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""Tests for portal shell routes and navigation API.""" + +from __future__ import annotations + +import json + +from mes_dashboard.app import create_app + + +def _login_as_admin(client) -> None: + with client.session_transaction() as sess: + sess["admin"] = {"displayName": "Admin", "employeeNo": "A001"} + + +def test_portal_shell_fallback_html_served_when_dist_missing(monkeypatch): + app = create_app("testing") + app.config["TESTING"] = True + + # Force fallback path by simulating missing dist file. + monkeypatch.setattr("os.path.exists", lambda *_args, **_kwargs: False) + + client = app.test_client() + response = client.get("/portal-shell") + assert response.status_code == 200 + html = response.data.decode("utf-8") + assert "/static/dist/portal-shell.js" in html + assert "/static/dist/portal-shell.css" in html + assert "/static/dist/tailwind.css" in html + + +def test_portal_shell_uses_nested_dist_html_when_top_level_missing(monkeypatch): + app = create_app("testing") + app.config["TESTING"] = True + + def fake_exists(path: str) -> bool: + if path.endswith("/dist/portal-shell.html"): + return False + return path.endswith("/dist/src/portal-shell/index.html") + + monkeypatch.setattr("os.path.exists", fake_exists) + + client = app.test_client() + response = client.get("/portal-shell") + assert response.status_code == 200 + html = response.data.decode("utf-8") + assert "/static/dist/portal-shell.js" in html + assert "/static/dist/portal-shell.css" in html + assert "/static/dist/tailwind.css" in html + + +def test_portal_navigation_non_admin_visibility_matches_release_only(): + app = create_app("testing") + app.config["TESTING"] = True + client = app.test_client() + + response = client.get("/api/portal/navigation") + assert response.status_code == 200 + payload = json.loads(response.data.decode("utf-8")) + assert payload["is_admin"] is False + assert payload["admin_user"] is None + + all_routes = { + page["route"] + for drawer in payload["drawers"] + for page in drawer["pages"] + } + + # Non-admin baseline from current config. + assert "/wip-overview" in all_routes + assert "/resource" in all_routes + assert "/qc-gate" in all_routes + assert "/resource-history" in all_routes + assert "/job-query" in all_routes + assert "/admin/pages" not in all_routes + assert "/excel-query" not in all_routes + + +def test_portal_navigation_admin_includes_admin_drawer_routes(): + app = create_app("testing") + app.config["TESTING"] = True + client = app.test_client() + _login_as_admin(client) + + response = client.get("/api/portal/navigation") + assert response.status_code == 200 + payload = json.loads(response.data.decode("utf-8")) + assert payload["is_admin"] is True + assert payload["admin_user"]["displayName"] == "Admin" + + all_routes = { + page["route"] + for drawer in payload["drawers"] + for page in drawer["pages"] + } + assert "/admin/pages" in all_routes + assert "/admin/performance" in all_routes + assert "/excel-query" in all_routes + + +def test_wrapper_telemetry_endpoint_removed_after_wrapper_decommission(): + app = create_app("testing") + app.config["TESTING"] = True + client = app.test_client() + + response = client.post( + "/api/portal/wrapper-telemetry", + json={ + "route": "/job-query", + "event_type": "wrapper_loaded", + }, + ) + assert response.status_code == 404 + + +def test_navigation_drawer_and_page_order_deterministic_non_admin(): + app = create_app("testing") + app.config["TESTING"] = True + client = app.test_client() + + response = client.get("/api/portal/navigation") + assert response.status_code == 200 + payload = json.loads(response.data.decode("utf-8")) + + drawer_ids = [drawer["id"] for drawer in payload["drawers"]] + assert drawer_ids == ["reports", "drawer-2", "drawer"] + + reports_routes = [page["route"] for page in payload["drawers"][0]["pages"]] + assert reports_routes == ["/wip-overview", "/resource", "/qc-gate"] + + +def test_navigation_mixed_release_dev_visibility_admin_vs_non_admin(): + app = create_app("testing") + app.config["TESTING"] = True + + non_admin_client = app.test_client() + non_admin_resp = non_admin_client.get("/api/portal/navigation") + assert non_admin_resp.status_code == 200 + non_admin_payload = json.loads(non_admin_resp.data.decode("utf-8")) + non_admin_routes = { + page["route"] + for drawer in non_admin_payload["drawers"] + for page in drawer["pages"] + } + assert "/hold-overview" not in non_admin_routes + assert "/hold-history" not in non_admin_routes + + admin_client = app.test_client() + _login_as_admin(admin_client) + admin_resp = admin_client.get("/api/portal/navigation") + assert admin_resp.status_code == 200 + admin_payload = json.loads(admin_resp.data.decode("utf-8")) + admin_routes = { + page["route"] + for drawer in admin_payload["drawers"] + for page in drawer["pages"] + } + assert "/hold-overview" in admin_routes + assert "/hold-history" in admin_routes + + +def test_legacy_wrapper_routes_are_reachable(): + app = create_app("testing") + app.config["TESTING"] = True + client = app.test_client() + _login_as_admin(client) + + for route in ["/job-query", "/excel-query", "/query-tool", "/tmtt-defect"]: + response = client.get(route) + assert response.status_code == 200, f"{route} should be reachable" diff --git a/tests/test_template_integration.py b/tests/test_template_integration.py index 388c36b..37ba557 100644 --- a/tests/test_template_integration.py +++ b/tests/test_template_integration.py @@ -6,6 +6,7 @@ required core JavaScript resources. """ import unittest +import os from unittest.mock import patch from mes_dashboard.app import create_app @@ -21,12 +22,20 @@ class TestTemplateIntegration(unittest.TestCase): """Test that all templates properly extend _base.html.""" def setUp(self): + self._old_portal_spa = os.environ.get("PORTAL_SPA_ENABLED") + os.environ["PORTAL_SPA_ENABLED"] = "false" db._ENGINE = None self.app = create_app('testing') self.app.config['TESTING'] = True self.client = self.app.test_client() _login_as_admin(self.client) + def tearDown(self): + if self._old_portal_spa is None: + os.environ.pop("PORTAL_SPA_ENABLED", None) + else: + os.environ["PORTAL_SPA_ENABLED"] = self._old_portal_spa + def test_portal_includes_base_scripts(self): response = self.client.get('/') self.assertEqual(response.status_code, 200) @@ -113,13 +122,21 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase): """Test dynamic portal drawer rendering.""" def setUp(self): + self._old_portal_spa = os.environ.get("PORTAL_SPA_ENABLED") + os.environ["PORTAL_SPA_ENABLED"] = "false" db._ENGINE = None self.app = create_app('testing') self.app.config['TESTING'] = True self.client = self.app.test_client() _login_as_admin(self.client) - def test_portal_uses_navigation_config_for_sidebar_and_iframes(self): + def tearDown(self): + if self._old_portal_spa is None: + os.environ.pop("PORTAL_SPA_ENABLED", None) + else: + os.environ["PORTAL_SPA_ENABLED"] = self._old_portal_spa + + def test_portal_uses_navigation_config_for_sidebar_links_without_iframe(self): drawers = [ { "id": "custom", @@ -132,8 +149,6 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase): "name": "自訂首頁", "status": "released", "order": 1, - "frame_id": "customFrame", - "tool_src": None, } ], }, @@ -148,8 +163,6 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase): "name": "頁面管理", "status": "dev", "order": 1, - "frame_id": "toolFrame", - "tool_src": "/admin/pages", } ], }, @@ -160,10 +173,11 @@ class TestPortalDynamicDrawerRendering(unittest.TestCase): self.assertEqual(response.status_code, 200) html = response.data.decode("utf-8") self.assertIn("自訂分類", html) - self.assertIn('data-target="customFrame"', html) - self.assertIn('id="customFrame"', html) - self.assertIn('data-tool-src="/admin/pages"', html) - self.assertIn('id="toolFrame"', html) + self.assertIn('href="/wip-overview"', html) + self.assertIn('data-route="/wip-overview"', html) + self.assertIn('href="/admin/pages"', html) + self.assertIn('data-route="/admin/pages"', html) + self.assertNotIn("