diff --git a/doc/detail_dialog_unification_plan.md b/doc/detail_dialog_unification_plan.md new file mode 100644 index 0000000..275d1df --- /dev/null +++ b/doc/detail_dialog_unification_plan.md @@ -0,0 +1,116 @@ +# 상세 팝업 정보 영역 통합 계획 + +## 1. 배경 +- 현재 모든 상세 팝업(마스터·결재·재고 등)은 `SuperportDetailDialog` 상단 summary/metadata(정보1)와 탭 섹션(정보2)에서 동일한 엔티티 데이터를 중복 노출한다. +- 사용자는 정보가 하나의 블록에서 완결되길 기대하지만, 구현상 개요(summary)와 `_OverviewSection` 사이에 필드가 분산되어 정보1/정보2가 따로 보이게 된다. +- 같은 API 데이터를 두 영역에 반복 표기하면서도 정보 간 일관성이 떨어져 UX 혼란이 발생한다. + +## 2. 대상 팝업 +| 분류 | 파일 | +| --- | --- | +| 결재 템플릿 | `lib/features/approvals/template/presentation/dialogs/approval_template_detail_dialog.dart` | +| 결재 단계 | `lib/features/approvals/step/presentation/dialogs/approval_step_detail_dialog.dart` | +| 벤더 | `lib/features/masters/vendor/presentation/dialogs/vendor_detail_dialog.dart` | +| 그룹 | `lib/features/masters/group/presentation/dialogs/group_detail_dialog.dart` | +| 그룹 메뉴 권한 | `lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart` | +| 고객사 | `lib/features/masters/customer/presentation/dialogs/customer_detail_dialog.dart` | +| 메뉴 | `lib/features/masters/menu/presentation/dialogs/menu_detail_dialog.dart` | +| 제품 | `lib/features/masters/product/presentation/dialogs/product_detail_dialog.dart` | +| 사용자 | `lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart` | +| 창고 | `lib/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog.dart` | + +> ※ `SuperportDetailDialog` 자체(`lib/widgets/components/superport_detail_dialog.dart`)를 공통으로 수정해야 하므로 목록에 포함. + +## 3. 목표 UX +1. 정보1(상단 summary+metadata)에서 모든 읽기 전용 필드를 완결성 있게 노출한다. +2. 정보2(탭 영역)는 액션 중심(수정/삭제/히스토리 등)으로 단순화한다. +3. 동일 필드는 단 한 곳(정보1)에만 표시되도록 하여 중복을 제거한다. + +## 4. 단계별 플랜 + +### 4.1 구조 파악 & 정리 +- [x] 대상 다이얼로그마다 현재 summary/metadata/overview에 배치된 필드를 표로 정리한다. +- [x] 중복 필드, 정보1에 반드시 남겨야 할 핵심 필드(이름, 코드, 상태, 타임스탬프 등)를 분류한다. +- [x] 추가 데이터 소스 유무 확인(현행은 단일 엔티티 전달 → 별도 API 불필요). + +#### 4.1.1 필드 배치 매핑 +| 팝업 | Summary 필드 | Metadata 필드 | Overview/기타 섹션 필드 | 중복 & 정보1 핵심 정리 | +| --- | --- | --- | --- | --- | +| 결재 템플릿 | 템플릿명
설명 | ID
코드
생성일시
변경일시
비고 | 템플릿명
코드
설명
사용 여부
생성일시
변경일시
비고 | 중복: 이름/코드/설명/타임스탬프/비고. 핵심: summary=템플릿명·설명 유지, metadata=ID/코드/생성·변경/비고, 사용 여부는 summaryBadges에 집중. | +| 결재 단계 | 단계 순번
승인자 이름+사번
비고 | 결재 ID
트랜잭션번호
템플릿명
승인자 사번
배정일시
결정일시 | 단계 순서
승인자 이름
승인자 사번
상태
배정/결정일시
비고 | 중복: 승인자 사번·배정/결정 시각·비고. 핵심: summary=단계 제목+승인자, metadata=결재/템플릿 식별자+승인자 사번+상태+배정/결정/비고로 통합. | +| 벤더 | 벤더명
벤더코드 | ID
생성일시
변경일시
비고 | 벤더코드
벤더명
사용 여부
삭제 여부
비고
생성/변경일시 | 중복: 코드·이름·비고·타임스탬프. 핵심: summary=벤더명·코드, metadata=ID/코드/생성·변경/비고와 사용·삭제 상태(배지 또는 metadata)만 유지. | +| 그룹 | 그룹명
설명 | ID
생성일시
변경일시
비고 | 그룹명
설명
기본 여부
사용 여부
삭제 여부
비고
생성/변경일시 | 중복: 그룹명·설명·비고·타임스탬프. 핵심: summary=그룹명·설명, metadata=ID/상태/타임스탬프/비고, 기본 여부는 metadata로 이동. | +| 그룹 권한 | 그룹명 → 메뉴명 | ID
메뉴 경로
비고
생성일시
변경일시 | 그룹
메뉴
경로
CRUD 권한 4종
사용 여부
삭제 여부
비고
생성/변경일시 | 중복: 경로·비고·타임스탬프. 핵심: summary=그룹/메뉴 페어, metadata=ID/경로/CRUD 권한/상태/비고/타임스탬프, 삭제 시 배지. | +| 고객사 | 고객사명
고객코드 | ID
담당자
연락처
이메일
생성일시
수정일시 | 고객코드
유형(파트너/일반)
사용/삭제 여부
담당자
연락처
이메일
우편번호
주소
비고
생성/수정일시 | 중복: 담당자·연락처·이메일·타임스탬프. 핵심: summary=이름·코드, metadata=ID/유형/담당·연락·이메일/주소/상태/타임스탬프/비고. | +| 메뉴 | 메뉴명
경로 | ID
메뉴코드
표시순서
비고
생성일시
변경일시 | 메뉴코드
메뉴명
상위메뉴
경로
표시순서
사용 여부
삭제 여부
비고
생성/변경일시 | 중복: 코드·경로·표시순서·비고·타임스탬프. 핵심: summary=메뉴명+경로, metadata=ID/코드/상위/표시순서/상태/타임스탬프/비고. | +| 제품 | 제품명
제품코드 | ID
제조사
단위
생성일시
변경일시
비고 | 기본 정보: 제품코드·제품명·사용/삭제 여부·비고·생성/변경일시
관계 섹션: 제조사·단위
히스토리 섹션: 안내 문구 | 중복: 제조사/단위, 비고, 타임스탬프. 핵심: summary=이름·코드, metadata=ID/제조사/단위/상태/타임스탬프/비고, 관계 섹션은 액션/링크 중심으로 축소. | +| 사용자 | 직원명
사번 | ID
그룹
이메일
연락처
비고
비밀번호 변경일시
생성/수정일시 | 개요: 사번·이메일·연락처·그룹·사용/삭제 여부·비고·생성/수정일시
보안 섹션: 비밀번호 변경일시·강제 변경 여부 | 중복: 이메일·연락처·그룹·비고·타임스탬프·비밀번호 변경일시. 핵심: summary=직원명·사번, metadata=ID/그룹/연락처/이메일/비고/비밀번호 정보/상태/타임스탬프. | +| 창고 | 창고명
창고코드 | ID
우편번호
기본주소
상세주소
생성일시
변경일시
비고 | 기본 정보: 창고코드·창고명·사용/삭제 여부·비고·생성/변경일시
주소 섹션: 우편번호·주소·상세주소 | 중복: 주소 3종·타임스탬프·비고. 핵심: summary=이름·코드, metadata=ID/주소/상태/타임스탬프/비고, 별도 주소 섹션은 지도/편집 가이드를 위한 설명만 남김. | + +- 모든 상세 다이얼로그가 단일 엔티티를 인자로 전달받고 있으며, 별도의 추가 API 호출이나 비동기 데이터 로딩 없이 summary/metadata/overview를 구성한다. 따라서 정보1에 필드를 통합할 때도 데이터 소스 확장이 필요 없다. +- 공통 컴포넌트 `SuperportDetailDialog`는 summary·metadata·섹션을 세로로 쌓는 단일 레이아웃으로, 현재 1열 `_MetadataTable`만 지원한다. 고객/창고처럼 필드가 10개 이상인 경우 스크롤이 길어지므로 2열화 또는 그룹 헤더 도입 필요성이 확인됐다. + +### 4.2 공통 레이아웃 개편안 확정 +- [x] `SuperportDetailDialog`에서 summary/metadata 블록이 “정보1”로 인지되도록 레이아웃/간격/구분선을 다듬는다. +- [x] metadata를 2열 테이블 또는 Grid로 확장해 더 많은 필드를 수용할 수 있도록 옵션 검토. +- [x] 탭 섹션에는 개요를 제외하고, 폼·위험 액션·히스토리 등 행위 중심 요소만 남기는 가이드 작성. + +#### 4.2.1 닫기 인터랙션 정리 +- 상세보기 계열 팝업은 기본적으로 세 가지 닫기 방식을 제공한다. + 1. 팝업 우상단의 X 버튼 + 2. 팝업 바깥 영역 클릭(barrier dismissible) + 3. 팝업 우하단 액션 영역의 “닫기” 버튼 +- 3번 “닫기” 버튼은 X 버튼과 동일한 동작을 반복해서 정보 과밀감을 유발하므로 UI에서 제거한다. 필요 시 개별 화면에서만 액션 버튼을 추가하도록 하고, 공통 다이얼로그에서는 닫기 버튼을 렌더링하지 않는다. + +#### 4.2.2 정보1 레이아웃 스펙 +- `SuperportDetailDialog` 상단 영역을 `infoPanel`(summary + metadata)로 묶어 `ShadCard` 스타일로 렌더링한다. 좌우/상하 여백을 통일해 summary/metadata가 하나의 덩어리(정보1)로 인지되도록 했다. +- summary 영역은 기존 `summary` 슬롯을 그대로 사용하며, 대표 타이틀/보조 텍스트 → 배지 순의 수직 스택을 유지한다. summary/metadata 사이 여백은 카드 내부에서만 관리해 섹션과 시각적으로 분리된다. +- metadata는 `_MetadataGrid`로 교체해 2열 레이아웃을 기본값(`metadataColumns = 2`)으로 제공한다. 가로폭이 520px 미만이거나 열 수가 항목 수보다 많으면 자동으로 1열로 접어 UX를 보장한다. +- metadata 항목은 라벨/아이콘/값이 세로 정렬된 카드 타일로 렌더링되며, 값 영역은 `Widget` 그대로 머지되어 텍스트·뱃지·아이콘 등 자유로운 구성을 유지한다. +- `infoPanelPadding` 파라미터를 추가해 summary+metadata 블록과 탭 섹션 사이 간격을 한 번에 조절할 수 있도록 했고, `metadataColumns` 파라미터로 팝업 별 열 수 튜닝도 가능하다. + +#### 4.2.3 탭 구성 가이드 +- 탭 영역은 “행위 중심 정보2”로 재정의한다. 읽기 전용 필드는 summary/metadata에 모두 흡수하고, 탭에는 폼, 수정/위험 액션, 히스토리, 관계 관리 등 상호작용성 있는 콘텐츠만 남긴다. +- `SuperportDetailDialogSection`에 overview 전용 빌더를 남겨두지 않고, 각 다이얼로그는 `_OverviewSection` 파일을 제거하면서 해당 필드를 `metadata` 리스트로 옮긴다. +- 탭이 한 개만 남는 경우 자동으로 단일 패널 모드로 전환되므로, 최소 “폼/위험/히스토리” 중 필요한 섹션만 남겨도 된다. 이때 `initialSectionId`는 남은 섹션 ID 중 하나로 업데이트한다. + +### 4.3 다이얼로그별 코드 수정 +- [ ] 각 다이얼로그에서 `_OverviewSection`(읽기 전용)을 제거하고 해당 필드를 metadata 배열로 이동. +- [ ] summary에는 대표 타이틀/보조 텍스트만 남기고 상태 배지는 `summaryBadges`로 유지. +- [ ] 탭 목록에서 제거된 섹션을 반영하여 ID 상수·초기 선택 로직을 정리. +- [ ] 필요 시 metadata 행 구성을 위한 헬퍼(예: `_KeyValueRow`)를 공통 유틸로 승격하거나 삭제. + +#### 4.3 진행 현황 +- [x] 결재 템플릿 팝업: `_TemplateOverviewSection` 제거, metadata에 상태/코드/타임스탬프/비고를 통합하고 초기 탭을 `steps`로 변경했다. `_KeyValueRow` 유틸은 더 이상 사용하지 않아 삭제 완료. +- [x] 결재 단계 팝업: `_ApprovalStepOverviewSection` 제거, 단계 순서·승인자·상태·배정/결정일시·비고를 metadata로 이동하고 탭은 수정/삭제(복구)만 남겼다. 권한에 따라 섹션이 없을 수 있으므로 info panel만으로도 정보가 완결된다. +- [x] 벤더 팝업: `_VendorOverviewSection`과 `_KeyValueRow` 삭제, ID/코드/상태/삭제 상태/타임스탬프/비고를 metadata로 통합하고 summary에는 이름만 남겼다. 초기 탭은 생성/수정으로 변경되어 정보1+액션 분리가 완료됐다. +- [x] 그룹 팝업: `_GroupOverviewSection` 제거, 기본 여부/사용·삭제 여부/타임스탬프/비고를 metadata로 이동했으며 summary는 이름·설명만 유지한다. 탭은 등록/수정과 삭제/복구만 남겨 행위 중심 구조를 맞췄다. +- [x] 그룹 권한 팝업: `_GroupPermissionOverviewSection`을 삭제하고 그룹/메뉴/경로/CRUD 권한/상태/삭제 여부/노트/타임스탬프를 metadata에 넣었다. summary에는 `그룹 → 메뉴`만 남기고 탭은 수정, 삭제/복구(또는 등록)만 유지한다. +- [x] 고객사 팝업: `_CustomerOverviewSection` 제거, 고객 코드/유형/연락처/주소/상태/삭제 여부/타임스탬프/비고를 metadata로 통합하고 summary는 이름만 남겼다. 탭은 등록/수정+삭제/복구만 유지해 정보1과 액션을 분리했다. +- [x] 제품 팝업: `_ProductOverviewSection` 제거, 제품 코드/상태/삭제 여부/제조사/단위/비고/타임스탬프를 metadata로 이동하고 summary에는 제품명만 남겼다. 탭은 연결 관계·히스토리(행위 가이드)와 등록/수정·삭제/복구만 남도록 조정했다. +- [x] 창고 팝업: `_WarehouseOverviewSection`/`_WarehouseAddressSection` 삭제, ID/창고코드/상태/삭제 여부/주소/비고/타임스탬프를 metadata로 결합했고 탭은 등록·수정과 삭제/복구만 남겼다. 상세 모드 초기 탭을 `edit`으로 지정해 정보1과 행위 영역이 명확히 구분된다. +- [x] 재고 입출고/대여 팝업: 재고 상세 공통 다이얼로그를 `SuperportDetailDialog`로 교체하고 입고/출고/대여 레코드의 상태·창고·금액/수량·반납 예정일을 metadata로 옮겼다. 탭은 라인 품목(고객/관계 포함)만 남겨 info1과 행위 영역이 분리되었다. +- [ ] 나머지 결재/마스터 팝업: 동일한 패턴으로 순차 대응 예정. + +### 4.4 검증 & 테스트 +- [ ] 기존 위젯 테스트 업데이트: overview 탭 삭제, metadata 렌더링 검증 등. +- [ ] 신규 테스트 케이스 추가: summary+metadata에 필드가 모두 나타나는지, 탭이 최소 한 개만 남았을 때 동작하는지 확인. +- [ ] `flutter analyze`, `flutter test` 전 프로젝트 실행. +- [ ] 스타일 변경 시 주요 화면 캡처를 남겨 리뷰 참고자료로 사용. + +## 5. 예상 산출물 +- 수정된 상세 다이얼로그 10여 개 및 공통 컴포넌트 1개. +- 테스트 업데이트(다이얼로그 위젯 테스트 최소 1개 이상). +- 필요 시 설계 문서(본 문서 갱신 포함) 및 회고 노트. + +## 6. 리스크 및 대응 +| 리스크 | 대응 | +| --- | --- | +| metadata 영역에 필드가 많아져 가독성 저하 | 2열 레이아웃, 섹션 헤더, collapse 등 UI 개선 옵션 검토 | +| 탭 제거로 인한 라우팅/초기 섹션 ID 로직 오류 | `initialSectionId` 사용 여부 재확인 및 단위 테스트 작성 | +| 테스트 스냅샷/Golden 미존재 시 회귀 미포착 | 주요 다이얼로그에 대한 Widget 테스트 보강 | + +## 7. 이후 액션 +1. 4.1의 필드 매핑 표를 작성해 본 문서를 업데이트. +2. 공통 레이아웃 확정 후 시놉시스/디자인 확인. +3. 다이얼로그-by-다이얼로그로 리팩토링 진행, 각 단계 완료 시 테스트/리포트 공유. diff --git a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart index 2510e2c..a922964 100644 --- a/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart +++ b/lib/features/approvals/history/presentation/controllers/approval_history_controller.dart @@ -205,7 +205,9 @@ class ApprovalHistoryController extends ChangeNotifier { _selectedFlow = flow; } catch (error) { final failure = Failure.from(error); - _errorMessage = failure.describe(); + _errorMessage = failure.statusCode == 403 + ? failure.describe() + : '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'; if (failure.statusCode == 403) { _isSelectionForbidden = true; _selectedFlow = null; @@ -238,7 +240,9 @@ class ApprovalHistoryController extends ChangeNotifier { return flow; } catch (error) { final failure = Failure.from(error); - _errorMessage = failure.describe(); + _errorMessage = failure.statusCode == 403 + ? failure.describe() + : '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'; notifyListeners(); return null; } diff --git a/lib/features/approvals/history/presentation/dialogs/approval_history_detail_dialog.dart b/lib/features/approvals/history/presentation/dialogs/approval_history_detail_dialog.dart new file mode 100644 index 0000000..2e35661 --- /dev/null +++ b/lib/features/approvals/history/presentation/dialogs/approval_history_detail_dialog.dart @@ -0,0 +1,770 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/feedback.dart'; +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../../../../widgets/components/superport_dialog.dart'; +import '../../../../../widgets/components/superport_table.dart'; +import '../../../../auth/domain/entities/authenticated_user.dart'; +import '../../../domain/entities/approval.dart'; +import '../../domain/entities/approval_history_record.dart'; +import '../controllers/approval_history_controller.dart'; +import '../widgets/approval_audit_log_table.dart'; +import '../widgets/approval_flow_timeline.dart'; +import '../../../shared/widgets/approver_autocomplete_field.dart'; +import '../../../shared/widgets/approval_ui_helpers.dart'; +import '../../../domain/entities/approval_flow.dart'; + +/// 결재 이력 상세 다이얼로그를 표시한다. +Future showApprovalHistoryDetailDialog({ + required BuildContext context, + required ApprovalHistoryController controller, + required ApprovalHistoryRecord record, + required intl.DateFormat dateFormat, + required AuthenticatedUser? currentUser, +}) { + return showSuperportDialog( + context: context, + title: '결재 이력 상세', + description: '결재번호 ${record.approvalNo}', + body: _ApprovalHistoryDetailDialogBody( + controller: controller, + record: record, + dateFormat: dateFormat, + currentUser: currentUser, + ), + constraints: const BoxConstraints(maxWidth: 920), + barrierDismissible: true, + scrollable: true, + ); +} + +class _ApprovalHistoryDetailDialogBody extends StatefulWidget { + const _ApprovalHistoryDetailDialogBody({ + required this.controller, + required this.record, + required this.dateFormat, + required this.currentUser, + }); + + final ApprovalHistoryController controller; + final ApprovalHistoryRecord record; + final intl.DateFormat dateFormat; + final AuthenticatedUser? currentUser; + + @override + State<_ApprovalHistoryDetailDialogBody> createState() => + _ApprovalHistoryDetailDialogBodyState(); +} + +class _ApprovalHistoryDetailDialogBodyState + extends State<_ApprovalHistoryDetailDialogBody> { + late ApprovalHistoryRecord _record; + final TextEditingController _auditActorIdController = TextEditingController(); + final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + static const _auditActionAll = '__all__'; + + ApprovalHistoryController get _controller => widget.controller; + + @override + void initState() { + super.initState(); + _record = widget.record; + WidgetsBinding.instance.addPostFrameCallback((_) { + _initialize(); + }); + } + + @override + void didUpdateWidget(covariant _ApprovalHistoryDetailDialogBody oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.record.id != widget.record.id) { + _record = widget.record; + _initialize(); + } + } + + Future _initialize() async { + try { + _controller.updateActiveTab(ApprovalHistoryTab.flow); + await _controller.loadApprovalFlow(_record.approvalId, force: true); + if (!_controller.isSelectionForbidden) { + await _controller.fetchAuditLogs(approvalId: _record.approvalId); + } + } catch (_) { + // 오류 메시지는 컨트롤러 리스너에서 처리된다. + } + } + + @override + void dispose() { + _auditActorIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final flow = _controller.selectedFlow; + final auditResult = _controller.auditResult; + final auditLogs = auditResult?.items ?? const []; + final pagination = auditResult == null + ? null + : SuperportTablePagination( + currentPage: auditResult.page, + totalPages: auditResult.pageSize == 0 + ? 1 + : (auditResult.total / auditResult.pageSize).ceil().clamp( + 1, + 9999, + ), + totalItems: auditResult.total, + pageSize: auditResult.pageSize, + ); + final metadata = _buildMetadata(); + final badges = _buildBadges(flow); + + final sections = [ + SuperportDetailDialogSection( + id: 'timeline', + label: '상태 타임라인', + icon: lucide.LucideIcons.listTree, + builder: (_) => _buildTimelineSection(flow), + ), + SuperportDetailDialogSection( + id: 'audit', + label: '감사 로그', + icon: lucide.LucideIcons.listChecks, + builder: (_) => + _buildAuditSection(logs: auditLogs, pagination: pagination), + ), + ]; + + return SuperportDetailDialog( + sections: sections, + summary: _buildSummary(flow), + summaryBadges: badges, + metadata: metadata, + initialSectionId: 'timeline', + ); + }, + ); + } + + Widget _buildSummary(ApprovalFlow? flow) { + final theme = ShadTheme.of(context); + final requester = flow?.requester; + final currentStep = flow?.statusSummary.currentStepOrder; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ApprovalStatusBadge( + label: _record.toStatus.name, + colorHex: _record.toStatus.color, + ), + const SizedBox(height: 12), + Text('결재번호 ${_record.approvalNo}', style: theme.textTheme.small), + const SizedBox(height: 4), + Text( + _record.stepOrder == null + ? '단계 정보 없음 · ${_record.approver.name}' + : '${_record.stepOrder}단계 · ${_record.approver.name}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text(_statusLabel(_record), style: theme.textTheme.muted), + if (requester != null) ...[ + const SizedBox(height: 8), + Text( + '상신자 ${requester.name} (${requester.employeeNo})', + style: theme.textTheme.small, + ), + ], + if (flow != null && currentStep != null) ...[ + const SizedBox(height: 4), + Text( + '현재 진행 단계: $currentStep / ${flow.statusSummary.totalSteps}', + style: theme.textTheme.small, + ), + ], + const SizedBox(height: 16), + _buildActionButtons(flow), + ], + ); + } + + List _buildBadges(ApprovalFlow? flow) { + final badges = [ + ShadBadge.outline(child: Text(_record.action.name)), + ShadBadge(child: Text(_record.toStatus.name)), + ]; + final isForbidden = _controller.isSelectionForbidden; + if (isForbidden) { + badges.add(const ShadBadge.destructive(child: Text('열람 제한'))); + } else if (flow?.approval.updatedAt != null) { + badges.add( + ShadBadge.outline( + child: Text( + '변경 ${_dateTimeFormat.format(flow!.approval.updatedAt!.toLocal())}', + ), + ), + ); + } + return badges; + } + + List _buildMetadata() { + return [ + SuperportDetailMetadata.text( + label: '결재 ID', + value: '${_record.approvalId}', + ), + SuperportDetailMetadata.text( + label: '승인자 사번', + value: _record.approver.employeeNo, + ), + SuperportDetailMetadata.text( + label: '행위 시간', + value: _dateTimeFormat.format(_record.actionAt.toLocal()), + ), + SuperportDetailMetadata( + label: '메모', + value: ApprovalNoteTooltip(note: _record.note), + ), + ]; + } + + Widget _buildTimelineSection(ApprovalFlow? flow) { + final theme = ShadTheme.of(context); + if (_controller.isLoadingFlow) { + return const Center(child: CircularProgressIndicator()); + } + if (_controller.isSelectionForbidden) { + return _buildForbiddenNotice(theme); + } + if (flow == null) { + return _buildPlaceholder(theme, '결재 정보를 불러오는 중입니다.'); + } + return ApprovalFlowTimeline(flow: flow, dateFormat: _dateTimeFormat); + } + + Widget _buildAuditSection({ + required List logs, + required SuperportTablePagination? pagination, + }) { + final theme = ShadTheme.of(context); + if (_controller.isSelectionForbidden) { + return _buildForbiddenNotice(theme); + } + if (_controller.isLoadingAudit) { + return const Center(child: CircularProgressIndicator()); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildAuditFilters(theme), + const SizedBox(height: 16), + ApprovalAuditLogTable( + logs: logs, + dateFormat: _dateTimeFormat, + pagination: pagination, + onPageChange: (page) { + _controller.fetchAuditLogs( + approvalId: _record.approvalId, + page: page, + ); + }, + onPageSizeChange: (value) { + _controller.updateAuditPageSize(value); + _controller.fetchAuditLogs(approvalId: _record.approvalId, page: 1); + }, + isLoading: _controller.isLoadingAudit, + ), + ], + ); + } + + Widget _buildAuditFilters(ShadThemeData theme) { + final actorId = _controller.auditActorId; + final actorText = actorId?.toString() ?? ''; + if (_auditActorIdController.text.trim() != actorText) { + _auditActorIdController.value = TextEditingValue(text: actorText); + } + final actionOptions = _controller.auditActions; + final currentAction = _controller.auditActionCode ?? _auditActionAll; + final isLoading = _controller.isLoadingAudit; + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + SizedBox( + width: 240, + child: ApprovalApproverAutocompleteField( + key: ValueKey(actorId ?? 'all'), + idController: _auditActorIdController, + hintText: '행위자 검색', + onSelected: (candidate) { + final selected = + candidate?.id ?? + int.tryParse(_auditActorIdController.text.trim()); + _controller.updateAuditActor(selected); + _controller.fetchAuditLogs( + approvalId: _record.approvalId, + page: 1, + ); + }, + ), + ), + SizedBox( + width: 200, + child: ShadSelect( + key: ValueKey(currentAction), + initialValue: currentAction, + selectedOptionBuilder: (context, value) => + Text(_auditActionLabel(value, actionOptions)), + onChanged: isLoading + ? null + : (value) { + if (value == null || value == _auditActionAll) { + _controller.updateAuditAction(null); + } else { + _controller.updateAuditAction(value); + } + _controller.fetchAuditLogs( + approvalId: _record.approvalId, + page: 1, + ); + }, + options: [ + const ShadOption(value: _auditActionAll, child: Text('전체 행위')), + ...actionOptions + .where( + (action) => action.code != null && action.code!.isNotEmpty, + ) + .map( + (action) => ShadOption( + value: action.code!, + child: Text(action.name), + ), + ), + ], + ), + ), + if (_controller.hasActiveAuditFilters) + ShadButton.ghost( + onPressed: isLoading ? null : _clearAuditFilters, + child: const Text('필터 초기화'), + ), + ], + ); + } + + Widget _buildActionButtons(ApprovalFlow? flow) { + final theme = ShadTheme.of(context); + final canRecall = flow != null && _canRecall(flow); + final canResubmit = flow != null && _canResubmit(flow); + final recallReason = flow == null + ? '결재 정보를 불러오는 중입니다.' + : _recallDisabledReason(flow); + final resubmitReason = flow == null + ? '결재 정보를 불러오는 중입니다.' + : _resubmitDisabledReason(flow); + + final recallNotice = _buildRecallConditionNotice( + theme: theme, + flow: flow, + canRecall: canRecall, + reason: recallReason, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + ShadButton( + onPressed: + flow == null || !canRecall || _controller.isPerformingAction + ? null + : () => _handleRecall(flow), + child: const Text('회수'), + ), + ShadButton.outline( + onPressed: + flow == null || !canResubmit || _controller.isPerformingAction + ? null + : () => _handleResubmit(flow), + child: const Text('재상신'), + ), + ], + ), + if (_controller.isPerformingAction) ...[ + const SizedBox(height: 8), + Text( + '결재 작업을 처리하는 중입니다...', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ] else ...[ + if (recallNotice != null) ...[ + const SizedBox(height: 8), + recallNotice, + ], + if (!canResubmit && resubmitReason != null) ...[ + SizedBox(height: recallNotice == null ? 8 : 4), + Text( + '재상신 불가: $resubmitReason', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ], + ], + ); + } + + Widget? _buildRecallConditionNotice({ + required ShadThemeData theme, + required ApprovalFlow? flow, + required bool canRecall, + required String? reason, + }) { + if (flow == null) { + return Row( + children: [ + Icon( + lucide.LucideIcons.info, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '회수 조건을 확인하는 중입니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ], + ); + } + final icon = canRecall + ? lucide.LucideIcons.badgeCheck + : lucide.LucideIcons.shieldAlert; + final color = canRecall + ? theme.colorScheme.primary + : theme.colorScheme.destructive; + final message = canRecall + ? '첫 승인자가 아직 결정을 내리지 않아 회수할 수 있습니다.' + : (reason ?? '회수 조건을 확인할 수 없습니다.'); + return Row( + children: [ + Icon(icon, size: 16, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + message, + style: theme.textTheme.small.copyWith(color: color), + ), + ), + ], + ); + } + + Widget _buildForbiddenNotice(ShadThemeData theme) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '열람 권한이 없습니다', + style: theme.textTheme.p.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.destructive, + ), + ), + const SizedBox(height: 8), + Text( + '상신자 또는 기결재자만 감사 로그와 상세 내역을 확인할 수 있습니다.', + style: theme.textTheme.small, + ), + const SizedBox(height: 8), + Text( + '필요 시 담당자에게 접근 권한을 요청하거나 다른 결재를 선택하세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ); + } + + Widget _buildPlaceholder(ShadThemeData theme, String message) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Text(message, style: theme.textTheme.muted), + ); + } + + void _clearAuditFilters() { + _auditActorIdController.clear(); + _controller.clearAuditFilters(); + _controller.fetchAuditLogs(approvalId: _record.approvalId, page: 1); + } + + String _statusLabel(ApprovalHistoryRecord record) { + final from = record.fromStatus?.name; + if (from == null || from.isEmpty) { + return record.toStatus.name; + } + return '$from → ${record.toStatus.name}'; + } + + String _auditActionLabel(String value, List actions) { + if (value == _auditActionAll) { + return '전체 행위'; + } + for (final action in actions) { + if (action.code == value) { + return action.name; + } + } + return '전체 행위'; + } + + bool _canRecall(ApprovalFlow flow) { + if (flow.status.isTerminal) { + return false; + } + if (flow.steps.isEmpty) { + return false; + } + final first = flow.steps.first; + return first.decidedAt == null; + } + + bool _canResubmit(ApprovalFlow flow) { + if (!flow.status.isTerminal) { + return false; + } + final statusName = flow.status.name.toLowerCase(); + return statusName.contains('반려') || statusName.contains('reject'); + } + + String? _recallDisabledReason(ApprovalFlow flow) { + if (flow.status.isTerminal) { + return '결재가 종료되었습니다.'; + } + if (flow.steps.isEmpty) { + return '결재 단계 정보가 없습니다.'; + } + if (flow.steps.first.decidedAt != null) { + return '첫 승인자가 이미 결정을 내려 회수할 수 없습니다.'; + } + return null; + } + + String? _resubmitDisabledReason(ApprovalFlow flow) { + if (!flow.status.isTerminal) { + return '결재가 아직 진행 중입니다.'; + } + final statusName = flow.status.name.toLowerCase(); + if (!(statusName.contains('반려') || statusName.contains('reject'))) { + return '반려 상태에서만 재상신할 수 있습니다.'; + } + return null; + } + + Future _handleRecall(ApprovalFlow flow) async { + final user = widget.currentUser; + if (user == null) { + SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.'); + return; + } + final approvalId = flow.id; + if (approvalId == null) { + SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.'); + return; + } + final note = await _promptActionNote( + title: '결재 회수', + confirmLabel: '회수', + description: '회수 사유를 입력하세요. 입력하지 않아도 회수를 진행할 수 있습니다.', + ); + final refreshed = await _controller.refreshFlow(approvalId); + if (!mounted) { + return; + } + if (refreshed == null) { + SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'); + return; + } + final latestFlow = refreshed; + final sanitizedNote = note?.isEmpty == true ? null : note; + final transactionUpdatedAt = latestFlow.transactionUpdatedAt; + if (transactionUpdatedAt == null) { + SuperportToast.error( + context, + '연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.', + ); + return; + } + final input = ApprovalRecallInput( + approvalId: approvalId, + actorId: user.id, + note: sanitizedNote, + expectedUpdatedAt: latestFlow.approval.updatedAt, + transactionExpectedUpdatedAt: transactionUpdatedAt, + ); + final result = await _controller.recallApproval(input); + if (!mounted) { + return; + } + if (result != null) { + SuperportToast.success( + context, + '결재(${latestFlow.approvalNo}) 회수를 완료했습니다.', + ); + await _controller.loadApprovalFlow(approvalId, force: true); + await _controller.fetchAuditLogs(approvalId: approvalId); + } + } + + Future _handleResubmit(ApprovalFlow flow) async { + final user = widget.currentUser; + if (user == null) { + SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.'); + return; + } + final approvalId = flow.id; + if (approvalId == null) { + SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.'); + return; + } + final note = await _promptActionNote( + title: '결재 재상신', + confirmLabel: '재상신', + description: '재상신 시 전달할 메시지를 입력하세요. 입력하지 않아도 재상신됩니다.', + ); + final refreshed = await _controller.refreshFlow(approvalId); + if (!mounted) { + return; + } + if (refreshed == null) { + SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'); + return; + } + final latestFlow = refreshed; + final sanitizedNote = note?.isEmpty == true ? null : note; + final transactionUpdatedAt = latestFlow.transactionUpdatedAt; + if (transactionUpdatedAt == null) { + SuperportToast.error( + context, + '연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.', + ); + return; + } + final steps = latestFlow.steps + .map( + (step) => ApprovalStepAssignmentItem( + stepOrder: step.stepOrder, + approverId: step.approver.id, + note: step.note, + ), + ) + .toList(growable: false); + final submission = ApprovalSubmissionInput( + transactionId: latestFlow.transactionId, + statusId: latestFlow.status.id, + requesterId: latestFlow.requester.id, + finalApproverId: latestFlow.finalApprover?.id, + note: latestFlow.note, + steps: steps, + ); + final input = ApprovalResubmissionInput( + approvalId: approvalId, + actorId: user.id, + submission: submission, + note: sanitizedNote, + expectedUpdatedAt: latestFlow.approval.updatedAt, + transactionExpectedUpdatedAt: transactionUpdatedAt, + ); + final result = await _controller.resubmitApproval(input); + if (!mounted) { + return; + } + if (result != null) { + SuperportToast.success( + context, + '결재(${latestFlow.approvalNo}) 재상신을 완료했습니다.', + ); + await _controller.loadApprovalFlow(approvalId, force: true); + await _controller.fetchAuditLogs(approvalId: approvalId); + } + } + + Future _promptActionNote({ + required String title, + required String confirmLabel, + required String description, + }) async { + final theme = ShadTheme.of(context); + final controller = TextEditingController(); + String? result; + await showSuperportDialog( + context: context, + title: title, + description: description, + constraints: const BoxConstraints(maxWidth: 420), + actions: [ + ShadButton.ghost( + onPressed: () => + Navigator.of(context, rootNavigator: true).maybePop(), + child: const Text('취소'), + ), + ShadButton( + onPressed: () { + result = controller.text.trim(); + Navigator.of(context, rootNavigator: true).maybePop(); + }, + child: Text(confirmLabel), + ), + ], + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('사유는 선택 입력입니다. 비워두면 전달되지 않습니다.', style: theme.textTheme.muted), + const SizedBox(height: 12), + ShadTextarea(controller: controller, minHeight: 120, maxHeight: 200), + ], + ), + ); + controller.dispose(); + return result; + } +} diff --git a/lib/features/approvals/history/presentation/pages/approval_history_page.dart b/lib/features/approvals/history/presentation/pages/approval_history_page.dart index 1144816..bfd8c82 100644 --- a/lib/features/approvals/history/presentation/pages/approval_history_page.dart +++ b/lib/features/approvals/history/presentation/pages/approval_history_page.dart @@ -7,27 +7,20 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/constants/app_sections.dart'; import '../../../../../widgets/app_layout.dart'; -import '../../../../../widgets/components/feedback.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_date_picker.dart'; import '../../../../../widgets/components/superport_table.dart'; import '../../../../../widgets/components/feature_disabled_placeholder.dart'; -import '../../../../../widgets/components/superport_dialog.dart'; import '../../../../auth/application/auth_service.dart'; import '../../../../auth/domain/entities/authenticated_user.dart'; import '../../domain/entities/approval_history_record.dart'; import '../../domain/repositories/approval_history_repository.dart'; -import '../../../domain/entities/approval.dart'; -import '../../../domain/entities/approval_flow.dart'; import '../../../domain/repositories/approval_repository.dart'; import '../../../domain/usecases/recall_approval_use_case.dart'; import '../../../domain/usecases/resubmit_approval_use_case.dart'; -import '../../../shared/domain/entities/approval_approver_candidate.dart'; import '../../../shared/widgets/widgets.dart'; -import '../../../shared/widgets/approver_autocomplete_field.dart'; import '../controllers/approval_history_controller.dart'; -import '../widgets/approval_audit_log_table.dart'; -import '../widgets/approval_flow_timeline.dart'; +import '../dialogs/approval_history_detail_dialog.dart'; class ApprovalHistoryPage extends StatelessWidget { const ApprovalHistoryPage({super.key}); @@ -82,13 +75,10 @@ class _ApprovalHistoryEnabledPageState DateTimeRange? _dateRange; String? _lastError; static const _pageSizeOptions = [10, 20, 50]; - static const _auditActionAll = '__all__'; - final TextEditingController _auditActorIdController = TextEditingController(); int? _sortColumnIndex; bool _sortAscending = true; static const _sortableColumns = {0, 1, 2, 3, 4, 5}; AuthenticatedUser? _currentUser; - ApprovalHistoryRecord? _selectedRecord; @override void initState() { @@ -124,7 +114,6 @@ class _ApprovalHistoryEnabledPageState _controller.dispose(); _searchController.dispose(); _searchFocus.dispose(); - _auditActorIdController.dispose(); super.dispose(); } @@ -237,50 +226,14 @@ class _ApprovalHistoryEnabledPageState ), ], ), - child: LayoutBuilder( - builder: (context, constraints) { - final isCompact = constraints.maxWidth < 1080; - if (_selectedRecord != null && - !sortedHistories.any( - (record) => record.id == _selectedRecord!.id, - )) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) { - return; - } - setState(() { - _selectedRecord = null; - }); - _controller.clearSelection(); - }); - } - final tableCard = _buildHistoryTableCard( - context, - theme, - sortedHistories, - totalCount, - currentPage, - totalPages, - result?.pageSize ?? _controller.pageSize, - ); - final detailCard = _buildDetailCard(theme); - - if (isCompact) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [tableCard, const SizedBox(height: 16), detailCard], - ); - } - - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded(child: tableCard), - const SizedBox(width: 16), - SizedBox(width: 360, child: detailCard), - ], - ); - }, + child: _buildHistoryTableCard( + context, + theme, + sortedHistories, + totalCount, + currentPage, + totalPages, + result?.pageSize ?? _controller.pageSize, ), ); }, @@ -309,18 +262,6 @@ class _ApprovalHistoryEnabledPageState _controller.fetch(page: 1); } - void _clearAuditFilters() { - _auditActorIdController.clear(); - _controller.clearAuditFilters(); - _refreshAuditForSelectedRecord(resetPage: true); - } - - void _handleAuditActorSelected(ApprovalApproverCandidate? item) { - final selectedId = item?.id ?? int.tryParse(_auditActorIdController.text); - _controller.updateAuditActor(selectedId); - _refreshAuditForSelectedRecord(resetPage: true); - } - void _handleSortChange(int columnIndex, bool ascending) { setState(() { _sortColumnIndex = columnIndex; @@ -398,7 +339,6 @@ class _ApprovalHistoryEnabledPageState final normalizedQuery = _controller.query.trim().toLowerCase(); final rows = >[]; for (final record in histories) { - final isSelected = _selectedRecord?.id == record.id; final approvalNo = record.approvalNo; final highlight = normalizedQuery.isNotEmpty && @@ -410,15 +350,10 @@ class _ApprovalHistoryEnabledPageState ) : theme.textTheme.small; rows.add([ - _buildTableCell( - theme, - Text(approvalNo, style: approvalStyle), - selected: isSelected, - ), + _buildTableCell(theme, Text(approvalNo, style: approvalStyle)), _buildTableCell( theme, _buildStepBadge(theme, record), - selected: isSelected, alignment: Alignment.centerLeft, ), _buildTableCell( @@ -427,28 +362,20 @@ class _ApprovalHistoryEnabledPageState name: record.approver.name, employeeNo: record.approver.employeeNo, ), - selected: isSelected, ), _buildTableCell( theme, ShadBadge.outline(child: Text(record.action.name)), - selected: isSelected, ), _buildTableCell( theme, Text(_statusLabel(record), style: theme.textTheme.small), - selected: isSelected, ), _buildTableCell( theme, Text(_dateTimeFormat.format(record.actionAt.toLocal())), - selected: isSelected, - ), - _buildTableCell( - theme, - ApprovalNoteTooltip(note: record.note), - selected: isSelected, ), + _buildTableCell(theme, ApprovalNoteTooltip(note: record.note)), ]); } @@ -487,11 +414,6 @@ class _ApprovalHistoryEnabledPageState Text('$totalCount건', style: theme.textTheme.muted), ], ), - if (_selectedRecord != null) - ShadButton.ghost( - onPressed: _controller.isLoading ? null : _clearSelection, - child: const Text('선택 해제'), - ), ], ), const SizedBox(height: 16), @@ -542,7 +464,9 @@ class _ApprovalHistoryEnabledPageState return const FixedTableSpanExtent(200); } }, - onRowTap: (index) => _handleSelectRecord(histories[index]), + onRowTap: _controller.isLoading + ? null + : (index) => _openHistoryDetail(histories[index]), sortableColumns: _sortableColumns, sortState: _sortColumnIndex == null ? null @@ -565,493 +489,6 @@ class _ApprovalHistoryEnabledPageState ); } - Widget _buildDetailCard(ShadThemeData theme) { - final selectedRecord = _selectedRecord; - final selectedFlow = _controller.selectedFlow; - final auditResult = _controller.auditResult; - final auditLogs = auditResult?.items ?? const []; - final auditPagination = auditResult == null - ? null - : SuperportTablePagination( - currentPage: auditResult.page, - totalPages: auditResult.pageSize == 0 - ? 1 - : (auditResult.total / auditResult.pageSize).ceil().clamp( - 1, - 9999, - ), - totalItems: auditResult.total, - pageSize: _controller.auditPageSize, - pageSizeOptions: _pageSizeOptions, - ); - - return ShadCard( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('결재 상세', style: theme.textTheme.h3), - const SizedBox(height: 4), - Text('회수 · 재상신과 감사 로그 요약', style: theme.textTheme.muted), - ], - ), - if (selectedRecord != null) - ShadButton.ghost( - onPressed: _controller.isPerformingAction - ? null - : _clearSelection, - child: const Text('선택 해제'), - ), - ], - ), - const SizedBox(height: 16), - if (selectedRecord == null) - _buildDetailPlaceholder(theme) - else ...[ - _buildDetailSummary(theme, selectedRecord, selectedFlow), - const SizedBox(height: 16), - if (_controller.isSelectionForbidden) ...[ - _buildForbiddenDetailNotice(theme), - ] else ...[ - _buildActionButtons(theme, selectedFlow), - const SizedBox(height: 16), - _buildTabSelector(theme), - const SizedBox(height: 16), - if (_controller.isLoadingFlow) - const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Center(child: CircularProgressIndicator()), - ) - else if (_controller.activeTab == ApprovalHistoryTab.flow) - _buildFlowTabContent(theme, selectedFlow) - else - _buildAuditTabContent(theme, auditLogs, auditPagination), - ], - ], - ], - ), - ), - ); - } - - Widget _buildForbiddenDetailNotice(ShadThemeData theme) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), - decoration: BoxDecoration( - color: theme.colorScheme.secondary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.colorScheme.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '열람 권한이 없습니다', - style: theme.textTheme.p.copyWith( - fontWeight: FontWeight.w600, - color: theme.colorScheme.destructive, - ), - ), - const SizedBox(height: 8), - Text( - '상신자 또는 기결재자만 감사 로그와 상세 내역을 확인할 수 있습니다.', - style: theme.textTheme.small, - ), - const SizedBox(height: 8), - Text( - '필요 시 담당자에게 접근 권한을 요청하거나 다른 결재를 선택하세요.', - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ), - ); - } - - Widget _buildDetailPlaceholder(ShadThemeData theme) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), - decoration: BoxDecoration( - color: theme.colorScheme.secondary.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: theme.colorScheme.border), - ), - child: Text( - '좌측 목록에서 결재 이력을 선택하면 상태 타임라인과 감사 로그가 표시됩니다.', - style: theme.textTheme.muted, - ), - ); - } - - Widget _buildDetailSummary( - ShadThemeData theme, - ApprovalHistoryRecord record, - ApprovalFlow? flow, - ) { - final statusLabel = _statusLabel(record); - final stepLabel = record.stepOrder == null - ? '단계 정보 없음' - : '${record.stepOrder}단계'; - final requester = flow?.requester; - final currentStep = flow?.statusSummary.currentStepOrder; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ApprovalStatusBadge( - label: record.toStatus.name, - colorHex: record.toStatus.color, - ), - const SizedBox(height: 12), - Text('결재번호 ${record.approvalNo}', style: theme.textTheme.small), - const SizedBox(height: 4), - Text( - '$stepLabel · ${record.approver.name}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text(statusLabel, style: theme.textTheme.muted), - if (requester != null) ...[ - const SizedBox(height: 8), - Text( - '상신자 ${requester.name} (${requester.employeeNo})', - style: theme.textTheme.small, - ), - ], - if (flow != null && currentStep != null) ...[ - const SizedBox(height: 4), - Text( - '현재 진행 단계: $currentStep / ${flow.statusSummary.totalSteps}', - style: theme.textTheme.small, - ), - ], - ], - ); - } - - Widget _buildActionButtons(ShadThemeData theme, ApprovalFlow? flow) { - final canRecall = flow != null && _canRecall(flow); - final canResubmit = flow != null && _canResubmit(flow); - final recallReason = flow == null - ? '결재 정보를 불러오는 중입니다.' - : _recallDisabledReason(flow); - final resubmitReason = flow == null - ? '결재 정보를 불러오는 중입니다.' - : _resubmitDisabledReason(flow); - - final recallNotice = _buildRecallConditionNotice( - theme: theme, - flow: flow, - canRecall: canRecall, - reason: recallReason, - ); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - ShadButton( - onPressed: - flow == null || !canRecall || _controller.isPerformingAction - ? null - : () => _handleRecall(flow), - child: const Text('회수'), - ), - ShadButton.outline( - onPressed: - flow == null || !canResubmit || _controller.isPerformingAction - ? null - : () => _handleResubmit(flow), - child: const Text('재상신'), - ), - ], - ), - if (_controller.isPerformingAction) ...[ - const SizedBox(height: 8), - Text( - '결재 작업을 처리하는 중입니다...', - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - ), - ), - ] else ...[ - if (recallNotice != null) ...[ - const SizedBox(height: 8), - recallNotice, - ], - if (!canResubmit && resubmitReason != null) ...[ - SizedBox(height: recallNotice == null ? 8 : 4), - Text( - '재상신 불가: $resubmitReason', - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - ), - ), - ], - ], - ], - ); - } - - Widget? _buildRecallConditionNotice({ - required ShadThemeData theme, - required ApprovalFlow? flow, - required bool canRecall, - required String? reason, - }) { - if (flow == null) { - return Row( - children: [ - Icon( - lucide.LucideIcons.info, - size: 16, - color: theme.colorScheme.mutedForeground, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '회수 조건을 확인하는 중입니다.', - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - ), - ), - ), - ], - ); - } - final icon = canRecall - ? lucide.LucideIcons.badgeCheck - : lucide.LucideIcons.shieldAlert; - final color = canRecall - ? theme.colorScheme.primary - : theme.colorScheme.destructive; - final message = canRecall - ? '첫 승인자가 아직 결정을 내리지 않아 회수할 수 있습니다.' - : (reason ?? '회수 조건을 확인할 수 없습니다.'); - return Row( - children: [ - Icon(icon, size: 16, color: color), - const SizedBox(width: 8), - Expanded( - child: Text( - message, - style: theme.textTheme.small.copyWith(color: color), - ), - ), - ], - ); - } - - Widget _buildTabSelector(ShadThemeData theme) { - final activeTab = _controller.activeTab; - return Row( - children: [ - _buildTabButton( - label: '상태 타임라인', - value: ApprovalHistoryTab.flow, - isActive: activeTab == ApprovalHistoryTab.flow, - enabled: true, - ), - const SizedBox(width: 8), - _buildTabButton( - label: '감사 로그', - value: ApprovalHistoryTab.audit, - isActive: activeTab == ApprovalHistoryTab.audit, - enabled: !_controller.isSelectionForbidden, - ), - ], - ); - } - - Widget _buildTabButton({ - required String label, - required ApprovalHistoryTab value, - required bool isActive, - required bool enabled, - }) { - if (isActive) { - return ShadButton(onPressed: null, child: Text(label)); - } - return ShadButton.outline( - onPressed: enabled ? () => _handleTabChange(value) : null, - child: Text(label), - ); - } - - Widget _buildFlowTabContent(ShadThemeData theme, ApprovalFlow? flow) { - if (flow == null) { - return _buildDetailPlaceholder(theme); - } - return ApprovalFlowTimeline(flow: flow, dateFormat: _dateTimeFormat); - } - - Widget _buildAuditTabContent( - ShadThemeData theme, - List logs, - SuperportTablePagination? pagination, - ) { - final record = _selectedRecord; - if (record == null) { - return _buildDetailPlaceholder(theme); - } - final auditRange = _currentAuditRange(); - final actorId = _controller.auditActorId; - final actorText = actorId?.toString() ?? ''; - final currentActionCode = _controller.auditActionCode ?? _auditActionAll; - final actionOptions = _controller.auditActions; - final isLoadingAudit = _controller.isLoadingAudit; - final hasAuditFilters = _controller.hasActiveAuditFilters; - if (_auditActorIdController.text.trim() != actorText) { - _auditActorIdController.value = TextEditingValue(text: actorText); - } - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - AbsorbPointer( - absorbing: isLoadingAudit, - child: SizedBox( - width: 240, - child: ApprovalApproverAutocompleteField( - key: ValueKey(actorId ?? 'all'), - idController: _auditActorIdController, - hintText: '행위자 검색', - onSelected: _handleAuditActorSelected, - ), - ), - ), - SizedBox( - width: 200, - child: ShadSelect( - key: ValueKey(currentActionCode), - initialValue: currentActionCode, - selectedOptionBuilder: (context, value) => - Text(_auditActionLabel(value, actionOptions)), - onChanged: isLoadingAudit - ? null - : (value) { - if (value == null || value == _auditActionAll) { - _controller.updateAuditAction(null); - } else { - _controller.updateAuditAction(value); - } - _refreshAuditForSelectedRecord(resetPage: true); - }, - options: [ - const ShadOption( - value: _auditActionAll, - child: Text('전체 행위'), - ), - ...actionOptions - .where( - (action) => - action.code != null && - action.code!.trim().isNotEmpty, - ) - .map( - (action) => ShadOption( - value: action.code!, - child: Text(action.name), - ), - ), - ], - ), - ), - SizedBox( - width: 240, - child: SuperportDateRangePickerButton( - value: auditRange, - dateFormat: intl.DateFormat('yyyy-MM-dd'), - enabled: !isLoadingAudit, - firstDate: DateTime(DateTime.now().year - 5), - lastDate: DateTime(DateTime.now().year + 1), - initialDateRange: - auditRange ?? - DateTimeRange( - start: DateTime.now().subtract(const Duration(days: 7)), - end: DateTime.now(), - ), - onChanged: (range) { - if (range == null) { - _controller.updateAuditDateRange(null, null); - _refreshAuditForSelectedRecord(resetPage: true); - return; - } - _controller.updateAuditDateRange(range.start, range.end); - _refreshAuditForSelectedRecord(resetPage: true); - }, - ), - ), - if (auditRange != null) - ShadButton.ghost( - onPressed: isLoadingAudit - ? null - : () { - _controller.updateAuditDateRange(null, null); - _refreshAuditForSelectedRecord(resetPage: true); - }, - child: const Text('기간 초기화'), - ), - if (hasAuditFilters) - ShadButton.ghost( - onPressed: isLoadingAudit ? null : _clearAuditFilters, - child: const Text('감사 필터 초기화'), - ), - ], - ), - const SizedBox(height: 16), - ApprovalAuditLogTable( - logs: logs, - dateFormat: _dateTimeFormat, - pagination: pagination, - onPageChange: (page) => _controller.fetchAuditLogs( - approvalId: record.approvalId, - page: page, - ), - onPageSizeChange: (size) { - _controller.updateAuditPageSize(size); - _controller.fetchAuditLogs(approvalId: record.approvalId, page: 1); - }, - isLoading: isLoadingAudit, - ), - ], - ); - } - - ShadTableCell _buildTableCell( - ShadThemeData theme, - Widget child, { - bool selected = false, - Alignment alignment = Alignment.centerLeft, - }) { - return ShadTableCell( - alignment: alignment, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - decoration: BoxDecoration( - color: selected - ? theme.colorScheme.primary.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: BorderRadius.circular(selected ? 8 : 6), - ), - child: child, - ), - ); - } - Widget _buildStepBadge(ShadThemeData theme, ApprovalHistoryRecord record) { if (record.stepOrder == null) { return Text('-', style: theme.textTheme.muted); @@ -1067,301 +504,29 @@ class _ApprovalHistoryEnabledPageState return '$from → ${record.toStatus.name}'; } - Future _handleSelectRecord(ApprovalHistoryRecord record) async { - if (_selectedRecord?.id == record.id && _controller.selectedFlow != null) { - return; - } - setState(() { - _selectedRecord = record; - }); - try { - await _controller.loadApprovalFlow(record.approvalId); - if (_controller.isSelectionForbidden) { - return; - } - if (_controller.activeTab == ApprovalHistoryTab.audit) { - await _controller.fetchAuditLogs(approvalId: record.approvalId); - } - } catch (_) { - // 오류 메시지는 컨트롤러 리스너에서 처리한다. - } - } - - void _handleTabChange(ApprovalHistoryTab tab) { - if (_controller.activeTab == tab) { - return; - } - _controller.updateActiveTab(tab); - final record = _selectedRecord; - if (tab == ApprovalHistoryTab.audit && record != null) { - if (_controller.isSelectionForbidden) { - return; - } - _controller.fetchAuditLogs(approvalId: record.approvalId); - } - } - - void _refreshAuditForSelectedRecord({bool resetPage = false}) { - final record = _selectedRecord; - if (record == null) { - return; - } - if (_controller.isSelectionForbidden) { - return; - } - final page = resetPage ? 1 : _controller.auditResult?.page ?? 1; - _controller.fetchAuditLogs(approvalId: record.approvalId, page: page); - } - - DateTimeRange? _currentAuditRange() { - final from = _controller.auditFrom; - final to = _controller.auditTo; - if (from == null || to == null) { - return null; - } - return DateTimeRange(start: from, end: to); - } - - String _auditActionLabel(String value, List actions) { - if (value == _auditActionAll) { - return '전체 행위'; - } - for (final action in actions) { - if (action.code == value) { - return action.name; - } - } - return '전체 행위'; - } - - Future _handleRecall(ApprovalFlow flow) async { - final user = _currentUser; - if (user == null) { - SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.'); - return; - } - final approvalId = flow.id; - if (approvalId == null) { - SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.'); - return; - } - final note = await _promptActionNote( - title: '결재 회수', - confirmLabel: '회수', - description: '회수 사유를 입력하세요. 입력하지 않아도 회수를 진행할 수 있습니다.', - ); - final refreshed = await _controller.refreshFlow(approvalId); - if (!mounted) { - return; - } - if (refreshed == null) { - SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'); - return; - } - final latestFlow = refreshed; - final sanitizedNote = note?.isEmpty == true ? null : note; - final transactionUpdatedAt = latestFlow.transactionUpdatedAt; - if (transactionUpdatedAt == null) { - SuperportToast.error( - context, - '연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.', - ); - return; - } - final input = ApprovalRecallInput( - approvalId: approvalId, - actorId: user.id, - note: sanitizedNote, - expectedUpdatedAt: latestFlow.approval.updatedAt, - transactionExpectedUpdatedAt: transactionUpdatedAt, - ); - final result = await _controller.recallApproval(input); - if (!mounted) { - return; - } - if (result != null) { - SuperportToast.success( - context, - '결재(${latestFlow.approvalNo}) 회수를 완료했습니다.', - ); - } - } - - Future _handleResubmit(ApprovalFlow flow) async { - final user = _currentUser; - if (user == null) { - SuperportToast.error(context, '현재 사용자 정보를 확인할 수 없습니다.'); - return; - } - final approvalId = flow.id; - if (approvalId == null) { - SuperportToast.error(context, '결재 식별자를 확인할 수 없습니다.'); - return; - } - final note = await _promptActionNote( - title: '결재 재상신', - confirmLabel: '재상신', - description: '재상신 시 전달할 메시지를 입력하세요. 입력하지 않아도 재상신됩니다.', - ); - final refreshed = await _controller.refreshFlow(approvalId); - if (!mounted) { - return; - } - if (refreshed == null) { - SuperportToast.error(context, '결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'); - return; - } - final latestFlow = refreshed; - final sanitizedNote = note?.isEmpty == true ? null : note; - final transactionUpdatedAt = latestFlow.transactionUpdatedAt; - if (transactionUpdatedAt == null) { - SuperportToast.error( - context, - '연동 전표 변경 시각을 확인할 수 없습니다. 화면을 새로고침한 뒤 다시 시도하세요.', - ); - return; - } - final steps = latestFlow.steps - .map( - (step) => ApprovalStepAssignmentItem( - stepOrder: step.stepOrder, - approverId: step.approver.id, - note: step.note, - ), - ) - .toList(growable: false); - final submission = ApprovalSubmissionInput( - transactionId: latestFlow.transactionId, - statusId: latestFlow.status.id, - requesterId: latestFlow.requester.id, - finalApproverId: latestFlow.finalApprover?.id, - note: latestFlow.note, - steps: steps, - ); - final input = ApprovalResubmissionInput( - approvalId: approvalId, - actorId: user.id, - submission: submission, - note: sanitizedNote, - expectedUpdatedAt: latestFlow.approval.updatedAt, - transactionExpectedUpdatedAt: transactionUpdatedAt, - ); - final result = await _controller.resubmitApproval(input); - if (!mounted) { - return; - } - if (result != null) { - SuperportToast.success( - context, - '결재(${latestFlow.approvalNo}) 재상신을 완료했습니다.', - ); - } - } - - bool _canRecall(ApprovalFlow flow) { - if (flow.status.isTerminal) { - return false; - } - if (flow.steps.isEmpty) { - return false; - } - final first = flow.steps.first; - return first.decidedAt == null; - } - - bool _canResubmit(ApprovalFlow flow) { - if (!flow.status.isTerminal) { - return false; - } - final statusName = flow.status.name.toLowerCase(); - return statusName.contains('반려') || statusName.contains('reject'); - } - - String? _recallDisabledReason(ApprovalFlow flow) { - if (flow.status.isTerminal) { - return '결재가 종료되었습니다.'; - } - if (flow.steps.isEmpty) { - return '결재 단계 정보가 없습니다.'; - } - if (flow.steps.first.decidedAt != null) { - return '첫 승인자가 이미 결정을 내려 회수할 수 없습니다.'; - } - return null; - } - - String? _resubmitDisabledReason(ApprovalFlow flow) { - if (!flow.status.isTerminal) { - return '결재가 아직 진행 중입니다.'; - } - final statusName = flow.status.name.toLowerCase(); - if (!(statusName.contains('반려') || statusName.contains('reject'))) { - return '반려 상태에서만 재상신할 수 있습니다.'; - } - return null; - } - - Future _promptActionNote({ - required String title, - required String confirmLabel, - required String description, - }) async { - final theme = ShadTheme.of(context); - final controller = TextEditingController(); - String? result; - await showSuperportDialog( + Future _openHistoryDetail(ApprovalHistoryRecord record) async { + await showApprovalHistoryDetailDialog( context: context, - title: title, - description: description, - headerActions: [ - if (_controller.selectedFlow != null) - ApprovalStatusBadge( - label: _controller.selectedFlow!.status.name, - colorHex: _controller.selectedFlow!.status.color, - ), - ], - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).maybePop(), - child: const Text('취소'), - ), - ShadButton( - onPressed: () { - result = controller.text.trim(); - Navigator.of(context, rootNavigator: true).maybePop(); - }, - child: Text(confirmLabel), - ), - ], - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('사유는 선택 입력입니다. 비워두면 전달되지 않습니다.', style: theme.textTheme.muted), - const SizedBox(height: 12), - ShadInput( - controller: controller, - maxLines: 4, - placeholder: const Text('사유 입력'), - ), - ], - ), + controller: _controller, + record: record, + dateFormat: _dateTimeFormat, + currentUser: _currentUser, ); - controller.dispose(); - if (result != null && result!.isEmpty) { - return null; - } - return result; - } - - void _clearSelection() { - _controller.clearSelection(); - if (_selectedRecord == null) { + if (!mounted) { return; } - setState(() { - _selectedRecord = null; - }); + _controller.clearSelection(); + } + + ShadTableCell _buildTableCell( + ShadThemeData theme, + Widget child, { + Alignment alignment = Alignment.centerLeft, + }) { + return ShadTableCell( + alignment: alignment, + child: DefaultTextStyle(style: theme.textTheme.small, child: child), + ); } } diff --git a/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart b/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart new file mode 100644 index 0000000..1306cc0 --- /dev/null +++ b/lib/features/approvals/presentation/dialogs/approval_detail_dialog.dart @@ -0,0 +1,1046 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../widgets/components/feedback.dart'; +import '../../../../widgets/components/superport_dialog.dart'; +import '../../../../widgets/components/superport_detail_dialog.dart'; +import '../../domain/entities/approval.dart'; +import '../../domain/entities/approval_template.dart'; +import '../../presentation/controllers/approval_controller.dart'; + +/// 결재 상세 다이얼로그를 표시한다. +/// +/// 선택된 결재의 개요, 단계, 이력을 SuperportDetailDialog 패턴으로 노출하며 +/// 단계 행위와 템플릿 적용을 동일 팝업 안에서 수행한다. +Future showApprovalDetailDialog({ + required BuildContext context, + required ApprovalController controller, + required intl.DateFormat dateFormat, + required bool canPerformStepActions, + required bool canApplyTemplate, +}) { + return showSuperportDialog( + context: context, + title: '결재 상세', + headerActions: [ + ShadButton.ghost( + key: const ValueKey('approval_detail_refresh'), + onPressed: controller.isLoadingDetail + ? null + : () async { + final selected = controller.selected; + if (selected?.id == null) { + SuperportToast.warning(context, '상세 조회 대상이 없습니다.'); + return; + } + await controller.selectApproval(selected!.id!); + final error = controller.errorMessage; + if (error != null && context.mounted) { + SuperportToast.error(context, error); + controller.clearError(); + return; + } + if (context.mounted) { + SuperportToast.success(context, '결재 상세를 새로고침했습니다.'); + } + }, + child: const Icon(lucide.LucideIcons.refreshCw, size: 16), + ), + ], + body: ApprovalDetailDialogView( + controller: controller, + dateFormat: dateFormat, + canPerformStepActions: canPerformStepActions, + canApplyTemplate: canApplyTemplate, + ), + constraints: const BoxConstraints(maxWidth: 880), + barrierDismissible: true, + ); +} + +class ApprovalDetailDialogView extends StatefulWidget { + const ApprovalDetailDialogView({ + super.key, + required this.controller, + required this.dateFormat, + required this.canPerformStepActions, + required this.canApplyTemplate, + }); + + final ApprovalController controller; + final intl.DateFormat dateFormat; + final bool canPerformStepActions; + final bool canApplyTemplate; + + @override + State createState() => + _ApprovalDetailDialogViewState(); +} + +class _ApprovalDetailDialogViewState extends State { + int? _selectedTemplateId; + + @override + void initState() { + super.initState(); + _initializeTemplateSelection(); + } + + @override + void didUpdateWidget(covariant ApprovalDetailDialogView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + _initializeTemplateSelection(); + } + } + + void _initializeTemplateSelection() { + final templates = widget.controller.templates; + if (templates.isEmpty) { + setState(() => _selectedTemplateId = null); + return; + } + final current = _selectedTemplateId; + if (current != null && + templates.any((template) => template.id == current)) { + return; + } + setState(() => _selectedTemplateId = templates.first.id); + } + + void _ensureTemplateSelectionValid(List templates) { + if (templates.isEmpty) { + if (_selectedTemplateId != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedTemplateId = null); + }); + } + return; + } + if (_selectedTemplateId == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedTemplateId = templates.first.id); + }); + return; + } + final exists = templates.any( + (template) => template.id == _selectedTemplateId, + ); + if (!exists) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() => _selectedTemplateId = templates.first.id); + }); + } + } + + Future _handleApplyTemplate(int templateId) async { + final success = await widget.controller.applyTemplate(templateId); + if (!mounted) { + return; + } + final error = widget.controller.errorMessage; + if (error != null) { + SuperportToast.error(context, error); + widget.controller.clearError(); + return; + } + if (success) { + SuperportToast.success(context, '결재 템플릿을 적용했습니다.'); + } + } + + Future _handleStepAction( + ApprovalStep step, + ApprovalStepActionType type, + ) async { + final result = await _showActionConfirmDialog(step, type); + if (result == null) { + return; + } + final success = await widget.controller.performStepAction( + step: step, + type: type, + note: result, + ); + if (!mounted) { + return; + } + final error = widget.controller.errorMessage; + if (error != null) { + SuperportToast.error(context, error); + widget.controller.clearError(); + return; + } + if (success) { + SuperportToast.success(context, _successMessage(type)); + } + } + + Future _showActionConfirmDialog( + ApprovalStep step, + ApprovalStepActionType type, + ) async { + final requireNote = type == ApprovalStepActionType.comment; + final noteController = TextEditingController(); + String? errorText; + final confirmed = await showSuperportDialog( + context: context, + title: _dialogTitle(type), + constraints: const BoxConstraints(maxWidth: 420), + onSubmit: () { + final note = noteController.text.trim(); + if (requireNote && note.isEmpty) { + errorText = '비고를 입력하세요.'; + return; + } + Navigator.of( + context, + rootNavigator: true, + ).maybePop(note.isEmpty ? null : note); + }, + body: StatefulBuilder( + builder: (dialogContext, setState) { + final theme = ShadTheme.of(dialogContext); + final materialTheme = Theme.of(dialogContext); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '선택 단계: Step ${step.stepOrder}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text('승인자: ${step.approver.name}', style: theme.textTheme.small), + const SizedBox(height: 4), + Text('현재 상태: ${step.status.name}', style: theme.textTheme.small), + const SizedBox(height: 12), + Text(_dialogDescription(type), style: theme.textTheme.muted), + const SizedBox(height: 12), + Text('비고', style: theme.textTheme.small), + const SizedBox(height: 8), + ShadTextarea( + controller: noteController, + minHeight: 120, + maxHeight: 220, + ), + if (requireNote) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + '코멘트에는 비고 입력이 필요합니다.', + style: theme.textTheme.muted, + ), + ), + if (errorText != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + errorText!, + style: theme.textTheme.small.copyWith( + color: materialTheme.colorScheme.error, + ), + ), + ), + ], + ); + }, + ), + actions: [ + ShadButton.ghost( + onPressed: () => + Navigator.of(context, rootNavigator: true).maybePop(), + child: const Text('취소'), + ), + ShadButton( + onPressed: () { + final note = noteController.text.trim(); + if (requireNote && note.isEmpty) { + errorText = '비고를 입력하세요.'; + return; + } + Navigator.of( + context, + rootNavigator: true, + ).maybePop(note.isEmpty ? null : note); + }, + child: Text(_dialogConfirmLabel(type)), + ), + ], + ); + return confirmed; + } + + List _buildSummaryBadges(Approval approval) { + final badges = [ShadBadge(child: Text(approval.status.name))]; + badges.add( + approval.isActive + ? const ShadBadge.outline(child: Text('사용중')) + : const ShadBadge.outline(child: Text('비활성')), + ); + if (approval.isDeleted) { + badges.add(const ShadBadge.destructive(child: Text('삭제됨'))); + } + return badges; + } + + List _buildMetadata({ + required Approval approval, + required bool canProceed, + required String? cannotProceedReason, + }) { + final metadata = [ + if (approval.id != null) + SuperportDetailMetadata.text(label: 'ID', value: '${approval.id}'), + SuperportDetailMetadata.text(label: '결재번호', value: approval.approvalNo), + SuperportDetailMetadata.text( + label: '트랜잭션번호', + value: approval.transactionNo, + ), + SuperportDetailMetadata.text( + label: '현재 단계', + value: _formatCurrentStep(approval), + ), + SuperportDetailMetadata.text( + label: '상신자', + value: '${approval.requester.name} (${approval.requester.employeeNo})', + ), + SuperportDetailMetadata.text( + label: '상신일시', + value: widget.dateFormat.format(approval.requestedAt.toLocal()), + ), + SuperportDetailMetadata.text( + label: '최종결정일시', + value: _formatDate(approval.decidedAt), + ), + SuperportDetailMetadata.text( + label: '트랜잭션 갱신일시', + value: _formatDate(approval.transactionUpdatedAt), + ), + SuperportDetailMetadata.text( + label: '사용 상태', + value: approval.isActive ? '사용중' : '비활성', + ), + SuperportDetailMetadata.text( + label: '삭제 여부', + value: approval.isDeleted ? '삭제됨' : '정상', + ), + SuperportDetailMetadata.text( + label: '비고', + value: approval.note?.trim().isEmpty ?? true + ? '-' + : approval.note!.trim(), + ), + if (!canProceed && cannotProceedReason != null) + SuperportDetailMetadata.text( + label: '진행 제한 사유', + value: cannotProceedReason, + ), + ]; + return metadata; + } + + String _formatCurrentStep(Approval approval) { + final step = approval.currentStep; + if (step == null) { + return '-'; + } + return 'Step ${step.stepOrder} · ${step.approver.name}'; + } + + String _formatDate(DateTime? value) { + if (value == null) { + return '-'; + } + return widget.dateFormat.format(value.toLocal()); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: widget.controller, + builder: (context, _) { + final approval = widget.controller.selected; + final isLoading = widget.controller.isLoadingDetail; + final templates = widget.controller.templates; + final isLoadingTemplates = widget.controller.isLoadingTemplates; + final isApplyingTemplate = widget.controller.isApplyingTemplate; + final applyingTemplateId = widget.controller.applyingTemplateId; + final hasActionOptions = widget.controller.hasActionOptions; + final isLoadingActions = widget.controller.isLoadingActions; + final isPerformingAction = widget.controller.isPerformingAction; + final processingStepId = widget.controller.processingStepId; + final canProceed = widget.controller.canProceedSelected; + final cannotProceedReason = widget.controller.cannotProceedReason; + _ensureTemplateSelectionValid(templates); + + final theme = ShadTheme.of(context); + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (approval == null) { + return Center( + child: Text('선택된 결재 정보가 없습니다.', style: theme.textTheme.muted), + ); + } + + final summary = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('결재번호 ${approval.approvalNo}', style: theme.textTheme.h4), + const SizedBox(height: 4), + Text( + '트랜잭션 ${approval.transactionNo}', + style: theme.textTheme.muted, + ), + ], + ); + final summaryBadges = _buildSummaryBadges(approval); + final metadata = _buildMetadata( + approval: approval, + canProceed: canProceed, + cannotProceedReason: cannotProceedReason, + ); + + final sections = [ + SuperportDetailDialogSection( + key: const ValueKey('approval_detail_tab_steps'), + id: 'steps', + label: '단계', + icon: lucide.LucideIcons.listChecks, + builder: (_) => _ApprovalStepSection( + approval: approval, + dateFormat: widget.dateFormat, + templates: templates, + isLoadingTemplates: isLoadingTemplates, + isApplyingTemplate: isApplyingTemplate, + applyingTemplateId: applyingTemplateId, + selectedTemplateId: _selectedTemplateId, + canApplyTemplate: widget.canApplyTemplate, + canPerformStepActions: widget.canPerformStepActions, + hasActionOptions: hasActionOptions, + isLoadingActions: isLoadingActions, + isPerformingAction: isPerformingAction, + processingStepId: processingStepId, + canProceed: canProceed, + cannotProceedReason: cannotProceedReason, + onSelectTemplate: (id) => setState(() { + _selectedTemplateId = id; + }), + onApplyTemplate: _handleApplyTemplate, + onReloadTemplates: () => + widget.controller.loadTemplates(force: true), + onAction: _handleStepAction, + ), + ), + SuperportDetailDialogSection( + id: 'histories', + label: '이력', + icon: lucide.LucideIcons.history, + builder: (_) => _ApprovalHistorySection( + histories: approval.histories, + dateFormat: widget.dateFormat, + ), + ), + ]; + + return SuperportDetailDialog( + sections: sections, + summary: summary, + summaryBadges: summaryBadges, + metadata: metadata, + initialSectionId: 'steps', + emptyPlaceholder: Text( + '표시할 결재 상세 정보가 없습니다.', + style: theme.textTheme.muted, + ), + ); + }, + ); + } +} + +class _ApprovalStepSection extends StatelessWidget { + const _ApprovalStepSection({ + required this.approval, + required this.dateFormat, + required this.templates, + required this.isLoadingTemplates, + required this.isApplyingTemplate, + required this.applyingTemplateId, + required this.selectedTemplateId, + required this.canApplyTemplate, + required this.canPerformStepActions, + required this.hasActionOptions, + required this.isLoadingActions, + required this.isPerformingAction, + required this.processingStepId, + required this.canProceed, + required this.cannotProceedReason, + required this.onSelectTemplate, + required this.onApplyTemplate, + required this.onReloadTemplates, + required this.onAction, + }); + + final Approval approval; + final intl.DateFormat dateFormat; + final List templates; + final bool isLoadingTemplates; + final bool isApplyingTemplate; + final int? applyingTemplateId; + final int? selectedTemplateId; + final bool canApplyTemplate; + final bool canPerformStepActions; + final bool hasActionOptions; + final bool isLoadingActions; + final bool isPerformingAction; + final int? processingStepId; + final bool canProceed; + final String? cannotProceedReason; + final void Function(int?) onSelectTemplate; + final Future Function(int templateId) onApplyTemplate; + final Future Function() onReloadTemplates; + final Future Function(ApprovalStep step, ApprovalStepActionType type) + onAction; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _TemplateToolbar( + templates: templates, + isLoading: isLoadingTemplates, + selectedTemplateId: selectedTemplateId, + isApplyingTemplate: isApplyingTemplate, + applyingTemplateId: applyingTemplateId, + canApplyTemplate: canApplyTemplate, + onSelectTemplate: onSelectTemplate, + onApplyTemplate: (templateId) async { + await onApplyTemplate(templateId); + }, + onReload: onReloadTemplates, + ), + const SizedBox(height: 18), + if (approval.steps.isEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.secondary.withValues(alpha: 0.05), + border: Border.all(color: theme.colorScheme.border), + ), + child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted), + ) + else + for (var index = 0; index < approval.steps.length; index++) ...[ + if (index > 0) const SizedBox(height: 12), + Builder( + builder: (context) { + final step = approval.steps[index]; + final isProcessing = + isPerformingAction && + processingStepId != null && + processingStepId == step.id; + final disabledReason = _disabledReason( + step, + canPerformStepActions, + canProceed, + cannotProceedReason, + ); + final enabled = disabledReason == null; + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Step ${step.stepOrder}', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ShadBadge(child: Text(step.status.name)), + ], + ), + const SizedBox(height: 12), + _KeyValueRow( + label: '승인자', + value: + '${step.approver.name} (${step.approver.employeeNo})', + ), + _KeyValueRow( + label: '배정일시', + value: dateFormat.format(step.assignedAt.toLocal()), + ), + _KeyValueRow( + label: '결정일시', + value: step.decidedAt == null + ? '-' + : dateFormat.format(step.decidedAt!.toLocal()), + ), + if (step.note?.trim().isNotEmpty ?? false) + _KeyValueRow(label: '비고', value: step.note!.trim()), + const SizedBox(height: 12), + Wrap( + spacing: 12, + runSpacing: 8, + children: ApprovalStepActionType.values + .map( + (type) => _buildActionButton( + context: context, + step: step, + type: type, + enabled: enabled, + isProcessing: isProcessing, + disabledReason: disabledReason, + hasActionOptions: hasActionOptions, + isLoadingActions: isLoadingActions, + ), + ) + .toList(), + ), + if (disabledReason != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + disabledReason, + style: theme.textTheme.muted, + ), + ), + ], + ), + ), + ); + }, + ), + ], + const SizedBox(height: 12), + ], + ); + } + + Widget _buildActionButton({ + required BuildContext context, + required ApprovalStep step, + required ApprovalStepActionType type, + required bool enabled, + required bool isProcessing, + required String? disabledReason, + required bool hasActionOptions, + required bool isLoadingActions, + }) { + final theme = ShadTheme.of(context); + final label = _actionLabel(type); + final icon = _actionIcon(type); + final buttonKey = ValueKey( + 'step_action_${step.id ?? step.stepOrder}_${type.name}', + ); + final child = Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isProcessing) ...[ + SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + theme.colorScheme.primary, + ), + ), + ), + const SizedBox(width: 8), + Text('$label 처리 중...'), + ] else ...[ + Icon(icon, size: 16), + const SizedBox(width: 8), + Text(label), + ], + ], + ); + final effectiveReason = + disabledReason ?? + (!hasActionOptions + ? '사용 가능한 결재 행위가 없습니다.' + : (isLoadingActions ? '행위를 불러오는 중입니다.' : null)); + final action = enabled ? () => onAction(step, type) : null; + + Widget button; + switch (type) { + case ApprovalStepActionType.approve: + button = ShadButton(key: buttonKey, onPressed: action, child: child); + break; + case ApprovalStepActionType.reject: + button = ShadButton.outline( + key: buttonKey, + onPressed: action, + child: child, + ); + break; + case ApprovalStepActionType.comment: + button = ShadButton.ghost( + key: buttonKey, + onPressed: action, + child: child, + ); + break; + } + + if (effectiveReason != null && action == null) { + return Tooltip(message: effectiveReason, child: button); + } + return button; + } + + String? _disabledReason( + ApprovalStep step, + bool canPerform, + bool canProceed, + String? cannotProceedReason, + ) { + if (!canPerform) { + return '결재 권한이 없어 단계 행위를 실행할 수 없습니다.'; + } + if (!canProceed) { + return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.'; + } + if (step.id == null) { + return '단계 ID가 없어 행위를 수행할 수 없습니다.'; + } + return null; + } + + IconData _actionIcon(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return lucide.LucideIcons.check; + case ApprovalStepActionType.reject: + return lucide.LucideIcons.x; + case ApprovalStepActionType.comment: + return lucide.LucideIcons.messageCircle; + } + } + + String _actionLabel(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '승인'; + case ApprovalStepActionType.reject: + return '반려'; + case ApprovalStepActionType.comment: + return '코멘트'; + } + } +} + +class _ApprovalHistorySection extends StatelessWidget { + const _ApprovalHistorySection({ + required this.histories, + required this.dateFormat, + }); + + final List histories; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (histories.isEmpty) { + return Center(child: Text('결재 이력이 없습니다.', style: theme.textTheme.muted)); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var index = 0; index < histories.length; index++) ...[ + if (index > 0) const SizedBox(height: 16), + Builder( + builder: (_) { + final history = histories[index]; + return ShadCard( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + history.toStatus.name, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + dateFormat.format(history.actionAt.toLocal()), + style: theme.textTheme.muted, + ), + ], + ), + const SizedBox(height: 8), + Text(history.approver.name, style: theme.textTheme.small), + const SizedBox(height: 4), + Text(history.action.name, style: theme.textTheme.muted), + if (history.note?.trim().isNotEmpty ?? false) ...[ + const SizedBox(height: 8), + Text( + history.note!.trim(), + style: theme.textTheme.small, + ), + ], + ], + ), + ), + ); + }, + ), + ], + ], + ); + } +} + +class _TemplateToolbar extends StatelessWidget { + const _TemplateToolbar({ + required this.templates, + required this.isLoading, + required this.selectedTemplateId, + required this.isApplyingTemplate, + required this.applyingTemplateId, + required this.canApplyTemplate, + required this.onSelectTemplate, + required this.onApplyTemplate, + required this.onReload, + }); + + final List templates; + final bool isLoading; + final int? selectedTemplateId; + final bool isApplyingTemplate; + final int? applyingTemplateId; + final bool canApplyTemplate; + final void Function(int?) onSelectTemplate; + final Future Function(int templateId) onApplyTemplate; + final Future Function() onReload; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + ApprovalTemplate? selectedTemplate; + if (selectedTemplateId != null) { + for (final template in templates) { + if (template.id == selectedTemplateId) { + selectedTemplate = template; + break; + } + } + } + selectedTemplate ??= templates.isEmpty ? null : templates.first; + + Widget applyButton = ShadButton( + key: const ValueKey('approval_apply_template_button'), + onPressed: + !canApplyTemplate || selectedTemplateId == null || isApplyingTemplate + ? null + : () => onApplyTemplate(selectedTemplateId!), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isApplyingTemplate && applyingTemplateId == selectedTemplateId) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + const Icon(lucide.LucideIcons.layoutList, size: 16), + const SizedBox(width: 8), + Text( + isApplyingTemplate && applyingTemplateId == selectedTemplateId + ? '적용 중...' + : '템플릿 적용', + ), + ], + ), + ); + + if (!canApplyTemplate) { + applyButton = Tooltip( + message: '템플릿을 적용할 권한이 없습니다.', + child: applyButton, + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadSelect( + key: ValueKey(selectedTemplateId), + placeholder: const Text('템플릿 선택'), + initialValue: selectedTemplateId, + onChanged: canApplyTemplate ? onSelectTemplate : null, + selectedOptionBuilder: (context, value) { + if (templates.isEmpty) { + return const Text('템플릿 선택'); + } + final match = templates.firstWhere( + (template) => template.id == value, + orElse: () => templates.first, + ); + return Text(match.name); + }, + options: templates + .map( + (template) => ShadOption( + value: template.id, + child: Text(template.name), + ), + ) + .toList(), + ), + ), + const SizedBox(width: 12), + ShadButton.outline( + onPressed: isLoading ? null : () => onReload(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(lucide.LucideIcons.refreshCw, size: 16), + SizedBox(width: 6), + Text('새로고침'), + ], + ), + ), + const SizedBox(width: 12), + applyButton, + ], + ), + if (isLoading) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Row( + children: [ + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Text('템플릿을 불러오는 중입니다.', style: theme.textTheme.small), + ], + ), + ) + else if (selectedTemplate != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _templateSummary(selectedTemplate), + style: theme.textTheme.small, + ), + ), + ], + ); + } + + String _templateSummary(ApprovalTemplate template) { + final stepCount = template.steps.length; + final description = template.description?.trim(); + final buffer = StringBuffer() + ..write('선택된 템플릿: ${template.name} (단계 $stepCount개)'); + if (description != null && description.isNotEmpty) { + buffer.write(' · $description'); + } + return buffer.toString(); + } +} + +class _KeyValueRow extends StatelessWidget { + const _KeyValueRow({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Expanded(child: Text(value, style: theme.textTheme.small)), + ], + ), + ); + } +} + +String _dialogTitle(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '결재 승인'; + case ApprovalStepActionType.reject: + return '결재 반려'; + case ApprovalStepActionType.comment: + return '코멘트 작성'; + } +} + +String _dialogDescription(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '해당 단계를 승인하면 다음 결재 단계로 진행합니다.'; + case ApprovalStepActionType.reject: + return '단계를 반려하면 결재가 반려 상태로 전환됩니다.'; + case ApprovalStepActionType.comment: + return '승인자에게 전달할 의견을 작성합니다.'; + } +} + +String _dialogConfirmLabel(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '승인'; + case ApprovalStepActionType.reject: + return '반려'; + case ApprovalStepActionType.comment: + return '등록'; + } +} + +String _successMessage(ApprovalStepActionType type) { + switch (type) { + case ApprovalStepActionType.approve: + return '단계를 승인했습니다.'; + case ApprovalStepActionType.reject: + return '단계를 반려했습니다.'; + case ApprovalStepActionType.comment: + return '코멘트를 등록했습니다.'; + } +} diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index e192ccd..54ae3a7 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -17,7 +19,6 @@ import '../../../../widgets/components/superport_table.dart'; import '../../../../widgets/components/superport_pagination_controls.dart'; import '../../../../widgets/components/feature_disabled_placeholder.dart'; import '../../domain/entities/approval.dart'; -import '../../domain/entities/approval_template.dart'; import '../../domain/repositories/approval_repository.dart'; import '../../domain/repositories/approval_template_repository.dart'; import '../../domain/usecases/get_approval_draft_use_case.dart'; @@ -26,6 +27,7 @@ import '../../domain/usecases/save_approval_draft_use_case.dart'; import '../../../inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; import '../../../inventory/shared/widgets/employee_autocomplete_field.dart'; import '../controllers/approval_controller.dart'; +import '../dialogs/approval_detail_dialog.dart'; const _approvalsResourcePath = PermissionResources.approvals; @@ -85,12 +87,10 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { final TextEditingController _transactionController = TextEditingController(); final TextEditingController _requesterController = TextEditingController(); final FocusNode _transactionFocus = FocusNode(); - final GlobalKey _detailSectionKey = GlobalKey(); InventoryEmployeeSuggestion? _selectedRequester; final intl.DateFormat _dateTimeFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; String? _lastAccessDeniedMessage; - int? _selectedTemplateId; String? _pendingRouteSelection; @override @@ -181,7 +181,7 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { if (approval.approvalNo == target && approval.id != null) { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - _selectApproval(approval.id!); + unawaited(_openApprovalDetailDialog(approval)); }); break; } @@ -189,28 +189,60 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { _pendingRouteSelection = null; } - Future _selectApproval(int id, {bool reveal = true}) async { + Future _openApprovalDetailDialog(Approval approval) async { + final id = approval.id; + if (id == null) { + SuperportToast.error(context, 'ID 정보가 없어 상세를 열 수 없습니다.'); + return; + } + await _controller.selectApproval(id); if (!mounted) { return; } - if (!reveal) { - return; - } - await _revealDetailSection(); - } - Future _revealDetailSection() async { - final detailContext = _detailSectionKey.currentContext; - if (detailContext == null) { + final selected = _controller.selected; + final error = _controller.errorMessage; + if (selected == null) { + if (error != null) { + SuperportToast.error(context, error); + _controller.clearError(); + } else { + SuperportToast.error(context, '결재 상세 정보를 불러오지 못했습니다.'); + } return; } - await Scrollable.ensureVisible( - detailContext, - duration: const Duration(milliseconds: 300), - alignment: 0.05, - curve: Curves.easeInOut, + + if (_controller.templates.isEmpty && !_controller.isLoadingTemplates) { + await _controller.loadTemplates(force: true); + if (!mounted) { + return; + } + } + + final permissionScope = PermissionScope.of(context); + final canPerformStepActions = permissionScope.can( + _approvalsResourcePath, + PermissionAction.approve, ); + final canApplyTemplate = permissionScope.can( + _approvalsResourcePath, + PermissionAction.edit, + ); + + await showApprovalDetailDialog( + context: context, + controller: _controller, + dateFormat: _dateTimeFormat, + canPerformStepActions: canPerformStepActions, + canApplyTemplate: canApplyTemplate, + ); + + if (!mounted) { + return; + } + + _controller.clearSelection(); } @override @@ -226,7 +258,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - final permissionManager = PermissionScope.of(context); return AnimatedBuilder( animation: _controller, @@ -236,41 +267,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { if (result != null) { _applyRouteSelectionIfNeeded(approvals); } - final selectedApproval = _controller.selected; final totalCount = result?.total ?? 0; final currentPage = result?.page ?? 1; final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final isLoadingActions = _controller.isLoadingActions; - final isPerformingAction = _controller.isPerformingAction; - final processingStepId = _controller.processingStepId; - final hasActionOptions = _controller.hasActionOptions; - final templates = _controller.templates; - final isLoadingTemplates = _controller.isLoadingTemplates; - final isApplyingTemplate = _controller.isApplyingTemplate; - final applyingTemplateId = _controller.applyingTemplateId; - final canPerformStepActions = permissionManager.can( - _approvalsResourcePath, - PermissionAction.approve, - ); - final canManageTemplates = permissionManager.can( - _approvalsResourcePath, - PermissionAction.edit, - ); - - if (templates.isNotEmpty && _selectedTemplateId == null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - setState(() => _selectedTemplateId = templates.first.id); - }); - } else if (_selectedTemplateId != null && - templates.every((template) => template.id != _selectedTemplateId)) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; - setState(() => _selectedTemplateId = null); - }); - } return AppLayout( title: '결재 관리', @@ -405,46 +406,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { approvals: approvals, dateFormat: _dateTimeFormat, onView: (approval) { - final id = approval.id; - if (id != null) { - _selectApproval(id); - } + unawaited(_openApprovalDetailDialog(approval)); }, ), ), const SizedBox(height: 24), - _DetailSection( - key: _detailSectionKey, - approval: selectedApproval, - isLoading: _controller.isLoadingDetail, - isLoadingActions: isLoadingActions, - isPerformingAction: isPerformingAction, - processingStepId: processingStepId, - hasActionOptions: hasActionOptions, - templates: templates, - isLoadingTemplates: isLoadingTemplates, - isApplyingTemplate: isApplyingTemplate, - applyingTemplateId: applyingTemplateId, - selectedTemplateId: _selectedTemplateId, - canPerformStepActions: canPerformStepActions, - canApplyTemplate: canManageTemplates, - canProceed: _controller.canProceedSelected, - cannotProceedReason: _controller.cannotProceedReason, - dateFormat: _dateTimeFormat, - onRefresh: () { - final id = selectedApproval?.id; - if (id != null) { - _selectApproval(id, reveal: false); - } - }, - onClose: selectedApproval == null - ? null - : _controller.clearSelection, - onSelectTemplate: _handleSelectTemplate, - onApplyTemplate: _handleApplyTemplate, - onReloadTemplates: () => _controller.loadTemplates(force: true), - onAction: _handleStepAction, - ), ], ), ); @@ -803,230 +769,8 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { _controller.statusFilter != ApprovalStatusFilter.all; } - Future _handleStepAction( - ApprovalStep step, - ApprovalStepActionType type, - ) async { - final result = await _showStepActionDialog(step, type); - if (result == null) { - return; - } - final success = await _controller.performStepAction( - step: step, - type: type, - note: result.note, - ); - if (!mounted || !success) { - return; - } - SuperportToast.success(context, _successMessage(type)); - } - - void _handleSelectTemplate(int? templateId) { - setState(() => _selectedTemplateId = templateId); - } - - Future _handleApplyTemplate(int templateId) async { - ApprovalTemplate? template; - for (final item in _controller.templates) { - if (item.id == templateId) { - template = item; - break; - } - } - if (template == null) { - SuperportToast.error(context, '선택한 템플릿 정보를 찾을 수 없습니다.'); - return; - } - - final confirmed = await _showTemplateApplyConfirm(template); - if (!confirmed) { - return; - } - - final success = await _controller.applyTemplate(templateId); - if (!mounted || !success) { - return; - } - - SuperportToast.success(context, '템플릿 "${template.name}"을(를) 적용했습니다.'); - } - - Future<_StepActionDialogResult?> _showStepActionDialog( - ApprovalStep step, - ApprovalStepActionType type, - ) async { - final noteController = TextEditingController(); - final requireNote = type == ApprovalStepActionType.comment; - final dialogResult = await showDialog<_StepActionDialogResult>( - context: context, - builder: (dialogContext) { - String? errorText; - return StatefulBuilder( - builder: (context, setState) { - final materialTheme = Theme.of(context); - final shadTheme = ShadTheme.of(context); - return SuperportDialog( - title: _dialogTitle(type), - constraints: const BoxConstraints(maxWidth: 420), - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(dialogContext, rootNavigator: true).pop(), - child: const Text('취소'), - ), - ShadButton( - onPressed: () { - final note = noteController.text.trim(); - if (requireNote && note.isEmpty) { - setState(() => errorText = '비고를 입력하세요.'); - return; - } - Navigator.of(dialogContext, rootNavigator: true).pop( - _StepActionDialogResult(note: note.isEmpty ? null : note), - ); - }, - child: Text(_dialogConfirmLabel(type)), - ), - ], - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '선택 단계: Step ${step.stepOrder}', - style: shadTheme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '승인자: ${step.approver.name}', - style: shadTheme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '현재 상태: ${step.status.name}', - style: shadTheme.textTheme.small, - ), - const SizedBox(height: 16), - Text( - _dialogDescription(type), - style: shadTheme.textTheme.muted, - ), - const SizedBox(height: 12), - Text('비고', style: shadTheme.textTheme.small), - const SizedBox(height: 8), - ShadTextarea( - controller: noteController, - minHeight: 120, - maxHeight: 220, - ), - if (requireNote) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - '코멘트에는 비고 입력이 필요합니다.', - style: shadTheme.textTheme.muted, - ), - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - errorText!, - style: shadTheme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ); - }, - ); - - noteController.dispose(); - return dialogResult; - } - - Future _showTemplateApplyConfirm(ApprovalTemplate template) async { - final stepCount = template.steps.length; - final description = template.description?.trim(); - final buffer = StringBuffer() - ..writeln('선택한 템플릿을 적용하면 기존 단계 구성이 템플릿 순서로 교체됩니다.') - ..write('템플릿: ${template.name} (단계 $stepCount개)'); - if (description != null && description.isNotEmpty) { - buffer.write('\n설명: $description'); - } - - final confirmed = await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '템플릿 적용 확인', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('적용'), - ), - ], - child: Text(buffer.toString()), - ), - ); - return confirmed ?? false; - } - - String _statusLabel(ApprovalStatusFilter filter) => - _controller.statusLabel(filter); - - String _dialogTitle(ApprovalStepActionType type) { - switch (type) { - case ApprovalStepActionType.approve: - return '결재 단계 승인'; - case ApprovalStepActionType.reject: - return '결재 단계 반려'; - case ApprovalStepActionType.comment: - return '결재 단계 코멘트'; - } - } - - String _dialogConfirmLabel(ApprovalStepActionType type) { - switch (type) { - case ApprovalStepActionType.approve: - return '승인'; - case ApprovalStepActionType.reject: - return '반려'; - case ApprovalStepActionType.comment: - return '등록'; - } - } - - String _successMessage(ApprovalStepActionType type) { - switch (type) { - case ApprovalStepActionType.approve: - return '결재 단계를 승인했습니다.'; - case ApprovalStepActionType.reject: - return '결재 단계를 반려했습니다.'; - case ApprovalStepActionType.comment: - return '코멘트를 등록했습니다.'; - } - } - - String _dialogDescription(ApprovalStepActionType type) { - switch (type) { - case ApprovalStepActionType.approve: - return '승인하면 다음 단계로 진행합니다. 필요 시 비고를 남길 수 있습니다.'; - case ApprovalStepActionType.reject: - return '반려 사유를 입력해 단계를 반려합니다. 비고는 선택 사항입니다.'; - case ApprovalStepActionType.comment: - return '코멘트를 등록하면 결재 참여자에게 공유됩니다. 비고 입력이 필요합니다.'; - } + String _statusLabel(ApprovalStatusFilter filter) { + return _controller.statusLabel(filter); } } @@ -1052,7 +796,6 @@ class _ApprovalTable extends StatelessWidget { '요청일시', '최종결정일시', '비고', - '동작', ].map((text) => ShadTableCell.header(child: Text(text))).toList(); final rows = >[]; @@ -1086,20 +829,6 @@ class _ApprovalTable extends StatelessWidget { ), ]; - cells.add( - ShadTableCell( - child: Align( - alignment: Alignment.centerRight, - child: ShadButton.ghost( - key: ValueKey('approval_view_${approval.id ?? index}'), - size: ShadButtonSize.sm, - onPressed: () => onView(approval), - child: const Text('자세히'), - ), - ), - ), - ); - rows.add(cells); } @@ -1108,6 +837,12 @@ class _ApprovalTable extends StatelessWidget { rows: rows, rowHeight: 56, maxHeight: 520, + onRowTap: (index) { + if (index < 0 || index >= approvals.length) { + return; + } + onView(approvals[index]); + }, columnSpanExtent: (index) { switch (index) { case 1: @@ -1118,8 +853,6 @@ class _ApprovalTable extends StatelessWidget { return const FixedTableSpanExtent(140); case 7: return const FixedTableSpanExtent(220); - case 8: - return const FixedTableSpanExtent(120); default: return const FixedTableSpanExtent(140); } @@ -1127,804 +860,3 @@ class _ApprovalTable extends StatelessWidget { ); } } - -/// 결재 상세 탭 전체를 감싸는 카드 위젯. -/// -/// 선택 상태와 로딩 여부에 따라 안내 문구 또는 상세 정보를 노출한다. -class _DetailSection extends StatelessWidget { - const _DetailSection({ - super.key, - required this.approval, - required this.isLoading, - required this.isLoadingActions, - required this.isPerformingAction, - required this.processingStepId, - required this.hasActionOptions, - required this.templates, - required this.isLoadingTemplates, - required this.isApplyingTemplate, - required this.applyingTemplateId, - required this.selectedTemplateId, - required this.canPerformStepActions, - required this.canApplyTemplate, - required this.canProceed, - required this.cannotProceedReason, - required this.dateFormat, - required this.onRefresh, - required this.onClose, - required this.onSelectTemplate, - required this.onApplyTemplate, - required this.onReloadTemplates, - required this.onAction, - }); - - final Approval? approval; - final bool isLoading; - final bool isLoadingActions; - final bool isPerformingAction; - final int? processingStepId; - final bool hasActionOptions; - final List templates; - final bool isLoadingTemplates; - final bool isApplyingTemplate; - final int? applyingTemplateId; - final int? selectedTemplateId; - final bool canPerformStepActions; - final bool canApplyTemplate; - final bool canProceed; - final String? cannotProceedReason; - final intl.DateFormat dateFormat; - final VoidCallback onRefresh; - final VoidCallback? onClose; - final void Function(int?) onSelectTemplate; - final void Function(int templateId) onApplyTemplate; - final VoidCallback onReloadTemplates; - final void Function(ApprovalStep step, ApprovalStepActionType type) onAction; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - if (isLoading) { - return const Center(child: CircularProgressIndicator()); - } - if (approval == null) { - return ShadCard( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - '좌측 목록에서 결재를 선택하면 상세 정보를 확인할 수 있습니다.', - style: theme.textTheme.muted, - ), - ), - ); - } - - return DefaultTabController( - length: 3, - child: ShadCard( - title: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('결재 상세', style: theme.textTheme.h3), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - ShadButton.ghost( - onPressed: onRefresh, - child: const Icon(lucide.LucideIcons.refreshCw, size: 16), - ), - const SizedBox(width: 8), - ShadButton.ghost( - onPressed: onClose, - child: const Icon(lucide.LucideIcons.x, size: 16), - ), - ], - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TabBar( - labelStyle: theme.textTheme.small, - tabs: const [ - Tab(text: '개요'), - Tab(text: '단계'), - Tab(text: '이력'), - ], - ), - SizedBox( - height: 340, - child: TabBarView( - children: [ - _OverviewTab(approval: approval!, dateFormat: dateFormat), - _StepTab( - approval: approval!, - steps: approval!.steps, - dateFormat: dateFormat, - hasActionOptions: hasActionOptions, - isLoadingActions: isLoadingActions, - isPerformingAction: isPerformingAction, - processingStepId: processingStepId, - templates: templates, - isLoadingTemplates: isLoadingTemplates, - isApplyingTemplate: isApplyingTemplate, - applyingTemplateId: applyingTemplateId, - selectedTemplateId: selectedTemplateId, - canPerformStepActions: canPerformStepActions, - canApplyTemplate: canApplyTemplate, - canProceed: canProceed, - cannotProceedReason: cannotProceedReason, - onSelectTemplate: onSelectTemplate, - onApplyTemplate: onApplyTemplate, - onReloadTemplates: onReloadTemplates, - onAction: onAction, - ), - _HistoryTab( - histories: approval!.histories, - dateFormat: dateFormat, - ), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _OverviewTab extends StatelessWidget { - const _OverviewTab({required this.approval, required this.dateFormat}); - - final Approval approval; - final intl.DateFormat dateFormat; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - final rows = [ - ('결재번호', approval.approvalNo), - ('트랜잭션번호', approval.transactionNo), - ('현재 상태', approval.status.name), - ( - '현재 단계', - approval.currentStep == null - ? '-' - : 'Step ${approval.currentStep!.stepOrder} · ${approval.currentStep!.approver.name}', - ), - ('상신자', approval.requester.name), - ('상신일시', dateFormat.format(approval.requestedAt.toLocal())), - ( - '최종결정일시', - approval.decidedAt == null - ? '-' - : dateFormat.format(approval.decidedAt!.toLocal()), - ), - ('비고', approval.note?.isEmpty ?? true ? '-' : approval.note!), - ]; - - return Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: rows - .map( - (entry) => Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 140, - child: Text( - entry.$1, - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - Expanded( - child: Text(entry.$2, style: theme.textTheme.small), - ), - ], - ), - ), - ) - .toList(), - ), - ); - } -} - -/// 결재 단계 목록과 템플릿 적용 컨트롤을 묶어 보여주는 탭. -class _StepTab extends StatelessWidget { - const _StepTab({ - required this.approval, - required this.steps, - required this.dateFormat, - required this.hasActionOptions, - required this.isLoadingActions, - required this.isPerformingAction, - required this.processingStepId, - required this.templates, - required this.isLoadingTemplates, - required this.isApplyingTemplate, - required this.applyingTemplateId, - required this.selectedTemplateId, - required this.canPerformStepActions, - required this.canApplyTemplate, - required this.canProceed, - required this.cannotProceedReason, - required this.onSelectTemplate, - required this.onApplyTemplate, - required this.onReloadTemplates, - required this.onAction, - }); - - final Approval approval; - final List steps; - final intl.DateFormat dateFormat; - final bool hasActionOptions; - final bool isLoadingActions; - final bool isPerformingAction; - final int? processingStepId; - final List templates; - final bool isLoadingTemplates; - final bool isApplyingTemplate; - final int? applyingTemplateId; - final int? selectedTemplateId; - final bool canPerformStepActions; - final bool canApplyTemplate; - final bool canProceed; - final String? cannotProceedReason; - final void Function(int?) onSelectTemplate; - final void Function(int templateId) onApplyTemplate; - final VoidCallback onReloadTemplates; - final void Function(ApprovalStep step, ApprovalStepActionType type) onAction; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), - child: _TemplateToolbar( - templates: templates, - isLoading: isLoadingTemplates, - selectedTemplateId: selectedTemplateId, - isApplyingTemplate: isApplyingTemplate, - applyingTemplateId: applyingTemplateId, - canApplyTemplate: canApplyTemplate, - onSelectTemplate: onSelectTemplate, - onApplyTemplate: onApplyTemplate, - onReload: onReloadTemplates, - ), - ), - if (!canApplyTemplate) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - '템플릿 적용 권한이 없어 단계 구성을 변경할 수 없습니다.', - style: theme.textTheme.muted, - ), - ), - if (!isLoadingTemplates && templates.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - '사용 가능한 결재 템플릿이 없습니다. 템플릿을 등록하면 단계 일괄 구성이 가능합니다.', - style: theme.textTheme.muted, - ), - ), - if (!canPerformStepActions) - Padding( - padding: const EdgeInsets.fromLTRB(20, 12, 20, 8), - child: Text( - '결재 권한이 없어 단계 행위를 실행할 수 없습니다.', - style: theme.textTheme.muted, - ), - ), - if (!canProceed) - Padding( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 12), - child: Text( - cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.', - style: theme.textTheme.muted, - ), - ), - if (steps.isEmpty) - Expanded( - child: Center( - child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted), - ), - ) - else - Expanded( - child: ListView.separated( - padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), - itemBuilder: (context, index) { - final step = steps[index]; - final disabledReason = _disabledReason( - step, - canPerformStepActions, - canProceed, - cannotProceedReason, - ); - final isProcessingStep = - isPerformingAction && processingStepId == step.id; - final isEnabled = disabledReason == null && !isProcessingStep; - - return ShadCard( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - 'Step ${step.stepOrder}', - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - step.status.name, - style: theme.textTheme.small, - ), - ], - ), - const SizedBox(height: 8), - Text( - '승인자: ${step.approver.name}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '배정: ${dateFormat.format(step.assignedAt.toLocal())}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '결정: ${step.decidedAt == null ? '-' : dateFormat.format(step.decidedAt!.toLocal())}', - style: theme.textTheme.small, - ), - if (step.note?.isNotEmpty ?? false) ...[ - const SizedBox(height: 8), - Text( - '비고: ${step.note}', - style: theme.textTheme.small, - ), - ], - const SizedBox(height: 12), - if (isLoadingActions) - Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: const CircularProgressIndicator( - strokeWidth: 2, - ), - ), - const SizedBox(width: 8), - Text( - '행위 목록을 불러오는 중입니다.', - style: theme.textTheme.small, - ), - ], - ), - ), - Wrap( - spacing: 12, - runSpacing: 8, - children: ApprovalStepActionType.values - .map( - (type) => _buildActionButton( - context: context, - step: step, - type: type, - enabled: isEnabled, - isProcessing: isProcessingStep, - disabledReason: disabledReason, - ), - ) - .toList(), - ), - if (!isEnabled && disabledReason != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - disabledReason, - style: theme.textTheme.muted, - ), - ), - ], - ), - ), - ); - }, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemCount: steps.length, - ), - ), - ], - ); - } - - Widget _buildActionButton({ - required BuildContext context, - required ApprovalStep step, - required ApprovalStepActionType type, - required bool enabled, - required bool isProcessing, - required String? disabledReason, - }) { - final theme = ShadTheme.of(context); - final actionKey = ValueKey(_actionKey(step, type)); - final label = _actionLabel(type); - final icon = _actionIcon(type); - - final child = Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isProcessing) ...[ - SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - theme.colorScheme.primary, - ), - ), - ), - const SizedBox(width: 8), - Text('$label 처리 중...'), - ] else ...[ - Icon(icon, size: 16), - const SizedBox(width: 8), - Text(label), - ], - ], - ); - - final onPressed = enabled ? () => onAction(step, type) : null; - - Widget button; - switch (type) { - case ApprovalStepActionType.approve: - button = ShadButton(key: actionKey, onPressed: onPressed, child: child); - break; - case ApprovalStepActionType.reject: - button = ShadButton.outline( - key: actionKey, - onPressed: onPressed, - child: child, - ); - break; - case ApprovalStepActionType.comment: - button = ShadButton.ghost( - key: actionKey, - onPressed: onPressed, - child: child, - ); - break; - } - - if (!enabled && disabledReason != null) { - return Tooltip(message: disabledReason, child: button); - } - return button; - } - - String? _disabledReason( - ApprovalStep step, - bool canPerformStepActions, - bool canProceed, - String? cannotProceedReason, - ) { - if (!canPerformStepActions) { - return '결재 행위를 수행할 권한이 없습니다.'; - } - if (isLoadingActions) { - return '행위 목록을 불러오는 중입니다.'; - } - if (!hasActionOptions) { - return '사용 가능한 결재 행위가 없습니다.'; - } - if (!canProceed) { - return cannotProceedReason ?? '현재는 결재 단계를 진행할 수 없습니다.'; - } - if (isPerformingAction && processingStepId != step.id) { - return '다른 결재 단계를 처리 중입니다.'; - } - if (step.decidedAt != null) { - return '이미 처리된 단계입니다.'; - } - final current = approval.currentStep; - if (current == null) { - return '현재 진행할 단계가 지정되지 않았습니다.'; - } - final matchesId = current.id != null && current.id == step.id; - final matchesOrder = - current.id == null && - step.id == null && - current.stepOrder == step.stepOrder; - if (!matchesId && !matchesOrder) { - return '현재 진행 중인 단계가 아닙니다.'; - } - return null; - } - - String _actionLabel(ApprovalStepActionType type) { - switch (type) { - case ApprovalStepActionType.approve: - return '승인'; - case ApprovalStepActionType.reject: - return '반려'; - case ApprovalStepActionType.comment: - return '코멘트'; - } - } - - IconData _actionIcon(ApprovalStepActionType type) { - switch (type) { - case ApprovalStepActionType.approve: - return lucide.LucideIcons.check; - case ApprovalStepActionType.reject: - return lucide.LucideIcons.x; - case ApprovalStepActionType.comment: - return lucide.LucideIcons.messageCircle; - } - } - - String _actionKey(ApprovalStep step, ApprovalStepActionType type) { - if (step.id != null) { - return 'step_action_${step.id}_${type.code}'; - } - return 'step_action_order_${step.stepOrder}_${type.code}'; - } -} - -/// 템플릿 목록을 선택·적용하고 재조회할 수 있는 툴바 UI. -class _TemplateToolbar extends StatelessWidget { - const _TemplateToolbar({ - required this.templates, - required this.isLoading, - required this.selectedTemplateId, - required this.isApplyingTemplate, - required this.applyingTemplateId, - required this.canApplyTemplate, - required this.onSelectTemplate, - required this.onApplyTemplate, - required this.onReload, - }); - - final List templates; - final bool isLoading; - final int? selectedTemplateId; - final bool isApplyingTemplate; - final int? applyingTemplateId; - final bool canApplyTemplate; - final void Function(int?) onSelectTemplate; - final void Function(int templateId) onApplyTemplate; - final VoidCallback onReload; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - final selectedTemplate = _findTemplate(selectedTemplateId); - final isApplyingCurrent = - isApplyingTemplate && applyingTemplateId == selectedTemplateId; - final canApply = - canApplyTemplate && - templates.isNotEmpty && - !isLoading && - selectedTemplateId != null && - !isApplyingTemplate; - - Widget applyButton = ShadButton( - onPressed: canApply - ? () { - final templateId = selectedTemplateId; - if (templateId != null) { - onApplyTemplate(templateId); - } - } - : null, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (isApplyingCurrent) ...[ - SizedBox( - width: 16, - height: 16, - child: const CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 8), - const Text('적용 중...'), - ] else ...[ - const Icon(lucide.LucideIcons.layoutList, size: 16), - const SizedBox(width: 8), - const Text('템플릿 적용'), - ], - ], - ), - ); - - if (!canApplyTemplate) { - applyButton = Tooltip(message: '템플릿을 적용할 권한이 없습니다.', child: applyButton); - } - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: ShadSelect( - key: ValueKey(templates.length), - placeholder: const Text('템플릿 선택'), - initialValue: selectedTemplateId, - onChanged: canApplyTemplate ? onSelectTemplate : null, - selectedOptionBuilder: (context, value) { - final match = _findTemplate(value); - return Text(match?.name ?? '템플릿 선택'); - }, - options: templates - .map( - (template) => ShadOption( - value: template.id, - child: Text(template.name), - ), - ) - .toList(), - ), - ), - const SizedBox(width: 12), - ShadButton.outline( - onPressed: isLoading ? null : onReload, - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(lucide.LucideIcons.refreshCw, size: 16), - SizedBox(width: 6), - Text('새로고침'), - ], - ), - ), - const SizedBox(width: 12), - applyButton, - ], - ), - if (!canApplyTemplate) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text('결재 템플릿 적용 권한이 없습니다.', style: theme.textTheme.muted), - ), - if (isLoading) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Row( - children: [ - SizedBox( - width: 16, - height: 16, - child: const CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 8), - Text('템플릿을 불러오는 중입니다.', style: theme.textTheme.small), - ], - ), - ) - else if (selectedTemplate != null) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - _templateSummary(selectedTemplate), - style: theme.textTheme.small, - ), - ), - ], - ); - } - - ApprovalTemplate? _findTemplate(int? id) { - if (id == null) { - return null; - } - for (final template in templates) { - if (template.id == id) { - return template; - } - } - return null; - } - - String _templateSummary(ApprovalTemplate template) { - final stepCount = template.steps.length; - final description = template.description?.trim(); - final buffer = StringBuffer() - ..write('선택된 템플릿: ${template.name} (단계 $stepCount개)'); - if (description != null && description.isNotEmpty) { - buffer.write(' · $description'); - } - return buffer.toString(); - } -} - -class _HistoryTab extends StatelessWidget { - const _HistoryTab({required this.histories, required this.dateFormat}); - - final List histories; - final intl.DateFormat dateFormat; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - if (histories.isEmpty) { - return Center(child: Text('결재 이력이 없습니다.', style: theme.textTheme.muted)); - } - - return ListView.separated( - padding: const EdgeInsets.all(20), - itemBuilder: (context, index) { - final history = histories[index]; - return ShadCard( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - history.action.name, - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - dateFormat.format(history.actionAt.toLocal()), - style: theme.textTheme.small, - ), - ], - ), - const SizedBox(height: 6), - Text( - '승인자: ${history.approver.name}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '상태: ${history.fromStatus?.name ?? '-'} → ${history.toStatus.name}', - style: theme.textTheme.small, - ), - if (history.note?.isNotEmpty ?? false) ...[ - const SizedBox(height: 6), - Text('비고: ${history.note}', style: theme.textTheme.small), - ], - ], - ), - ), - ); - }, - separatorBuilder: (_, __) => const SizedBox(height: 12), - itemCount: histories.length, - ); - } -} - -class _StepActionDialogResult { - const _StepActionDialogResult({this.note}); - - final String? note; -} diff --git a/lib/features/approvals/step/presentation/dialogs/approval_step_detail_dialog.dart b/lib/features/approvals/step/presentation/dialogs/approval_step_detail_dialog.dart new file mode 100644 index 0000000..958af13 --- /dev/null +++ b/lib/features/approvals/step/presentation/dialogs/approval_step_detail_dialog.dart @@ -0,0 +1,534 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../domain/entities/approval_step_input.dart'; +import '../../domain/entities/approval_step_record.dart'; + +/// 결재 단계 상세 다이얼로그 내 액션 구분값이다. +enum ApprovalStepDetailAction { updated, deleted, restored } + +/// 결재 단계 상세 다이얼로그 종료 시 전달되는 결과 모델이다. +class ApprovalStepDetailResult { + const ApprovalStepDetailResult({required this.action, required this.message}); + + /// 수행된 액션 종류. + final ApprovalStepDetailAction action; + + /// 사용자에게 노출할 완료 메시지. + final String message; +} + +typedef ApprovalStepUpdateCallback = + Future Function(int id, ApprovalStepInput input); +typedef ApprovalStepDeleteCallback = Future Function(int id); +typedef ApprovalStepRestoreCallback = + Future Function(int id); + +/// 결재 단계 상세 다이얼로그를 노출한다. +Future showApprovalStepDetailDialog({ + required BuildContext context, + required ApprovalStepRecord record, + required intl.DateFormat dateFormat, + required ApprovalStepUpdateCallback onUpdate, + required ApprovalStepDeleteCallback onDelete, + required ApprovalStepRestoreCallback onRestore, + bool canEdit = true, + bool canDelete = true, + bool canRestore = true, +}) { + final step = record.step; + final metadata = [ + SuperportDetailMetadata.text(label: '결재 ID', value: '${record.approvalId}'), + SuperportDetailMetadata.text( + label: '트랜잭션번호', + value: record.transactionNo ?? '-', + ), + SuperportDetailMetadata.text( + label: '템플릿', + value: record.templateName ?? '-', + ), + SuperportDetailMetadata.text(label: '단계 순서', value: '${step.stepOrder}'), + SuperportDetailMetadata.text(label: '상태', value: step.status.name), + SuperportDetailMetadata.text(label: '승인자', value: step.approver.name), + SuperportDetailMetadata.text( + label: '승인자 사번', + value: step.approver.employeeNo, + ), + SuperportDetailMetadata.text( + label: '배정일시', + value: dateFormat.format(step.assignedAt.toLocal()), + ), + SuperportDetailMetadata.text( + label: '결정일시', + value: step.decidedAt == null + ? '-' + : dateFormat.format(step.decidedAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '비고', + value: step.note?.isNotEmpty == true ? step.note! : '-', + ), + ]; + + final sections = [ + if (canEdit) + SuperportDetailDialogSection( + key: const ValueKey('approval_step_section_edit'), + id: _ApprovalStepSections.edit, + label: '수정', + icon: lucide.LucideIcons.pencil, + builder: (_) => _ApprovalStepEditSection( + record: record, + onSubmit: (input) async { + final stepId = step.id; + if (stepId == null) { + return null; + } + final updated = await onUpdate(stepId, input); + if (updated == null) { + return null; + } + return ApprovalStepDetailResult( + action: ApprovalStepDetailAction.updated, + message: '결재 단계 정보를 수정했습니다.', + ); + }, + ), + ), + if (step.isDeleted ? canRestore : canDelete) + SuperportDetailDialogSection( + key: ValueKey( + step.isDeleted + ? 'approval_step_section_restore' + : 'approval_step_section_delete', + ), + id: step.isDeleted + ? _ApprovalStepSections.restore + : _ApprovalStepSections.delete, + label: step.isDeleted ? '복구' : '삭제', + icon: step.isDeleted + ? lucide.LucideIcons.history + : lucide.LucideIcons.trash2, + scrollable: false, + builder: (_) => _ApprovalStepDangerSection( + record: record, + canDelete: canDelete, + canRestore: canRestore, + onDelete: () async { + final stepId = step.id; + if (stepId == null) { + return null; + } + final success = await onDelete(stepId); + if (!success) { + return null; + } + return ApprovalStepDetailResult( + action: ApprovalStepDetailAction.deleted, + message: '결재 단계를 삭제했습니다.', + ); + }, + onRestore: () async { + final stepId = step.id; + if (stepId == null) { + return null; + } + final restored = await onRestore(stepId); + if (restored == null) { + return null; + } + return ApprovalStepDetailResult( + action: ApprovalStepDetailAction.restored, + message: '결재 단계를 복구했습니다.', + ); + }, + ), + ), + ]; + + final initialSectionId = sections.isEmpty ? null : sections.first.id; + + final badges = [ + ShadBadge.outline(child: Text('단계 ${step.stepOrder}')), + ShadBadge(child: Text(step.status.name)), + if (step.isDeleted) const ShadBadge.destructive(child: Text('삭제됨')), + ]; + + return showSuperportDetailDialog( + context: context, + title: '결재 단계 상세', + description: '결재번호 ${record.approvalNo}', + sections: sections, + summary: _ApprovalStepSummary(record: record), + summaryBadges: badges, + metadata: metadata, + emptyPlaceholder: const Text('표시할 상세 정보가 없습니다.'), + initialSectionId: initialSectionId, + ); +} + +/// 다이얼로그 섹션 ID 상수 모음이다. +class _ApprovalStepSections { + static const edit = 'edit'; + static const delete = 'delete'; + static const restore = 'restore'; +} + +/// 결재 단계 요약 정보를 구성하는 위젯이다. +class _ApprovalStepSummary extends StatelessWidget { + const _ApprovalStepSummary({required this.record}); + + final ApprovalStepRecord record; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final step = record.step; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('결재 단계 ${step.stepOrder}', style: theme.textTheme.h4), + const SizedBox(height: 4), + Text( + '${step.approver.name} · ${step.approver.employeeNo}', + style: theme.textTheme.small, + ), + if (step.note?.isNotEmpty == true) ...[ + const SizedBox(height: 8), + Text(step.note!, style: theme.textTheme.muted), + ], + ], + ); + } +} + +/// 결재 단계 수정 폼 섹션이다. +class _ApprovalStepEditSection extends StatefulWidget { + const _ApprovalStepEditSection({ + required this.record, + required this.onSubmit, + }); + + final ApprovalStepRecord record; + final Future Function(ApprovalStepInput input) + onSubmit; + + @override + State<_ApprovalStepEditSection> createState() => + _ApprovalStepEditSectionState(); +} + +class _ApprovalStepEditSectionState extends State<_ApprovalStepEditSection> { + late final TextEditingController _stepOrderController; + late final TextEditingController _approverIdController; + late final TextEditingController _noteController; + bool _isSubmitting = false; + String? _stepOrderError; + String? _approverIdError; + String? _submitError; + + ApprovalStepRecord get _record => widget.record; + + @override + void initState() { + super.initState(); + _stepOrderController = TextEditingController( + text: _record.step.stepOrder.toString(), + ); + _approverIdController = TextEditingController( + text: _record.step.approver.id.toString(), + ); + _noteController = TextEditingController(text: _record.step.note ?? ''); + } + + @override + void dispose() { + _stepOrderController.dispose(); + _approverIdController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ReadOnlyField(label: '결재 ID', value: '${_record.approvalId}'), + const SizedBox(height: 12), + _ReadOnlyField(label: '결재번호', value: _record.approvalNo), + const SizedBox(height: 16), + _EditableField( + label: '단계 순서', + controller: _stepOrderController, + errorText: _stepOrderError, + keyboardType: TextInputType.number, + fieldKey: const ValueKey('approval_step_detail_step_order'), + onChanged: (_) { + if (_stepOrderError != null) { + setState(() => _stepOrderError = null); + } + }, + ), + const SizedBox(height: 16), + _EditableField( + label: '승인자 ID', + controller: _approverIdController, + errorText: _approverIdError, + keyboardType: TextInputType.number, + fieldKey: const ValueKey('approval_step_detail_approver_id'), + onChanged: (_) { + if (_approverIdError != null) { + setState(() => _approverIdError = null); + } + }, + ), + const SizedBox(height: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '비고', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + ShadTextarea( + key: const ValueKey('approval_step_detail_note'), + controller: _noteController, + minHeight: 96, + maxHeight: 200, + ), + ], + ), + if (_submitError != null) ...[ + const SizedBox(height: 12), + Text( + _submitError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + key: const ValueKey('approval_step_detail_submit'), + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isSubmitting ? '저장 중...' : '저장'), + ), + ), + ], + ); + } + + Future _handleSubmit() async { + final stepOrder = int.tryParse(_stepOrderController.text.trim()); + final approverId = int.tryParse(_approverIdController.text.trim()); + + setState(() { + _stepOrderError = stepOrder == null || stepOrder <= 0 + ? '1 이상의 숫자를 입력하세요.' + : null; + _approverIdError = approverId == null || approverId <= 0 + ? '1 이상의 숫자를 입력하세요.' + : null; + _submitError = null; + }); + + if (_stepOrderError != null || _approverIdError != null) { + return; + } + + setState(() => _isSubmitting = true); + + final input = ApprovalStepInput( + approvalId: _record.approvalId, + stepOrder: stepOrder!, + approverId: approverId!, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + statusId: _record.step.status.id, + ); + + final navigator = Navigator.of(context, rootNavigator: true); + ApprovalStepDetailResult? result; + + try { + result = await widget.onSubmit(input); + } finally { + if (mounted) { + setState(() => _isSubmitting = false); + } + } + + if (!mounted) { + return; + } + + if (result == null) { + setState(() { + _submitError = '요청 처리에 실패했습니다. 다시 시도해 주세요.'; + }); + return; + } + + if (navigator.mounted) { + navigator.pop(result); + } + } +} + +/// 삭제/복구 섹션을 담당하는 위젯이다. +class _ApprovalStepDangerSection extends StatelessWidget { + const _ApprovalStepDangerSection({ + required this.record, + required this.canDelete, + required this.canRestore, + required this.onDelete, + required this.onRestore, + }); + + final ApprovalStepRecord record; + final bool canDelete; + final bool canRestore; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + Widget build(BuildContext context) { + final step = record.step; + final theme = ShadTheme.of(context); + final navigator = Navigator.of(context, rootNavigator: true); + final description = step.isDeleted + ? '복구하면 결재 단계가 다시 활성화됩니다.' + : '삭제 시 단계는 목록에서 숨겨지지만, 필요 시 복구할 수 있습니다.'; + + Future handleAction( + Future Function() callback, + ) async { + final result = await callback(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + if (step.isDeleted) + ShadButton( + key: const ValueKey('approval_step_detail_restore'), + onPressed: canRestore ? () => handleAction(onRestore) : null, + child: const Text('복구'), + ) + else + ShadButton.destructive( + key: const ValueKey('approval_step_detail_delete'), + onPressed: canDelete ? () => handleAction(onDelete) : null, + child: const Text('삭제'), + ), + ], + ); + } +} + +/// 읽기 전용 필드 레이아웃을 제공한다. +class _ReadOnlyField extends StatelessWidget { + const _ReadOnlyField({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final background = theme.colorScheme.secondary.withValues(alpha: 0.05); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: background, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: theme.colorScheme.border), + ), + child: Text( + value.isEmpty ? '-' : value, + style: theme.textTheme.small, + ), + ), + ], + ); + } +} + +/// 편집 필드 공통 레이아웃 위젯이다. +class _EditableField extends StatelessWidget { + const _EditableField({ + required this.label, + required this.controller, + this.errorText, + this.keyboardType, + this.onChanged, + this.fieldKey, + }); + + final String label; + final TextEditingController controller; + final String? errorText; + final TextInputType? keyboardType; + final ValueChanged? onChanged; + final Key? fieldKey; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + ShadInput( + key: fieldKey, + controller: controller, + keyboardType: keyboardType, + onChanged: onChanged, + ), + if (errorText != null) ...[ + const SizedBox(height: 6), + Text( + errorText!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + ], + ); + } +} + +/// 개요 섹션에서 사용하는 단순 레코드 모델이다. diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index 06e84be..75435ab 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -11,8 +12,10 @@ import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_dialog.dart'; import '../../../../../widgets/components/superport_pagination_controls.dart'; +import '../../../../../widgets/components/superport_table.dart'; import '../../../../../widgets/components/feature_disabled_placeholder.dart'; import '../controllers/approval_step_controller.dart'; +import '../dialogs/approval_step_detail_dialog.dart'; import '../../domain/entities/approval_step_input.dart'; import '../../domain/entities/approval_step_record.dart'; import '../../domain/repositories/approval_step_repository.dart'; @@ -70,7 +73,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { late final ApprovalStepController _controller; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocus = FocusNode(); - final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; @override @@ -253,154 +256,77 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ - SizedBox( - height: 480, - child: ShadTable.list( - header: - [ - 'ID', - '결재번호', - '단계순서', - '승인자', - '상태', - '배정일시', - '결정일시', - '동작', - ] - .map( - (label) => ShadTableCell.header( - child: Text(label), - ), - ) - .toList(), - columnSpanExtent: (index) { - switch (index) { - case 1: - return const FixedTableSpanExtent(160); - case 2: - return const FixedTableSpanExtent(100); - case 3: - return const FixedTableSpanExtent(150); - case 4: - return const FixedTableSpanExtent(120); - case 5: - case 6: - return const FixedTableSpanExtent(160); - case 7: - return const FixedTableSpanExtent(110); - default: - return const FixedTableSpanExtent(90); - } - }, - children: records.map((record) { - final step = record.step; - final isDeleted = step.isDeleted; - return [ - ShadTableCell( - child: Text(step.id?.toString() ?? '-'), - ), - ShadTableCell(child: Text(record.approvalNo)), - ShadTableCell(child: Text('${step.stepOrder}')), - ShadTableCell(child: Text(step.approver.name)), - ShadTableCell( - child: ShadBadge(child: Text(step.status.name)), - ), - ShadTableCell( - child: Text(_formatDate(step.assignedAt)), - ), - ShadTableCell( - child: Text( - step.decidedAt == null - ? '-' - : _formatDate(step.decidedAt!), - ), - ), - ShadTableCell( - child: Align( - alignment: Alignment.centerRight, - child: Wrap( - spacing: 8, - children: [ - PermissionGate( - resource: _stepResourcePath, - action: PermissionAction.view, - child: ShadButton.outline( - key: ValueKey( - 'step_detail_${step.id ?? record.approvalId}_${step.stepOrder}', - ), - size: ShadButtonSize.sm, - onPressed: - step.id == null || - _controller.isLoading || - isSaving - ? null - : () => _openDetail(record), - child: const Text('상세'), - ), - ), - if (step.id != null && !isDeleted) - PermissionGate( - resource: _stepResourcePath, - action: PermissionAction.edit, - child: ShadButton( - key: ValueKey( - 'step_edit_${step.id}_${step.stepOrder}', - ), - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || - isSaving - ? null - : () => - _openEditStepForm(record), - child: const Text('수정'), - ), - ), - if (step.id != null && !isDeleted) - PermissionGate( - resource: _stepResourcePath, - action: PermissionAction.delete, - child: ShadButton.destructive( - key: ValueKey( - 'step_delete_${step.id}_${step.stepOrder}', - ), - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || - isSaving - ? null - : () => _confirmDeleteStep( - record, - ), - child: const Text('삭제'), - ), - ), - if (step.id != null && isDeleted) - PermissionGate( - resource: _stepResourcePath, - action: PermissionAction.restore, - child: ShadButton.outline( - key: ValueKey( - 'step_restore_${step.id}_${step.stepOrder}', - ), - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || - isSaving - ? null - : () => _confirmRestoreStep( - record, - ), - child: const Text('복구'), - ), - ), - ], + SuperportTable( + columns: const [ + Text('ID'), + Text('결재번호'), + Text('단계순서'), + Text('승인자'), + Text('상태'), + Text('배정일시'), + Text('결정일시'), + ], + rows: records.map((record) { + final step = record.step; + final isDeleted = step.isDeleted; + final decidedAt = step.decidedAt; + return [ + Text(step.id?.toString() ?? '-'), + Text(record.approvalNo), + Text('${step.stepOrder}'), + Text(step.approver.name), + Row( + children: [ + ShadBadge(child: Text(step.status.name)), + if (isDeleted) ...[ + const SizedBox(width: 8), + const ShadBadge.destructive( + child: Text('삭제됨'), ), - ), - ), - ]; - }).toList(), - ), + ], + ], + ), + Text(_formatDate(step.assignedAt)), + Text( + decidedAt == null ? '-' : _formatDate(decidedAt), + ), + ]; + }).toList(), + rowHeight: 56, + maxHeight: 520, + columnSpanExtent: (index) { + switch (index) { + case 1: + return const FixedTableSpanExtent(160); + case 2: + return const FixedTableSpanExtent(100); + case 3: + return const FixedTableSpanExtent(150); + case 4: + return const FixedTableSpanExtent(200); + case 5: + case 6: + return const FixedTableSpanExtent(160); + default: + return const FixedTableSpanExtent(90); + } + }, + onRowTap: (_controller.isLoading || isSaving) + ? null + : (index) { + if (records.isEmpty) { + return; + } + final int safeIndex; + if (index < 0) { + safeIndex = 0; + } else if (index >= records.length) { + safeIndex = records.length - 1; + } else { + safeIndex = index; + } + _openDetail(records[safeIndex]); + }, ), const SizedBox(height: 16), Row( @@ -471,11 +397,7 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { final input = await showDialog( context: context, builder: (dialogContext) { - return _StepFormDialog( - title: '결재 단계 추가', - submitLabel: '저장', - isEditing: false, - ); + return _StepFormDialog(title: '결재 단계 추가', submitLabel: '저장'); }, ); @@ -494,43 +416,6 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { ); } - Future _openEditStepForm(ApprovalStepRecord record) async { - final stepId = record.step.id; - if (stepId == null) { - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - const SnackBar(content: Text('저장되지 않은 단계는 수정할 수 없습니다.')), - ); - return; - } - - final input = await showDialog( - context: context, - builder: (dialogContext) { - return _StepFormDialog( - title: '결재 단계 수정', - submitLabel: '저장', - isEditing: true, - initialRecord: record, - ); - }, - ); - - if (!mounted || input == null) { - return; - } - - final updated = await _controller.updateStep(stepId, input); - if (!mounted || updated == null) { - return; - } - - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - SnackBar(content: Text('결재번호 ${updated.approvalNo} 단계 정보를 수정했습니다.')), - ); - } - Future _openDetail(ApprovalStepRecord record) async { final stepId = record.step.id; if (stepId == null) { @@ -548,146 +433,44 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { final detail = await _controller.fetchDetail(stepId); if (!mounted) return; Navigator.of(context, rootNavigator: true).pop(); - if (detail == null) return; - final step = detail.step; - await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '결재 단계 상세', - description: '결재번호 ${detail.approvalNo}', - constraints: const BoxConstraints(maxWidth: 560), - contentPadding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 18, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - _DetailRow(label: '단계 순서', value: '${step.stepOrder}'), - _DetailRow(label: '승인자', value: step.approver.name), - _DetailRow(label: '상태', value: step.status.name), - _DetailRow(label: '배정일시', value: _formatDate(step.assignedAt)), - _DetailRow( - label: '결정일시', - value: step.decidedAt == null - ? '-' - : _formatDate(step.decidedAt!), - ), - _DetailRow(label: '템플릿', value: detail.templateName ?? '-'), - _DetailRow(label: '트랜잭션번호', value: detail.transactionNo ?? '-'), - const SizedBox(height: 12), - Text( - '비고', - style: ShadTheme.of( - context, - ).textTheme.small.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - ShadTextarea( - initialValue: step.note ?? '', - readOnly: true, - minHeight: 80, - maxHeight: 200, - ), - ], - ), - ), + if (detail == null) { + final error = _controller.errorMessage; + if (error != null) { + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar(SnackBar(content: Text(error))); + _controller.clearError(); + } + return; + } + final permissionManager = PermissionScope.of(context); + final canEdit = permissionManager.can( + _stepResourcePath, + PermissionAction.edit, ); - } - - Future _confirmDeleteStep(ApprovalStepRecord record) async { - final stepId = record.step.id; - if (stepId == null) { - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - const SnackBar(content: Text('저장되지 않은 단계는 삭제할 수 없습니다.')), - ); - return; - } - - final confirmed = await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '결재 단계 삭제', - description: - '결재번호 ${record.approvalNo}의 ${record.step.stepOrder}단계를 삭제하시겠습니까? 삭제 후 복구할 수 있습니다.', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton.destructive( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('삭제'), - ), - ], - ), + final canDelete = permissionManager.can( + _stepResourcePath, + PermissionAction.delete, ); - - if (confirmed != true) { - return; - } - - final success = await _controller.deleteStep(stepId); - if (!mounted) { - return; - } - if (success) { - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - SnackBar(content: Text('결재번호 ${record.approvalNo} 단계가 삭제되었습니다.')), - ); - } - } - - Future _confirmRestoreStep(ApprovalStepRecord record) async { - final stepId = record.step.id; - if (stepId == null) { - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - const SnackBar(content: Text('단계 식별자가 없어 복구할 수 없습니다.')), - ); - return; - } - - final confirmed = await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '결재 단계 복구', - description: - '결재번호 ${record.approvalNo}의 ${record.step.stepOrder}단계를 복구하시겠습니까?', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('복구'), - ), - ], - ), + final canRestore = permissionManager.can( + _stepResourcePath, + PermissionAction.restore, ); - - if (confirmed != true) { + final result = await showApprovalStepDetailDialog( + context: context, + record: detail, + dateFormat: _dateFormat, + onUpdate: (id, input) => _controller.updateStep(id, input), + onDelete: (id) => _controller.deleteStep(id), + onRestore: (id) => _controller.restoreStep(id), + canEdit: canEdit, + canDelete: canDelete, + canRestore: canRestore, + ); + if (!mounted || result == null) { return; } - - final restored = await _controller.restoreStep(stepId); - if (!mounted) { - return; - } - if (restored != null) { - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - SnackBar(content: Text('결재번호 ${restored.approvalNo} 단계가 복구되었습니다.')), - ); - } + final messenger = ScaffoldMessenger.maybeOf(context); + messenger?.showSnackBar(SnackBar(content: Text(result.message))); } String _formatDate(DateTime date) { @@ -725,48 +508,11 @@ class _ApproverOption { int get hashCode => id.hashCode; } -class _DetailRow extends StatelessWidget { - const _DetailRow({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 120, - child: Text( - label, - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - ), - Expanded(child: Text(value, style: theme.textTheme.small)), - ], - ), - ); - } -} - class _StepFormDialog extends StatefulWidget { - const _StepFormDialog({ - required this.title, - required this.submitLabel, - required this.isEditing, - this.initialRecord, - }); + const _StepFormDialog({required this.title, required this.submitLabel}); final String title; final String submitLabel; - final bool isEditing; - final ApprovalStepRecord? initialRecord; @override State<_StepFormDialog> createState() => _StepFormDialogState(); @@ -774,7 +520,6 @@ class _StepFormDialog extends StatefulWidget { class _StepFormDialogState extends State<_StepFormDialog> { late final TextEditingController _approvalIdController; - late final TextEditingController _approvalNoController; late final TextEditingController _stepOrderController; late final TextEditingController _approverIdController; late final TextEditingController _noteController; @@ -783,28 +528,15 @@ class _StepFormDialogState extends State<_StepFormDialog> { @override void initState() { super.initState(); - final record = widget.initialRecord; - _approvalIdController = TextEditingController( - text: widget.isEditing && record != null - ? record.approvalId.toString() - : '', - ); - _approvalNoController = TextEditingController( - text: record?.approvalNo ?? '', - ); - _stepOrderController = TextEditingController( - text: record?.step.stepOrder.toString() ?? '', - ); - _approverIdController = TextEditingController( - text: record?.step.approver.id.toString() ?? '', - ); - _noteController = TextEditingController(text: record?.step.note ?? ''); + _approvalIdController = TextEditingController(); + _stepOrderController = TextEditingController(); + _approverIdController = TextEditingController(); + _noteController = TextEditingController(); } @override void dispose() { _approvalIdController.dispose(); - _approvalNoController.dispose(); _stepOrderController.dispose(); _approverIdController.dispose(); _noteController.dispose(); @@ -834,34 +566,16 @@ class _StepFormDialogState extends State<_StepFormDialog> { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - if (!widget.isEditing) - _FormFieldBlock( - label: '결재 ID', - errorText: _errors['approvalId'], - child: ShadInput( - key: const ValueKey('step_form_approval_id'), - controller: _approvalIdController, - onChanged: (_) => _clearError('approvalId'), - ), - ) - else ...[ - _FormFieldBlock( - label: '결재 ID', - child: ShadInput( - controller: _approvalIdController, - readOnly: true, - ), + _FormFieldBlock( + label: '결재 ID', + errorText: _errors['approvalId'], + child: ShadInput( + key: const ValueKey('step_form_approval_id'), + controller: _approvalIdController, + onChanged: (_) => _clearError('approvalId'), ), - const SizedBox(height: 16), - _FormFieldBlock( - label: '결재번호', - child: ShadInput( - controller: _approvalNoController, - readOnly: true, - ), - ), - ], - if (!widget.isEditing) const SizedBox(height: 16), + ), + const SizedBox(height: 16), _FormFieldBlock( label: '단계 순서', errorText: _errors['stepOrder'], @@ -910,14 +624,9 @@ class _StepFormDialogState extends State<_StepFormDialog> { void _handleSubmit() { final Map nextErrors = {}; - int? approvalId; - if (widget.isEditing) { - approvalId = widget.initialRecord?.approvalId; - } else { - approvalId = int.tryParse(_approvalIdController.text.trim()); - if (approvalId == null || approvalId <= 0) { - nextErrors['approvalId'] = '결재 ID를 1 이상의 숫자로 입력하세요.'; - } + final approvalId = int.tryParse(_approvalIdController.text.trim()); + if (approvalId == null || approvalId <= 0) { + nextErrors['approvalId'] = '결재 ID를 1 이상의 숫자로 입력하세요.'; } final stepOrder = int.tryParse(_stepOrderController.text.trim()); @@ -941,7 +650,6 @@ class _StepFormDialogState extends State<_StepFormDialog> { stepOrder: stepOrder!, approverId: approverId!, note: note.isEmpty ? null : note, - statusId: widget.initialRecord?.step.status.id, ); Navigator.of(context, rootNavigator: true).pop(input); diff --git a/lib/features/approvals/template/presentation/dialogs/approval_template_detail_dialog.dart b/lib/features/approvals/template/presentation/dialogs/approval_template_detail_dialog.dart new file mode 100644 index 0000000..574413e --- /dev/null +++ b/lib/features/approvals/template/presentation/dialogs/approval_template_detail_dialog.dart @@ -0,0 +1,824 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../../domain/entities/approval_template.dart'; +import '../../../../auth/application/auth_service.dart'; +import '../../../shared/widgets/approver_autocomplete_field.dart'; + +/// 결재 템플릿 상세 다이얼로그에서 발생 가능한 액션 종류이다. +enum ApprovalTemplateDetailAction { created, updated, deleted, restored } + +/// 결재 템플릿 상세 다이얼로그 결과 모델이다. +class ApprovalTemplateDetailResult { + const ApprovalTemplateDetailResult({ + required this.action, + required this.message, + }); + + final ApprovalTemplateDetailAction action; + final String message; +} + +typedef ApprovalTemplateCreateCallback = + Future Function( + ApprovalTemplateInput input, + List steps, + ); + +typedef ApprovalTemplateUpdateCallback = + Future Function( + int id, + ApprovalTemplateInput input, + List steps, + ); + +typedef ApprovalTemplateDeleteCallback = Future Function(int id); +typedef ApprovalTemplateRestoreCallback = + Future Function(int id); + +/// 결재 템플릿 상세 다이얼로그를 표시한다. +Future showApprovalTemplateDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + ApprovalTemplate? template, + required ApprovalTemplateCreateCallback onCreate, + required ApprovalTemplateUpdateCallback onUpdate, + required ApprovalTemplateDeleteCallback onDelete, + required ApprovalTemplateRestoreCallback onRestore, +}) { + final isCreate = template == null; + final summaryBadges = [ + if (template?.isActive == true) + const ShadBadge(child: Text('사용')) + else if (template != null) + const ShadBadge.outline(child: Text('미사용')), + ]; + final metadata = template == null + ? const [] + : [ + SuperportDetailMetadata.text(label: 'ID', value: '${template.id}'), + SuperportDetailMetadata.text(label: '코드', value: template.code), + SuperportDetailMetadata.text( + label: '상태', + value: template.isActive ? '사용' : '미사용', + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: template.createdAt == null + ? '-' + : dateFormat.format(template.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '변경일시', + value: template.updatedAt == null + ? '-' + : dateFormat.format(template.updatedAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '비고', + value: template.note?.isNotEmpty == true ? template.note! : '-', + ), + ]; + + final sections = [ + if (!isCreate) + SuperportDetailDialogSection( + id: _TemplateSections.steps, + label: '단계', + icon: lucide.LucideIcons.listTree, + builder: (_) => _TemplateStepsSection(template: template), + ), + SuperportDetailDialogSection( + id: isCreate ? _TemplateSections.create : _TemplateSections.edit, + label: isCreate ? '생성' : '수정', + icon: lucide.LucideIcons.pencil, + builder: (_) => _TemplateFormSection( + template: template, + onCreate: onCreate, + onUpdate: onUpdate, + ), + ), + if (!isCreate) + SuperportDetailDialogSection( + id: template.isActive + ? _TemplateSections.delete + : _TemplateSections.restore, + label: template.isActive ? '삭제' : '복구', + icon: template.isActive + ? lucide.LucideIcons.trash2 + : lucide.LucideIcons.history, + scrollable: false, + builder: (_) => _TemplateDangerSection( + template: template, + onDelete: onDelete, + onRestore: onRestore, + ), + ), + ]; + + return showSuperportDetailDialog( + context: context, + title: isCreate ? '결재 템플릿 생성' : '결재 템플릿 상세', + description: isCreate + ? '반복되는 결재 단계를 템플릿으로 등록합니다.' + : '템플릿 정보를 확인하고 수정하거나 삭제/복구할 수 있습니다.', + summary: template == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(template.name, style: ShadTheme.of(context).textTheme.h4), + if (template.description?.isNotEmpty == true) ...[ + const SizedBox(height: 4), + Text( + template.description!, + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ], + ), + summaryBadges: summaryBadges, + metadata: metadata, + sections: sections, + initialSectionId: isCreate + ? _TemplateSections.create + : _TemplateSections.steps, + ); +} + +/// 다이얼로그 섹션 식별자 상수 모음이다. +class _TemplateSections { + static const steps = 'steps'; + static const edit = 'edit'; + static const create = 'create'; + static const delete = 'delete'; + static const restore = 'restore'; +} + +/// 템플릿 단계 목록을 표시하는 섹션이다. +class _TemplateStepsSection extends StatelessWidget { + const _TemplateStepsSection({required this.template}); + + final ApprovalTemplate template; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + if (template.steps.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 32), + decoration: BoxDecoration( + color: theme.colorScheme.secondary.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.border), + ), + child: Center( + child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final step in template.steps) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: theme.colorScheme.secondary.withValues(alpha: 0.12), + ), + alignment: Alignment.center, + child: Text( + '${step.stepOrder}', + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + step.approver.name, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + Text( + '사번 ${step.approver.employeeNo}', + style: theme.textTheme.muted, + ), + if (step.note?.isNotEmpty ?? false) + Text(step.note!, style: theme.textTheme.muted), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + ], + ], + ); + } +} + +/// 템플릿 등록/수정 폼 섹션이다. +class _TemplateFormSection extends StatefulWidget { + const _TemplateFormSection({ + required this.template, + required this.onCreate, + required this.onUpdate, + }); + + final ApprovalTemplate? template; + final ApprovalTemplateCreateCallback onCreate; + final ApprovalTemplateUpdateCallback onUpdate; + + @override + State<_TemplateFormSection> createState() => _TemplateFormSectionState(); +} + +class _TemplateFormSectionState extends State<_TemplateFormSection> { + late final TextEditingController _codeController; + late final TextEditingController _nameController; + late final TextEditingController _descriptionController; + late final TextEditingController _noteController; + late final ValueNotifier _isActiveNotifier; + late final List<_TemplateStepField> _steps; + bool _isSubmitting = false; + String? _errorText; + + bool get _isEdit => widget.template != null; + + @override + void initState() { + super.initState(); + final template = widget.template; + _codeController = TextEditingController( + text: template?.code ?? _generateTemplateCode(), + ); + _nameController = TextEditingController(text: template?.name ?? ''); + _descriptionController = TextEditingController( + text: template?.description ?? '', + ); + _noteController = TextEditingController(text: template?.note ?? ''); + _isActiveNotifier = ValueNotifier(template?.isActive ?? true); + _steps = _buildInitialStepFields(template); + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + _descriptionController.dispose(); + _noteController.dispose(); + _isActiveNotifier.dispose(); + for (final step in _steps) { + step.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (!_isEdit) + _FormField( + label: '템플릿 코드', + child: ShadInput( + controller: _codeController, + readOnly: true, + enabled: false, + ), + ), + _FormField( + label: '템플릿명', + required: true, + child: ShadInput( + key: const ValueKey('template_form_name'), + controller: _nameController, + ), + ), + _FormField( + label: '설명', + child: ShadTextarea( + key: const ValueKey('template_form_description'), + controller: _descriptionController, + minHeight: 80, + maxHeight: 200, + ), + ), + _FormField( + label: '사용 여부', + child: ValueListenableBuilder( + valueListenable: _isActiveNotifier, + builder: (_, value, __) { + return Row( + children: [ + ShadSwitch( + value: value, + onChanged: _isSubmitting + ? null + : (next) => _isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ); + }, + ), + ), + _FormField( + label: '비고', + child: ShadTextarea( + key: const ValueKey('template_form_note'), + controller: _noteController, + minHeight: 80, + maxHeight: 200, + ), + ), + const SizedBox(height: 16), + Text( + '결재 단계', + style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Column( + children: [ + for (var index = 0; index < _steps.length; index++) + _StepEditorRow( + key: ValueKey('template_step_$index'), + field: _steps[index], + index: index, + isEdit: _isEdit, + isDisabled: _isSubmitting, + onRemove: _steps.length <= 1 || _isSubmitting + ? null + : () { + setState(() { + final removed = _steps.removeAt(index); + removed.dispose(); + }); + }, + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: ShadButton.outline( + onPressed: _isSubmitting + ? null + : () { + setState(() { + _steps.add( + _TemplateStepField.create(order: _steps.length + 1), + ); + }); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: const [ + Icon(lucide.LucideIcons.plus, size: 16), + SizedBox(width: 8), + Text('단계 추가'), + ], + ), + ), + ), + ], + ), + if (_errorText != null) ...[ + const SizedBox(height: 16), + Text( + _errorText!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isSubmitting ? '저장 중...' : (_isEdit ? '저장' : '등록')), + ), + ), + ], + ); + } + + List<_TemplateStepField> _buildInitialStepFields(ApprovalTemplate? template) { + if (template == null || template.steps.isEmpty) { + return [_TemplateStepField.create(order: 1)]; + } + return template.steps + .map( + (step) => _TemplateStepField( + id: step.id, + orderController: TextEditingController( + text: step.stepOrder.toString(), + ), + approverController: TextEditingController( + text: step.approver.id.toString(), + ), + noteController: TextEditingController(text: step.note ?? ''), + ), + ) + .toList(); + } + + String _generateTemplateCode() { + final authService = GetIt.I(); + final session = authService.session; + String normalizedEmployee = ''; + + final candidateValues = [ + session?.user.employeeNo, + session?.user.email, + session?.user.name, + ]; + for (final candidate in candidateValues) { + if (candidate == null) { + continue; + } + var source = candidate.trim(); + final atIndex = source.indexOf('@'); + if (atIndex > 0) { + source = source.substring(0, atIndex); + } + final normalized = source.toUpperCase().replaceAll( + RegExp(r'[^A-Z0-9]'), + '', + ); + if (normalized.isNotEmpty) { + normalizedEmployee = normalized; + break; + } + } + final userId = session?.user.id; + if (normalizedEmployee.isEmpty && userId != null) { + normalizedEmployee = userId.toString(); + } + + final suffixSource = normalizedEmployee.isEmpty + ? '0000' + : normalizedEmployee; + final suffix = suffixSource.length >= 4 + ? suffixSource.substring(suffixSource.length - 4) + : suffixSource.padLeft(4, '0'); + final timestamp = intl.DateFormat( + 'yyMMddHHmmssSSS', + ).format(DateTime.now().toUtc()); + return 'AP_TEMP_${suffix}_$timestamp'; + } + + Future _handleSubmit() async { + final isEdit = _isEdit; + final nameValue = _nameController.text.trim(); + if (nameValue.isEmpty) { + setState(() => _errorText = '템플릿명을 입력하세요.'); + return; + } + final validation = _validateSteps(_steps); + if (validation != null) { + setState(() => _errorText = validation); + return; + } + setState(() { + _errorText = null; + _isSubmitting = true; + }); + + final steps = _steps + .map( + (field) => ApprovalTemplateStepInput( + id: field.id, + stepOrder: int.parse(field.orderController.text.trim()), + approverId: int.parse(field.approverController.text.trim()), + note: field.noteController.text.trim().isEmpty + ? null + : field.noteController.text.trim(), + ), + ) + .toList(); + + final existingTemplate = widget.template; + final input = ApprovalTemplateInput( + code: isEdit && existingTemplate != null + ? existingTemplate.code + : _codeController.text.trim(), + name: nameValue, + description: _descriptionController.text.trim().isEmpty + ? null + : _descriptionController.text.trim(), + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + isActive: _isActiveNotifier.value, + ); + + final navigator = Navigator.of(context, rootNavigator: true); + ApprovalTemplate? result; + + try { + if (isEdit && existingTemplate != null) { + result = await widget.onUpdate(existingTemplate.id, input, steps); + if (result != null && navigator.mounted) { + navigator.pop( + ApprovalTemplateDetailResult( + action: ApprovalTemplateDetailAction.updated, + message: '템플릿 "${result.name}"을(를) 수정했습니다.', + ), + ); + return; + } + } else { + result = await widget.onCreate(input, steps); + if (result != null && navigator.mounted) { + navigator.pop( + ApprovalTemplateDetailResult( + action: ApprovalTemplateDetailAction.created, + message: '템플릿 "${result.name}"을(를) 생성했습니다.', + ), + ); + return; + } + } + setState(() { + _errorText = '요청 처리에 실패했습니다. 입력값을 확인한 뒤 다시 시도하세요.'; + _isSubmitting = false; + }); + } catch (_) { + if (mounted) { + setState(() { + _errorText = '요청 처리 중 오류가 발생했습니다. 다시 시도하세요.'; + _isSubmitting = false; + }); + } + } + } + + String? _validateSteps(List<_TemplateStepField> fields) { + if (fields.isEmpty) { + return '최소 1개 이상의 결재 단계를 입력하세요.'; + } + final orders = {}; + for (final field in fields) { + final order = int.tryParse(field.orderController.text.trim()); + final approver = int.tryParse(field.approverController.text.trim()); + if (order == null || order <= 0) { + return '모든 단계의 순서를 1 이상의 숫자로 입력하세요.'; + } + if (approver == null || approver <= 0) { + return '모든 단계의 승인자 ID를 1 이상의 숫자로 입력하세요.'; + } + if (!orders.add(order)) { + return '단계 순서는 중복될 수 없습니다.'; + } + } + return null; + } +} + +/// 템플릿 삭제/복구 섹션이다. +class _TemplateDangerSection extends StatelessWidget { + const _TemplateDangerSection({ + required this.template, + required this.onDelete, + required this.onRestore, + }); + + final ApprovalTemplate template; + final ApprovalTemplateDeleteCallback onDelete; + final ApprovalTemplateRestoreCallback onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final navigator = Navigator.of(context, rootNavigator: true); + final isActive = template.isActive; + + Future handleAction( + Future Function() callback, + ApprovalTemplateDetailAction action, + String message, + ) async { + final result = await callback(); + if (result != null && navigator.mounted) { + navigator.pop( + ApprovalTemplateDetailResult(action: action, message: message), + ); + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isActive + ? '삭제하면 템플릿은 미사용 상태로 전환됩니다. 필요 시 복구할 수 있습니다.' + : '복구하면 템플릿이 다시 사용 상태로 전환됩니다.', + style: theme.textTheme.small, + ), + const SizedBox(height: 16), + if (isActive) + ShadButton.destructive( + onPressed: () => handleAction( + () async { + final success = await onDelete(template.id); + return success ? template.copyWith(isActive: false) : null; + }, + ApprovalTemplateDetailAction.deleted, + '템플릿 "${template.name}"을(를) 삭제했습니다.', + ), + child: const Text('삭제'), + ) + else + ShadButton( + onPressed: () => handleAction( + () async => onRestore(template.id), + ApprovalTemplateDetailAction.restored, + '템플릿 "${template.name}"을(를) 복구했습니다.', + ), + child: const Text('복구'), + ), + ], + ); + } +} + +/// 폼 필드 레이아웃 위젯이다. +class _FormField extends StatelessWidget { + const _FormField({ + required this.label, + required this.child, + this.required = false, + }); + + final String label; + final Widget child; + final bool required; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (required) ...[ + const SizedBox(width: 4), + Text('*', style: theme.textTheme.small), + ], + ], + ), + const SizedBox(height: 8), + child, + ], + ), + ); + } +} + +/// 템플릿 단계 편집 행을 렌더링한다. +class _StepEditorRow extends StatelessWidget { + const _StepEditorRow({ + super.key, + required this.field, + required this.index, + required this.isEdit, + required this.isDisabled, + this.onRemove, + }); + + final _TemplateStepField field; + final int index; + final bool isEdit; + final bool isDisabled; + final VoidCallback? onRemove; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: theme.colorScheme.border.withValues(alpha: 0.6), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + key: ValueKey('template_step_${index}_order'), + controller: field.orderController, + keyboardType: TextInputType.number, + placeholder: const Text('단계 순서'), + enabled: !isDisabled, + ), + ), + const SizedBox(width: 12), + Expanded( + child: IgnorePointer( + ignoring: isDisabled, + child: ApprovalApproverAutocompleteField( + key: ValueKey('template_step_${index}_approver'), + idController: field.approverController, + hintText: '승인자 검색', + onSelected: (_) {}, + ), + ), + ), + const SizedBox(width: 12), + if (onRemove != null) + ShadButton.ghost( + size: ShadButtonSize.sm, + onPressed: onRemove, + child: const Icon(lucide.LucideIcons.trash2, size: 16), + ), + ], + ), + const SizedBox(height: 8), + IgnorePointer( + ignoring: isDisabled, + child: ShadTextarea( + controller: field.noteController, + minHeight: 60, + maxHeight: 160, + placeholder: const Text('비고 (선택)'), + ), + ), + if (isEdit && field.id != null) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text('단계 ID: ${field.id}', style: theme.textTheme.small), + ), + ], + ), + ); + } +} + +/// 템플릿 단계 필드 상태를 보관한다. +class _TemplateStepField { + _TemplateStepField({ + this.id, + required this.orderController, + required this.approverController, + required this.noteController, + }); + + final int? id; + final TextEditingController orderController; + final TextEditingController approverController; + final TextEditingController noteController; + + factory _TemplateStepField.create({required int order}) { + return _TemplateStepField( + orderController: TextEditingController(text: '$order'), + approverController: TextEditingController(), + noteController: TextEditingController(), + ); + } + + void dispose() { + orderController.dispose(); + approverController.dispose(); + noteController.dispose(); + } +} + +/// 단순 레이블/값 모델이다. diff --git a/lib/features/approvals/template/presentation/pages/approval_template_page.dart b/lib/features/approvals/template/presentation/pages/approval_template_page.dart index 0de9c07..bc34375 100644 --- a/lib/features/approvals/template/presentation/pages/approval_template_page.dart +++ b/lib/features/approvals/template/presentation/pages/approval_template_page.dart @@ -9,15 +9,13 @@ import '../../../../../core/constants/app_sections.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_table.dart'; -import '../../../../../widgets/components/superport_dialog.dart'; import '../../../../../widgets/components/feature_disabled_placeholder.dart'; -import '../../../shared/widgets/approver_autocomplete_field.dart'; import '../../../domain/entities/approval_template.dart'; import '../../../domain/repositories/approval_template_repository.dart'; import '../../../domain/usecases/apply_approval_template_use_case.dart'; import '../../../domain/usecases/save_approval_template_use_case.dart'; -import '../../../../auth/application/auth_service.dart'; import '../controllers/approval_template_controller.dart'; +import '../dialogs/approval_template_detail_dialog.dart'; /// 결재 템플릿 관리 페이지. 기능 플래그에 따라 준비중 화면을 노출한다. class ApprovalTemplatePage extends StatelessWidget { @@ -138,9 +136,7 @@ class _ApprovalTemplateEnabledPageState actions: [ ShadButton( leading: const Icon(lucide.LucideIcons.plus, size: 16), - onPressed: _controller.isSubmitting - ? null - : () => _openTemplateForm(), + onPressed: _controller.isSubmitting ? null : _openCreateTemplate, child: const Text('템플릿 생성'), ), ], @@ -218,7 +214,6 @@ class _ApprovalTemplateEnabledPageState ShadTableCell.header(child: Text('설명')), ShadTableCell.header(child: Text('사용')), ShadTableCell.header(child: Text('변경일시')), - ShadTableCell.header(child: Text('동작')), ], rows: templates.map((template) { return [ @@ -253,49 +248,6 @@ class _ApprovalTemplateEnabledPageState ), ), ), - ShadTableCell( - alignment: Alignment.centerRight, - child: Wrap( - spacing: 8, - runSpacing: 6, - children: [ - ShadButton.ghost( - key: ValueKey( - 'template_preview_${template.id}', - ), - size: ShadButtonSize.sm, - onPressed: () => - _openTemplatePreview(template.id), - child: const Text('보기'), - ), - ShadButton.ghost( - key: ValueKey( - 'template_edit_${template.id}', - ), - size: ShadButtonSize.sm, - onPressed: _controller.isSubmitting - ? null - : () => _openEditTemplate(template), - child: const Text('수정'), - ), - template.isActive - ? ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isSubmitting - ? null - : () => _confirmDelete(template), - child: const Text('삭제'), - ) - : ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isSubmitting - ? null - : () => _confirmRestore(template), - child: const Text('복구'), - ), - ], - ), - ), ]; }).toList(), rowHeight: 58, @@ -316,8 +268,6 @@ class _ApprovalTemplateEnabledPageState return const FixedTableSpanExtent(100); case 6: return const FixedTableSpanExtent(180); - case 7: - return const FixedTableSpanExtent(220); default: return const FixedTableSpanExtent(140); } @@ -335,6 +285,22 @@ class _ApprovalTemplateEnabledPageState _controller.fetch(page: 1); }, isLoading: _controller.isLoading, + onRowTap: _controller.isSubmitting + ? null + : (index) { + if (templates.isEmpty) { + return; + } + final int safeIndex; + if (index < 0) { + safeIndex = 0; + } else if (index >= templates.length) { + safeIndex = templates.length - 1; + } else { + safeIndex = index; + } + _openTemplateDetail(templates[safeIndex]); + }, ), ], ), @@ -356,144 +322,18 @@ class _ApprovalTemplateEnabledPageState _searchFocus.requestFocus(); } - String _generateTemplateCode() { - final authService = GetIt.I(); - final session = authService.session; - String normalizedEmployee = ''; - - final candidateValues = [ - session?.user.employeeNo, - session?.user.email, - session?.user.name, - ]; - for (final candidate in candidateValues) { - if (candidate == null) { - continue; - } - var source = candidate.trim(); - final atIndex = source.indexOf('@'); - if (atIndex > 0) { - source = source.substring(0, atIndex); - } - final normalized = source.toUpperCase().replaceAll( - RegExp(r'[^A-Z0-9]'), - '', - ); - if (normalized.isNotEmpty) { - normalizedEmployee = normalized; - break; - } + String _statusLabel(ApprovalTemplateStatusFilter filter) { + switch (filter) { + case ApprovalTemplateStatusFilter.all: + return '전체(사용/미사용)'; + case ApprovalTemplateStatusFilter.activeOnly: + return '사용중'; + case ApprovalTemplateStatusFilter.inactiveOnly: + return '미사용'; } - if (normalizedEmployee.isEmpty && session?.user.id != null) { - normalizedEmployee = session!.user.id.toString(); - } - - final suffixSource = normalizedEmployee.isEmpty - ? '0000' - : normalizedEmployee; - final suffix = suffixSource.length >= 4 - ? suffixSource.substring(suffixSource.length - 4) - : suffixSource.padLeft(4, '0'); - final timestamp = intl.DateFormat( - 'yyMMddHHmmssSSS', - ).format(DateTime.now().toUtc()); - return 'AP_TEMP_${suffix}_$timestamp'; } - Future _openTemplatePreview(int templateId) async { - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => const Center(child: CircularProgressIndicator()), - ); - final detail = await _controller.fetchDetail(templateId); - if (mounted) { - Navigator.of(context, rootNavigator: true).pop(); - } - if (!mounted) { - return; - } - if (detail == null) { - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - const SnackBar(content: Text('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.')), - ); - return; - } - - final theme = ShadTheme.of(context); - await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: detail.name, - description: detail.description, - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 540), - child: detail.steps.isEmpty - ? Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text('등록된 결재 단계가 없습니다.', style: theme.textTheme.muted), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - for (final step in detail.steps) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - color: theme.colorScheme.secondary.withValues( - alpha: 0.12, - ), - ), - alignment: Alignment.center, - child: Text( - '${step.stepOrder}', - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - step.approver.name, - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - '사번 ${step.approver.employeeNo}', - style: theme.textTheme.muted, - ), - if (step.note?.isNotEmpty ?? false) - Text( - step.note!, - style: theme.textTheme.muted, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 12), - ], - ], - ), - ), - ), - ); - } - - Future _openEditTemplate(ApprovalTemplate template) async { + Future _openTemplateDetail(ApprovalTemplate template) async { showDialog( context: context, barrierDismissible: false, @@ -503,366 +343,44 @@ class _ApprovalTemplateEnabledPageState if (mounted) { Navigator.of(context, rootNavigator: true).pop(); } - if (!mounted || detail == null) { + if (!mounted) { return; } - final success = await _openTemplateForm(template: detail); - if (!mounted || success != true) return; - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - SnackBar(content: Text('템플릿 "${detail.name}"을(를) 수정했습니다.')), - ); - } - - Future _confirmDelete(ApprovalTemplate template) async { - final confirmed = await SuperportDialog.show( + if (detail == null) { + _showSnack('템플릿 정보를 불러오지 못했습니다. 다시 시도하세요.'); + return; + } + final result = await showApprovalTemplateDetailDialog( context: context, - dialog: SuperportDialog( - title: '템플릿 삭제', - description: - '"${template.name}" 템플릿을 삭제하시겠습니까?\n삭제 시 템플릿은 미사용 상태로 전환됩니다.', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('삭제'), - ), - ], - ), - ); - if (confirmed != true) return; - final ok = await _controller.delete(template.id); - if (!mounted || !ok) return; - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - SnackBar(content: Text('템플릿 "${template.name}"을(를) 삭제했습니다.')), + dateFormat: _dateFormat, + template: detail, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, ); + if (result != null && mounted) { + _showSnack(result.message); + } } - Future _confirmRestore(ApprovalTemplate template) async { - final confirmed = await SuperportDialog.show( + Future _openCreateTemplate() async { + final result = await showApprovalTemplateDetailDialog( context: context, - dialog: SuperportDialog( - title: '템플릿 복구', - description: '"${template.name}" 템플릿을 복구하시겠습니까?', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('복구'), - ), - ], - ), + dateFormat: _dateFormat, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, ); - if (confirmed != true) return; - final restored = await _controller.restore(template.id); - if (!mounted || restored == null) return; + if (result != null && mounted) { + _showSnack(result.message); + } + } + + void _showSnack(String message) { final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - SnackBar(content: Text('템플릿 "${restored.name}"을(를) 복구했습니다.')), - ); - } - - Future _openTemplateForm({ApprovalTemplate? template}) async { - final isEdit = template != null; - final existingTemplate = template; - final codeController = TextEditingController( - text: isEdit ? existingTemplate!.code : _generateTemplateCode(), - ); - final nameController = TextEditingController(text: template?.name ?? ''); - final descriptionController = TextEditingController( - text: template?.description ?? '', - ); - final noteController = TextEditingController(text: template?.note ?? ''); - final steps = _buildStepFields(template); - final statusNotifier = ValueNotifier(template?.isActive ?? true); - bool isSaving = false; - String? errorText; - StateSetter? modalSetState; - - Future handleSubmit() async { - if (isSaving) return; - final codeValue = codeController.text.trim(); - final nameValue = nameController.text.trim(); - if (!isEdit && codeValue.isEmpty) { - modalSetState?.call(() => errorText = '템플릿 코드를 입력하세요.'); - return; - } - if (nameValue.isEmpty) { - modalSetState?.call(() => errorText = '템플릿명을 입력하세요.'); - return; - } - final validation = _validateSteps(steps); - if (validation != null) { - modalSetState?.call(() => errorText = validation); - return; - } - modalSetState?.call(() => errorText = null); - final stepInputs = steps - .map( - (field) => ApprovalTemplateStepInput( - id: field.id, - stepOrder: int.parse(field.orderController.text.trim()), - approverId: int.parse(field.approverController.text.trim()), - note: field.noteController.text.trim().isEmpty - ? null - : field.noteController.text.trim(), - ), - ) - .toList(); - final input = ApprovalTemplateInput( - code: isEdit ? existingTemplate!.code : codeValue, - name: nameValue, - description: descriptionController.text.trim().isEmpty - ? null - : descriptionController.text.trim(), - note: noteController.text.trim().isEmpty - ? null - : noteController.text.trim(), - isActive: statusNotifier.value, - ); - - modalSetState?.call(() => isSaving = true); - - final success = isEdit - ? await _controller.update(existingTemplate!.id, input, stepInputs) - : await _controller.create(input, stepInputs); - if (success != null && mounted) { - Navigator.of(context, rootNavigator: true).pop(true); - } else { - modalSetState?.call(() => isSaving = false); - } - } - - final result = await showSuperportDialog( - context: context, - title: isEdit ? '템플릿 수정' : '템플릿 생성', - barrierDismissible: !isSaving, - onSubmit: handleSubmit, - body: StatefulBuilder( - builder: (dialogContext, setModalState) { - modalSetState = setModalState; - final theme = ShadTheme.of(dialogContext); - return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 640), - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (!isEdit) - _FormField( - label: '템플릿 코드', - child: ShadInput( - controller: codeController, - readOnly: true, - enabled: false, - placeholder: const Text('예: AP_INBOUND'), - ), - ), - _FormField( - label: '템플릿명', - child: ShadInput(controller: nameController), - ), - _FormField( - label: '설명', - child: ShadTextarea( - controller: descriptionController, - minHeight: 80, - maxHeight: 200, - ), - ), - _FormField( - label: '사용 여부', - child: ValueListenableBuilder( - valueListenable: statusNotifier, - builder: (_, value, __) { - return Row( - children: [ - ShadSwitch( - value: value, - onChanged: isSaving - ? null - : (next) => statusNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ); - }, - ), - ), - _FormField( - label: '비고', - child: ShadTextarea( - controller: noteController, - minHeight: 80, - maxHeight: 200, - ), - ), - const SizedBox(height: 16), - Text( - '결재 단계', - style: theme.textTheme.small.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Column( - children: [ - for (var index = 0; index < steps.length; index++) - _StepEditorRow( - key: ValueKey('step_field_$index'), - field: steps[index], - index: index, - isEdit: isEdit, - isDisabled: isSaving, - onRemove: steps.length <= 1 || isSaving - ? null - : () { - setModalState(() { - final removed = steps.removeAt(index); - removed.dispose(); - }); - }, - ), - const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: ShadButton.outline( - onPressed: isSaving - ? null - : () { - setModalState(() { - steps.add( - _TemplateStepField.create( - order: steps.length + 1, - ), - ); - }); - }, - child: Row( - mainAxisSize: MainAxisSize.min, - children: const [ - Icon(lucide.LucideIcons.plus, size: 16), - SizedBox(width: 8), - Text('단계 추가'), - ], - ), - ), - ), - ], - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - errorText!, - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.destructive, - ), - ), - ), - ], - ), - ), - ); - }, - ), - actions: [ - ShadButton.ghost( - onPressed: () { - if (isSaving) return; - Navigator.of(context, rootNavigator: true).pop(false); - }, - child: const Text('취소'), - ), - ShadButton( - onPressed: handleSubmit, - child: Text(isEdit ? '수정 완료' : '생성 완료'), - ), - ], - ); - - final createdName = nameController.text.trim(); - - for (final field in steps) { - field.dispose(); - } - codeController.dispose(); - nameController.dispose(); - descriptionController.dispose(); - noteController.dispose(); - statusNotifier.dispose(); - - if (result == true && mounted && template == null) { - final messenger = ScaffoldMessenger.maybeOf(context); - messenger?.showSnackBar( - SnackBar(content: Text('템플릿 "$createdName"을 생성했습니다.')), - ); - } - return result; - } - - String? _validateSteps(List<_TemplateStepField> fields) { - if (fields.isEmpty) { - return '최소 1개의 결재 단계를 등록하세요.'; - } - for (var index = 0; index < fields.length; index++) { - final field = fields[index]; - final orderText = field.orderController.text.trim(); - final approverText = field.approverController.text.trim(); - final order = int.tryParse(orderText); - final approver = int.tryParse(approverText); - if (order == null || order <= 0) { - return '${index + 1}번째 단계의 순서를 올바르게 입력하세요.'; - } - if (approver == null || approver <= 0) { - return '${index + 1}번째 단계의 승인자ID를 올바르게 입력하세요.'; - } - } - return null; - } - - List<_TemplateStepField> _buildStepFields(ApprovalTemplate? template) { - if (template == null || template.steps.isEmpty) { - return [_TemplateStepField.create(order: 1)]; - } - return template.steps - .map( - (step) => _TemplateStepField( - id: step.id, - orderController: TextEditingController( - text: step.stepOrder.toString(), - ), - approverController: TextEditingController( - text: step.approver.id.toString(), - ), - noteController: TextEditingController(text: step.note ?? ''), - ), - ) - .toList(); - } - - String _statusLabel(ApprovalTemplateStatusFilter filter) { - switch (filter) { - case ApprovalTemplateStatusFilter.all: - return '전체'; - case ApprovalTemplateStatusFilter.activeOnly: - return '사용만'; - case ApprovalTemplateStatusFilter.inactiveOnly: - return '미사용만'; - } + messenger?.showSnackBar(SnackBar(content: Text(message))); } } @@ -955,139 +473,3 @@ class _TemplateStepSummaryCellState extends State<_TemplateStepSummaryCell> { ); } } - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - child, - ], - ), - ); - } -} - -class _StepEditorRow extends StatelessWidget { - const _StepEditorRow({ - super.key, - required this.field, - required this.index, - required this.isEdit, - required this.isDisabled, - required this.onRemove, - }); - - final _TemplateStepField field; - final int index; - final bool isEdit; - final bool isDisabled; - final VoidCallback? onRemove; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Container( - margin: const EdgeInsets.symmetric(vertical: 6), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all( - color: theme.colorScheme.border.withValues(alpha: 0.6), - ), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: ShadInput( - controller: field.orderController, - keyboardType: TextInputType.number, - placeholder: const Text('단계 순서'), - enabled: !isDisabled, - ), - ), - const SizedBox(width: 12), - Expanded( - child: IgnorePointer( - ignoring: isDisabled, - child: ApprovalApproverAutocompleteField( - idController: field.approverController, - hintText: '승인자 검색', - onSelected: (_) {}, - ), - ), - ), - const SizedBox(width: 12), - if (onRemove != null) - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRemove, - child: const Icon(lucide.LucideIcons.trash2, size: 16), - ), - ], - ), - const SizedBox(height: 8), - IgnorePointer( - ignoring: isDisabled, - child: ShadTextarea( - controller: field.noteController, - minHeight: 60, - maxHeight: 160, - placeholder: const Text('비고 (선택)'), - ), - ), - if (isEdit && field.id != null) - Padding( - padding: const EdgeInsets.only(top: 4), - child: Text('단계 ID: ${field.id}', style: theme.textTheme.small), - ), - ], - ), - ); - } -} - -class _TemplateStepField { - _TemplateStepField({ - this.id, - required this.orderController, - required this.approverController, - required this.noteController, - }); - - final int? id; - final TextEditingController orderController; - final TextEditingController approverController; - final TextEditingController noteController; - - void dispose() { - orderController.dispose(); - approverController.dispose(); - noteController.dispose(); - } - - factory _TemplateStepField.create({required int order}) { - return _TemplateStepField( - orderController: TextEditingController(text: order.toString()), - approverController: TextEditingController(), - noteController: TextEditingController(), - ); - } -} diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index fffd807..ea402b5 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -45,6 +45,10 @@ import '../widgets/inbound_detail_view.dart'; const String _inboundTransactionTypeId = '입고'; +class _InboundDetailSections { + static const lines = 'lines'; +} + /// 입고 목록과 상세/등록 모달을 관리하는 화면. class InboundPage extends StatefulWidget { const InboundPage({super.key, required this.routeUri}); @@ -711,7 +715,10 @@ class _InboundPageState extends State { rowSpanExtent: (index) => const FixedTableSpanExtent(InboundTableSpec.rowSpanHeight), onRowTap: (rowIndex) { - final record = records[rowIndex]; + if (rowIndex <= 0 || rowIndex > records.length) { + return; + } + final record = records[rowIndex - 1]; _selectRecord(record, openDetail: true); }, ); @@ -771,18 +778,136 @@ class _InboundPageState extends State { await showInventoryTransactionDetailDialog( context: context, title: '입고 상세', - transactionNumber: record.transactionNumber, - body: InboundDetailView( - record: record, - dateFormatter: _dateFormatter, - currencyFormatter: _currencyFormatter, - transitionsEnabled: _transitionsEnabled, - ), + description: '입고 트랜잭션과 라인 품목을 확인하세요.', + summary: _buildInboundDetailSummary(record), + summaryBadges: _buildInboundDetailBadges(record), + metadata: _buildInboundDetailMetadata(record), + sections: [ + SuperportDetailDialogSection( + id: _InboundDetailSections.lines, + label: '라인 품목', + icon: lucide.LucideIcons.listChecks, + builder: (_) => InboundDetailView( + record: record, + currencyFormatter: _currencyFormatter, + transitionsEnabled: _transitionsEnabled, + ), + ), + ], + initialSectionId: _InboundDetailSections.lines, actions: _buildDetailActions(record), - constraints: const BoxConstraints(maxWidth: 920), ); } + Widget _buildInboundDetailSummary(InboundRecord record) { + final theme = ShadTheme.of(context); + final processedAt = _dateFormatter.format(record.processedAt); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(record.transactionNumber, style: theme.textTheme.h4), + const SizedBox(height: 4), + Text( + '$processedAt · ${record.transactionType}', + style: theme.textTheme.muted, + ), + ], + ); + } + + List _buildInboundDetailBadges(InboundRecord record) { + final badges = [ShadBadge(child: Text(record.status))]; + badges.add( + record.isActive + ? const ShadBadge.outline(child: Text('사용중')) + : const ShadBadge.destructive(child: Text('미사용')), + ); + if (record.partnerName != null && record.partnerName!.trim().isNotEmpty) { + badges.add(ShadBadge.outline(child: Text(record.partnerName!.trim()))); + } + return badges; + } + + List _buildInboundDetailMetadata( + InboundRecord record, + ) { + final partnerSummary = _formatPartnerSummary(record); + return [ + SuperportDetailMetadata.text( + label: '트랜잭션번호', + value: record.transactionNumber, + ), + SuperportDetailMetadata.text( + label: '처리일자', + value: _dateFormatter.format(record.processedAt), + ), + SuperportDetailMetadata.text( + label: '트랜잭션 유형', + value: record.transactionType, + ), + SuperportDetailMetadata.text(label: '상태', value: record.status), + SuperportDetailMetadata.text( + label: '사용 상태', + value: record.isActive ? '사용중' : '미사용', + ), + SuperportDetailMetadata.text(label: '작성자', value: record.writer), + SuperportDetailMetadata.text(label: '창고', value: record.warehouse), + SuperportDetailMetadata.text( + label: '창고 코드', + value: _dashIfEmpty(record.warehouseCode), + ), + SuperportDetailMetadata.text( + label: '창고 우편번호', + value: _dashIfEmpty(record.warehouseZipcode), + ), + SuperportDetailMetadata.text( + label: '창고 주소', + value: _dashIfEmpty(record.warehouseAddress), + ), + SuperportDetailMetadata.text(label: '파트너사', value: partnerSummary), + SuperportDetailMetadata.text( + label: '파트너 코드', + value: _dashIfEmpty(record.partnerCode), + ), + SuperportDetailMetadata.text(label: '품목 수', value: '${record.itemCount}'), + SuperportDetailMetadata.text( + label: '총 수량', + value: '${record.totalQuantity}', + ), + SuperportDetailMetadata.text( + label: '총 금액', + value: _currencyFormatter.format(record.totalAmount), + ), + SuperportDetailMetadata.text( + label: '예상 반납일', + value: record.expectedReturnDate == null + ? '-' + : _dateFormatter.format(record.expectedReturnDate!), + ), + SuperportDetailMetadata.text( + label: '비고', + value: _dashIfEmpty(record.remark), + ), + ]; + } + + String _formatPartnerSummary(InboundRecord record) { + final name = _dashIfEmpty(record.partnerName); + final code = _dashIfEmpty(record.partnerCode); + if (name == '-') { + return '-'; + } + return code == '-' ? name : '$name ($code)'; + } + + String _dashIfEmpty(String? value) { + if (value == null) { + return '-'; + } + final trimmed = value.trim(); + return trimmed.isEmpty ? '-' : trimmed; + } + List _buildDetailActions(InboundRecord record) { final isProcessing = _isProcessing(record.id) || _isLoading; final actions = []; diff --git a/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart b/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart index 3328734..c756680 100644 --- a/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart +++ b/lib/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart @@ -9,13 +9,11 @@ class InboundDetailView extends StatelessWidget { const InboundDetailView({ super.key, required this.record, - required this.dateFormatter, required this.currencyFormatter, this.transitionsEnabled = true, }); final InboundRecord record; - final intl.DateFormat dateFormatter; final intl.NumberFormat currencyFormatter; final bool transitionsEnabled; @@ -31,44 +29,6 @@ class InboundDetailView extends StatelessWidget { ), const SizedBox(height: 16), ], - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - _DetailChip( - label: '처리일자', - value: dateFormatter.format(record.processedAt), - ), - _DetailChip(label: '창고', value: record.warehouse), - if (record.warehouseCode != null && - record.warehouseCode!.trim().isNotEmpty) - _DetailChip(label: '창고 코드', value: record.warehouseCode!), - if (record.warehouseZipcode != null && - record.warehouseZipcode!.trim().isNotEmpty) - _DetailChip(label: '창고 우편번호', value: record.warehouseZipcode!), - if (record.warehouseAddress != null && - record.warehouseAddress!.trim().isNotEmpty) - _DetailChip(label: '창고 주소', value: record.warehouseAddress!), - _DetailChip(label: '트랜잭션 유형', value: record.transactionType), - _DetailChip(label: '상태', value: record.status), - _DetailChip(label: '작성자', value: record.writer), - if (record.partnerName != null && - record.partnerName!.trim().isNotEmpty) - _DetailChip(label: '파트너사', value: record.partnerName!.trim()), - if (record.partnerCode != null && - record.partnerCode!.trim().isNotEmpty) - _DetailChip(label: '파트너 코드', value: record.partnerCode!.trim()), - _DetailChip(label: '품목 수', value: '${record.itemCount}'), - _DetailChip(label: '총 수량', value: '${record.totalQuantity}'), - _DetailChip( - label: '총 금액', - value: currencyFormatter.format(record.totalAmount), - ), - if (record.remark.isNotEmpty) - _DetailChip(label: '비고', value: record.remark), - ], - ), - const SizedBox(height: 24), Text('라인 품목', style: theme.textTheme.h4), const SizedBox(height: 8), _buildLineTable(), @@ -108,36 +68,3 @@ class InboundDetailView extends StatelessWidget { ); } } - -class _DetailChip extends StatelessWidget { - const _DetailChip({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: theme.colorScheme.card, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: theme.colorScheme.border), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - label, - style: theme.textTheme.small, - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text(value, style: theme.textTheme.p, textAlign: TextAlign.center), - ], - ), - ); - } -} diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 7ccebbb..4e9f7a4 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -45,6 +45,10 @@ import '../widgets/outbound_detail_view.dart'; const String _outboundTransactionTypeId = '출고'; +class _OutboundDetailSections { + static const lines = 'lines'; +} + /// 출고 목록과 등록/수정 모달을 관리하는 페이지. class OutboundPage extends StatefulWidget { const OutboundPage({super.key, required this.routeUri}); @@ -650,7 +654,11 @@ class _OutboundPageState extends State { OutboundTableSpec.rowSpanHeight, ), onRowTap: (rowIndex) { - final record = visibleRecords[rowIndex]; + if (rowIndex <= 0 || + rowIndex > visibleRecords.length) { + return; + } + final record = visibleRecords[rowIndex - 1]; _selectRecord(record, openDetail: true); }, ); @@ -870,18 +878,109 @@ class _OutboundPageState extends State { await showInventoryTransactionDetailDialog( context: context, title: '출고 상세', - transactionNumber: record.transactionNumber, - body: OutboundDetailView( - record: record, - dateFormatter: _dateFormatter, - currencyFormatter: _currencyFormatter, - transitionsEnabled: _transitionsEnabled, - ), + description: '출고 고객사와 라인 품목 정보를 확인하세요.', + summary: _buildOutboundDetailSummary(record), + summaryBadges: _buildOutboundDetailBadges(record), + metadata: _buildOutboundDetailMetadata(record), + sections: [ + SuperportDetailDialogSection( + id: _OutboundDetailSections.lines, + label: '라인 품목', + icon: lucide.LucideIcons.listChecks, + builder: (_) => OutboundDetailView( + record: record, + currencyFormatter: _currencyFormatter, + transitionsEnabled: _transitionsEnabled, + ), + ), + ], + initialSectionId: _OutboundDetailSections.lines, actions: _buildDetailActions(record), - constraints: const BoxConstraints(maxWidth: 920), ); } + Widget _buildOutboundDetailSummary(OutboundRecord record) { + final theme = ShadTheme.of(context); + final processedAt = _dateFormatter.format(record.processedAt); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(record.transactionNumber, style: theme.textTheme.h4), + const SizedBox(height: 4), + Text( + '$processedAt · ${record.transactionType}', + style: theme.textTheme.muted, + ), + ], + ); + } + + List _buildOutboundDetailBadges(OutboundRecord record) { + return [ + ShadBadge(child: Text(record.status)), + ShadBadge.outline(child: Text('고객 ${record.customerCount}곳')), + ]; + } + + List _buildOutboundDetailMetadata( + OutboundRecord record, + ) { + return [ + SuperportDetailMetadata.text( + label: '트랜잭션번호', + value: record.transactionNumber, + ), + SuperportDetailMetadata.text( + label: '처리일자', + value: _dateFormatter.format(record.processedAt), + ), + SuperportDetailMetadata.text( + label: '트랜잭션 유형', + value: record.transactionType, + ), + SuperportDetailMetadata.text(label: '상태', value: record.status), + SuperportDetailMetadata.text(label: '작성자', value: record.writer), + SuperportDetailMetadata.text(label: '창고', value: record.warehouse), + SuperportDetailMetadata.text( + label: '창고 코드', + value: _dashIfEmpty(record.warehouseCode), + ), + SuperportDetailMetadata.text( + label: '창고 우편번호', + value: _dashIfEmpty(record.warehouseZipcode), + ), + SuperportDetailMetadata.text( + label: '창고 주소', + value: _dashIfEmpty(record.warehouseAddress), + ), + SuperportDetailMetadata.text( + label: '고객 수', + value: '${record.customerCount}', + ), + SuperportDetailMetadata.text(label: '품목 수', value: '${record.itemCount}'), + SuperportDetailMetadata.text( + label: '총 수량', + value: '${record.totalQuantity}', + ), + SuperportDetailMetadata.text( + label: '총 금액', + value: _currencyFormatter.format(record.totalAmount), + ), + SuperportDetailMetadata.text( + label: '비고', + value: _dashIfEmpty(record.remark), + ), + ]; + } + + String _dashIfEmpty(String? value) { + if (value == null) { + return '-'; + } + final trimmed = value.trim(); + return trimmed.isEmpty ? '-' : trimmed; + } + List _buildDetailActions(OutboundRecord record) { final isProcessing = _isProcessing(record.id) || _isLoading; final actions = []; diff --git a/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart b/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart index 68f19d2..6fc5df9 100644 --- a/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart +++ b/lib/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart @@ -9,13 +9,11 @@ class OutboundDetailView extends StatelessWidget { const OutboundDetailView({ super.key, required this.record, - required this.dateFormatter, required this.currencyFormatter, this.transitionsEnabled = true, }); final OutboundRecord record; - final intl.DateFormat dateFormatter; final intl.NumberFormat currencyFormatter; final bool transitionsEnabled; @@ -31,39 +29,6 @@ class OutboundDetailView extends StatelessWidget { ), const SizedBox(height: 16), ], - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - _DetailChip( - label: '처리일자', - value: dateFormatter.format(record.processedAt), - ), - _DetailChip(label: '창고', value: record.warehouse), - if (record.warehouseCode != null && - record.warehouseCode!.trim().isNotEmpty) - _DetailChip(label: '창고 코드', value: record.warehouseCode!), - if (record.warehouseZipcode != null && - record.warehouseZipcode!.trim().isNotEmpty) - _DetailChip(label: '창고 우편번호', value: record.warehouseZipcode!), - if (record.warehouseAddress != null && - record.warehouseAddress!.trim().isNotEmpty) - _DetailChip(label: '창고 주소', value: record.warehouseAddress!), - _DetailChip(label: '트랜잭션 유형', value: record.transactionType), - _DetailChip(label: '상태', value: record.status), - _DetailChip(label: '작성자', value: record.writer), - _DetailChip(label: '고객 수', value: '${record.customerCount}'), - _DetailChip(label: '품목 수', value: '${record.itemCount}'), - _DetailChip(label: '총 수량', value: '${record.totalQuantity}'), - _DetailChip( - label: '총 금액', - value: currencyFormatter.format(record.totalAmount), - ), - if (record.remark.isNotEmpty && record.remark != '-') - _DetailChip(label: '비고', value: record.remark), - ], - ), - const SizedBox(height: 16), Text('출고 고객사', style: theme.textTheme.h4), const SizedBox(height: 8), Wrap( @@ -122,36 +87,3 @@ class OutboundDetailView extends StatelessWidget { ); } } - -class _DetailChip extends StatelessWidget { - const _DetailChip({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: theme.colorScheme.card, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: theme.colorScheme.border), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - label, - style: theme.textTheme.small, - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text(value, style: theme.textTheme.p, textAlign: TextAlign.center), - ], - ), - ); - } -} diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index 52a996d..bf42351 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -44,6 +44,10 @@ import '../widgets/rental_detail_view.dart'; const String _rentalTransactionTypeRent = '대여'; const String _rentalTransactionTypeReturn = '반납'; +class _RentalDetailSections { + static const lines = 'lines'; +} + /// 대여/반납 목록과 등록 모달을 관리하는 페이지. class RentalPage extends StatefulWidget { const RentalPage({super.key, required this.routeUri}); @@ -597,7 +601,11 @@ class _RentalPageState extends State { RentalTableSpec.rowSpanHeight, ), onRowTap: (rowIndex) { - final record = visibleRecords[rowIndex]; + if (rowIndex <= 0 || + rowIndex > visibleRecords.length) { + return; + } + final record = visibleRecords[rowIndex - 1]; _selectRecord(record, openDetail: true); }, ); @@ -829,18 +837,116 @@ class _RentalPageState extends State { await showInventoryTransactionDetailDialog( context: context, title: '대여 상세', - transactionNumber: record.transactionNumber, - body: RentalDetailView( - record: record, - dateFormatter: _dateFormatter, - currencyFormatter: _currencyFormatter, - transitionsEnabled: _transitionsEnabled, - ), + description: '대여 고객사와 라인 품목을 확인하세요.', + summary: _buildRentalDetailSummary(record), + summaryBadges: _buildRentalDetailBadges(record), + metadata: _buildRentalDetailMetadata(record), + sections: [ + SuperportDetailDialogSection( + id: _RentalDetailSections.lines, + label: '라인 품목', + icon: lucide.LucideIcons.listChecks, + builder: (_) => RentalDetailView( + record: record, + currencyFormatter: _currencyFormatter, + transitionsEnabled: _transitionsEnabled, + ), + ), + ], + initialSectionId: _RentalDetailSections.lines, actions: _buildDetailActions(record), - constraints: const BoxConstraints(maxWidth: 920), ); } + Widget _buildRentalDetailSummary(RentalRecord record) { + final theme = ShadTheme.of(context); + final processedAt = _dateFormatter.format(record.processedAt); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(record.transactionNumber, style: theme.textTheme.h4), + const SizedBox(height: 4), + Text( + '$processedAt · ${record.rentalType}', + style: theme.textTheme.muted, + ), + ], + ); + } + + List _buildRentalDetailBadges(RentalRecord record) { + return [ + ShadBadge(child: Text(record.status)), + ShadBadge.outline(child: Text(record.transactionType)), + ]; + } + + List _buildRentalDetailMetadata( + RentalRecord record, + ) { + return [ + SuperportDetailMetadata.text( + label: '트랜잭션번호', + value: record.transactionNumber, + ), + SuperportDetailMetadata.text( + label: '처리일자', + value: _dateFormatter.format(record.processedAt), + ), + SuperportDetailMetadata.text( + label: '트랜잭션 유형', + value: record.transactionType, + ), + SuperportDetailMetadata.text(label: '대여 구분', value: record.rentalType), + SuperportDetailMetadata.text(label: '상태', value: record.status), + SuperportDetailMetadata.text(label: '작성자', value: record.writer), + SuperportDetailMetadata.text(label: '창고', value: record.warehouse), + SuperportDetailMetadata.text( + label: '창고 코드', + value: _dashIfEmpty(record.warehouseCode), + ), + SuperportDetailMetadata.text( + label: '창고 우편번호', + value: _dashIfEmpty(record.warehouseZipcode), + ), + SuperportDetailMetadata.text( + label: '창고 주소', + value: _dashIfEmpty(record.warehouseAddress), + ), + SuperportDetailMetadata.text( + label: '고객 수', + value: '${record.customerCount}', + ), + SuperportDetailMetadata.text(label: '품목 수', value: '${record.itemCount}'), + SuperportDetailMetadata.text( + label: '총 수량', + value: '${record.totalQuantity}', + ), + SuperportDetailMetadata.text( + label: '총 금액', + value: _currencyFormatter.format(record.totalAmount), + ), + SuperportDetailMetadata.text( + label: '반납 예정일', + value: record.returnDueDate == null + ? '-' + : _dateFormatter.format(record.returnDueDate!), + ), + SuperportDetailMetadata.text( + label: '비고', + value: _dashIfEmpty(record.remark), + ), + ]; + } + + String _dashIfEmpty(String? value) { + if (value == null) { + return '-'; + } + final trimmed = value.trim(); + return trimmed.isEmpty ? '-' : trimmed; + } + List _buildDetailActions(RentalRecord record) { final isProcessing = _isProcessing(record.id) || _isLoading; final actions = []; diff --git a/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart b/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart index 69803a3..c7ec690 100644 --- a/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart +++ b/lib/features/inventory/rental/presentation/widgets/rental_detail_view.dart @@ -9,13 +9,11 @@ class RentalDetailView extends StatelessWidget { const RentalDetailView({ super.key, required this.record, - required this.dateFormatter, required this.currencyFormatter, this.transitionsEnabled = true, }); final RentalRecord record; - final intl.DateFormat dateFormatter; final intl.NumberFormat currencyFormatter; final bool transitionsEnabled; @@ -31,36 +29,6 @@ class RentalDetailView extends StatelessWidget { ), const SizedBox(height: 16), ], - Wrap( - spacing: 12, - runSpacing: 12, - children: [ - _DetailChip( - label: '처리일자', - value: dateFormatter.format(record.processedAt), - ), - _DetailChip(label: '창고', value: record.warehouse), - _DetailChip(label: '트랜잭션 유형', value: record.transactionType), - _DetailChip(label: '대여 구분', value: record.rentalType), - _DetailChip(label: '상태', value: record.status), - _DetailChip(label: '작성자', value: record.writer), - _DetailChip( - label: '반납 예정일', - value: record.returnDueDate == null - ? '-' - : dateFormatter.format(record.returnDueDate!), - ), - _DetailChip(label: '고객 수', value: '${record.customerCount}'), - _DetailChip(label: '총 수량', value: '${record.totalQuantity}'), - _DetailChip( - label: '총 금액', - value: currencyFormatter.format(record.totalAmount), - ), - if (record.remark.isNotEmpty && record.remark != '-') - _DetailChip(label: '비고', value: record.remark), - ], - ), - const SizedBox(height: 16), Text('연결 고객사', style: theme.textTheme.h4), const SizedBox(height: 8), Wrap( @@ -119,36 +87,3 @@ class RentalDetailView extends StatelessWidget { ); } } - -class _DetailChip extends StatelessWidget { - const _DetailChip({required this.label, required this.value}); - - final String label; - final String value; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: theme.colorScheme.card, - borderRadius: BorderRadius.circular(6), - border: Border.all(color: theme.colorScheme.border), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - label, - style: theme.textTheme.small, - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - Text(value, style: theme.textTheme.p, textAlign: TextAlign.center), - ], - ), - ); - } -} diff --git a/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart b/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart index 12c085c..175cf98 100644 --- a/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart +++ b/lib/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart @@ -1,43 +1,47 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; -import '../../../../../widgets/components/superport_dialog.dart'; +import '../../../../../widgets/components/superport_detail_dialog.dart'; -/// 재고 트랜잭션 상세 정보를 Superport 다이얼로그로 표시하는 헬퍼이다. +export '../../../../../widgets/components/superport_detail_dialog.dart' + show SuperportDetailDialogSection, SuperportDetailMetadata; + +/// 재고 트랜잭션 상세 정보를 Superport 상세 다이얼로그로 감싼다. Future showInventoryTransactionDetailDialog({ required BuildContext context, required String title, - required String transactionNumber, - required Widget body, + required List metadata, + List sections = const [], + Widget? summary, + List summaryBadges = const [], + String? description, List actions = const [], - bool includeDefaultClose = true, + bool barrierDismissible = true, BoxConstraints? constraints, EdgeInsetsGeometry? contentPadding, - bool scrollable = true, - bool barrierDismissible = true, + int metadataColumns = 2, + String? initialSectionId, + int initialSectionIndex = 0, FutureOr Function()? onSubmit, }) { - final resolvedActions = [ - ...actions, - if (includeDefaultClose) - ShadButton.ghost( - onPressed: () => Navigator.of(context, rootNavigator: true).maybePop(), - child: const Text('닫기'), - ), - ]; - - return showSuperportDialog( + return showSuperportDetailDialog( context: context, title: title, - description: '트랜잭션번호 $transactionNumber', - body: body, - actions: resolvedActions, - constraints: constraints, - contentPadding: contentPadding, - scrollable: scrollable, + description: description, + summary: summary, + summaryBadges: summaryBadges, + metadata: metadata, + sections: sections, + actions: actions, barrierDismissible: barrierDismissible, + constraints: constraints ?? const BoxConstraints(maxWidth: 920), + contentPadding: + contentPadding ?? + const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + metadataColumns: metadataColumns, + initialSectionId: initialSectionId, + initialSectionIndex: initialSectionIndex, onSubmit: onSubmit, ); } diff --git a/lib/features/masters/customer/presentation/dialogs/customer_detail_dialog.dart b/lib/features/masters/customer/presentation/dialogs/customer_detail_dialog.dart new file mode 100644 index 0000000..09e69cd --- /dev/null +++ b/lib/features/masters/customer/presentation/dialogs/customer_detail_dialog.dart @@ -0,0 +1,845 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../../../util/postal_search/presentation/models/postal_search_result.dart'; +import '../../../../util/postal_search/presentation/widgets/postal_search_dialog.dart'; +import '../../domain/entities/customer.dart'; + +/// 고객 상세 다이얼로그에서 발생 가능한 액션 종류이다. +enum CustomerDetailDialogAction { created, updated, deleted, restored } + +/// 고객 상세 다이얼로그가 반환하는 결과 모델이다. +class CustomerDetailDialogResult { + const CustomerDetailDialogResult({ + required this.action, + required this.message, + this.customer, + }); + + final CustomerDetailDialogAction action; + final String message; + + /// 갱신된 고객 엔터티. 삭제 동작의 경우 null일 수 있다. + final Customer? customer; +} + +typedef CustomerCreateCallback = + Future Function(CustomerInput input); +typedef CustomerUpdateCallback = + Future Function(int id, CustomerInput input); +typedef CustomerDeleteCallback = Future Function(int id); +typedef CustomerRestoreCallback = Future Function(int id); + +/// 고객 상세 다이얼로그를 노출한다. +Future showCustomerDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + Customer? customer, + required CustomerCreateCallback onCreate, + required CustomerUpdateCallback onUpdate, + required CustomerDeleteCallback onDelete, + required CustomerRestoreCallback onRestore, +}) { + final metadata = customer == null + ? const [] + : [ + SuperportDetailMetadata.text( + label: 'ID', + value: customer.id?.toString() ?? '-', + ), + SuperportDetailMetadata.text( + label: '고객사 코드', + value: customer.customerCode, + ), + SuperportDetailMetadata.text( + label: '유형', + value: _resolveType(customer), + ), + SuperportDetailMetadata.text( + label: '담당자', + value: customer.contactName?.isEmpty ?? true + ? '-' + : customer.contactName!, + ), + SuperportDetailMetadata.text( + label: '연락처', + value: customer.mobileNo?.isEmpty ?? true + ? '-' + : customer.mobileNo!, + ), + SuperportDetailMetadata.text( + label: '이메일', + value: customer.email?.isEmpty ?? true ? '-' : customer.email!, + ), + SuperportDetailMetadata.text( + label: '사용 상태', + value: customer.isActive ? '사용중' : '미사용', + ), + SuperportDetailMetadata.text( + label: '삭제 여부', + value: customer.isDeleted ? '삭제됨' : '정상', + ), + SuperportDetailMetadata.text( + label: '우편번호', + value: customer.zipcode?.zipcode ?? '-', + ), + SuperportDetailMetadata.text( + label: '주소', + value: _resolveAddress(customer), + ), + SuperportDetailMetadata.text( + label: '비고', + value: customer.note?.isEmpty ?? true ? '-' : customer.note!, + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: customer.createdAt == null + ? '-' + : dateFormat.format(customer.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '수정일시', + value: customer.updatedAt == null + ? '-' + : dateFormat.format(customer.updatedAt!.toLocal()), + ), + ]; + + return showSuperportDetailDialog( + context: context, + title: customer == null ? '고객사 등록' : '고객사 상세', + description: customer == null + ? '새로운 고객사 정보를 입력하세요.' + : '고객사 기본 정보와 연락처·주소를 확인하고 관리합니다.', + sections: [ + _CustomerEditSection( + id: customer == null + ? _CustomerDetailSections.create + : _CustomerDetailSections.edit, + label: customer == null ? '등록' : '수정', + customer: customer, + onSubmit: (input) async { + if (customer == null) { + final created = await onCreate(input); + if (created == null) { + return null; + } + return CustomerDetailDialogResult( + action: CustomerDetailDialogAction.created, + message: '고객사를 등록했습니다.', + customer: created, + ); + } + final updated = await onUpdate(customer.id!, input); + if (updated == null) { + return null; + } + return CustomerDetailDialogResult( + action: CustomerDetailDialogAction.updated, + message: '고객사를 수정했습니다.', + customer: updated, + ); + }, + ), + if (customer != null) + SuperportDetailDialogSection( + id: customer.isDeleted + ? _CustomerDetailSections.restore + : _CustomerDetailSections.delete, + label: customer.isDeleted ? '복구' : '삭제', + icon: customer.isDeleted ? LucideIcons.history : LucideIcons.trash2, + builder: (_) => _CustomerDangerSection( + customer: customer, + onDelete: () async { + final success = await onDelete(customer.id!); + if (!success) { + return null; + } + return const CustomerDetailDialogResult( + action: CustomerDetailDialogAction.deleted, + message: '고객사를 삭제했습니다.', + ); + }, + onRestore: () async { + final restored = await onRestore(customer.id!); + if (restored == null) { + return null; + } + return CustomerDetailDialogResult( + action: CustomerDetailDialogAction.restored, + message: '고객사를 복구했습니다.', + customer: restored, + ); + }, + ), + ), + ], + summary: customer == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + customer.customerName, + style: ShadTheme.of(context).textTheme.h4, + ), + const SizedBox(height: 4), + Text( + '코드 ${customer.customerCode}', + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ), + summaryBadges: customer == null + ? const [] + : [ + if (customer.isPartner && customer.isGeneral) + const ShadBadge(child: Text('파트너/일반')) + else if (customer.isPartner) + const ShadBadge(child: Text('파트너')) + else if (customer.isGeneral) + const ShadBadge(child: Text('일반')), + if (customer.isActive) + const ShadBadge.outline(child: Text('사용중')) + else + const ShadBadge.destructive(child: Text('미사용')), + if (customer.isDeleted) + const ShadBadge.destructive(child: Text('삭제됨')), + ], + metadata: metadata, + emptyPlaceholder: const Text('표시할 고객사 정보가 없습니다.'), + initialSectionId: customer == null + ? _CustomerDetailSections.create + : _CustomerDetailSections.edit, + ); +} + +/// 다이얼로그 섹션 식별자 상수 모음이다. +class _CustomerDetailSections { + static const edit = 'edit'; + static const create = 'create'; + static const delete = 'delete'; + static const restore = 'restore'; +} + +/// 키-값 형태의 행을 표현하는 내부 모델이다. + +/// 고객 등록/수정 폼 섹션 정의이다. +class _CustomerEditSection extends SuperportDetailDialogSection { + _CustomerEditSection({ + required super.id, + required super.label, + required this.customer, + required this.onSubmit, + }) : super( + icon: LucideIcons.pencil, + builder: (context) => + _CustomerForm(customer: customer, onSubmit: onSubmit), + ); + + final Customer? customer; + final Future Function(CustomerInput input) + onSubmit; +} + +/// 삭제/복구 단계를 안내하는 위험 섹션이다. +class _CustomerDangerSection extends StatelessWidget { + const _CustomerDangerSection({ + required this.customer, + required this.onDelete, + required this.onRestore, + }); + + final Customer customer; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final isDeleted = customer.isDeleted; + final description = isDeleted + ? '고객사를 복구하면 다시 목록에 노출되고 사용중 상태로 전환됩니다.' + : '고객사를 삭제하면 목록에서 숨겨지고 관련 데이터는 보존됩니다.'; + final actionLabel = isDeleted ? '복구' : '삭제'; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + if (isDeleted) + ShadButton( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onRestore(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: Text(actionLabel), + ) + else + ShadButton.destructive( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onDelete(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: Text(actionLabel), + ), + ], + ), + ); + } +} + +/// 고객 입력 폼을 관리하는 상태ful 위젯이다. +class _CustomerForm extends StatefulWidget { + const _CustomerForm({required this.customer, required this.onSubmit}); + + final Customer? customer; + final Future Function(CustomerInput input) + onSubmit; + + @override + State<_CustomerForm> createState() => _CustomerFormState(); +} + +class _CustomerFormState extends State<_CustomerForm> { + late final TextEditingController _codeController; + late final TextEditingController _nameController; + late final TextEditingController _contactController; + late final TextEditingController _emailController; + late final TextEditingController _mobileController; + late final TextEditingController _zipcodeController; + late final TextEditingController _addressController; + late final TextEditingController _noteController; + late bool _isPartner; + late bool _isGeneral; + late bool _isActive; + PostalSearchResult? _selectedPostal; + String? _codeError; + String? _nameError; + String? _typeError; + String? _zipcodeError; + String? _submitError; + bool _isSubmitting = false; + bool _isApplyingPostalSelection = false; + + bool get _isEdit => widget.customer != null; + + @override + void initState() { + super.initState(); + final existing = widget.customer; + _codeController = TextEditingController(text: existing?.customerCode ?? ''); + _nameController = TextEditingController(text: existing?.customerName ?? ''); + _contactController = TextEditingController( + text: existing?.contactName ?? '', + ); + _emailController = TextEditingController(text: existing?.email ?? ''); + _mobileController = TextEditingController(text: existing?.mobileNo ?? ''); + _zipcodeController = TextEditingController( + text: existing?.zipcode?.zipcode ?? '', + ); + _addressController = TextEditingController( + text: existing?.addressDetail ?? '', + ); + _noteController = TextEditingController(text: existing?.note ?? ''); + + final zipcode = existing?.zipcode; + _selectedPostal = zipcode == null + ? null + : PostalSearchResult( + zipcode: zipcode.zipcode, + sido: zipcode.sido, + sigungu: zipcode.sigungu, + roadName: zipcode.roadName, + ); + + _isPartner = existing?.isPartner ?? false; + _isGeneral = existing?.isGeneral ?? true; + _isActive = existing?.isActive ?? true; + + _zipcodeController.addListener(_handleZipcodeChanged); + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + _contactController.dispose(); + _emailController.dispose(); + _mobileController.dispose(); + _zipcodeController.removeListener(_handleZipcodeChanged); + _zipcodeController.dispose(); + _addressController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final destructiveColor = theme.colorScheme.destructive; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CustomerFormField( + label: '고객사코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _codeController, + readOnly: _isEdit, + onChanged: (_) { + if (_codeController.text.trim().isNotEmpty) { + setState(() { + _codeError = null; + }); + } + }, + ), + if (_codeError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _codeError!, + style: theme.textTheme.small.copyWith( + color: destructiveColor, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '고객사명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _nameController, + onChanged: (_) { + if (_nameController.text.trim().isNotEmpty) { + setState(() { + _nameError = null; + }); + } + }, + ), + if (_nameError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _nameError!, + style: theme.textTheme.small.copyWith( + color: destructiveColor, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '담당자', + child: ShadInput(controller: _contactController), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '유형', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ShadCheckbox( + value: _isPartner, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _isPartner = value; + if (!_isPartner && !_isGeneral) { + _typeError = '파트너/일반 중 하나 이상 선택하세요.'; + } else { + _typeError = null; + } + }); + }, + ), + const SizedBox(width: 8), + const Text('파트너'), + const SizedBox(width: 24), + ShadCheckbox( + value: _isGeneral, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _isGeneral = value; + if (!_isPartner && !_isGeneral) { + _typeError = '파트너/일반 중 하나 이상 선택하세요.'; + } else { + _typeError = null; + } + }); + }, + ), + const SizedBox(width: 8), + const Text('일반'), + ], + ), + if (_typeError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _typeError!, + style: theme.textTheme.small.copyWith( + color: destructiveColor, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '이메일', + child: ShadInput( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + ), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '연락처', + child: ShadInput( + controller: _mobileController, + keyboardType: TextInputType.phone, + ), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '우편번호', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + controller: _zipcodeController, + placeholder: const Text('예: 06000'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + ShadButton.outline( + onPressed: _isSubmitting ? null : _openPostalSearch, + child: const Text('검색'), + ), + ], + ), + const SizedBox(height: 8), + Text( + _selectedPostal == null + ? '검색 버튼을 눌러 주소를 선택하세요.' + : _selectedPostal!.fullAddress.isEmpty + ? '선택한 우편번호에 주소 정보가 없습니다.' + : _selectedPostal!.fullAddress, + style: theme.textTheme.small.copyWith( + color: _selectedPostal == null + ? theme.colorScheme.mutedForeground + : theme.textTheme.small.color, + ), + ), + if (_zipcodeError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _zipcodeError!, + style: theme.textTheme.small.copyWith( + color: destructiveColor, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '상세주소', + child: ShadInput( + controller: _addressController, + placeholder: const Text('상세주소 입력'), + ), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '사용여부', + child: Row( + children: [ + ShadSwitch( + value: _isActive, + onChanged: _isSubmitting + ? null + : (next) { + setState(() { + _isActive = next; + }); + }, + ), + const SizedBox(width: 8), + Text(_isActive ? '사용' : '미사용'), + ], + ), + ), + const SizedBox(height: 16), + _CustomerFormField( + label: '비고', + child: ShadTextarea( + controller: _noteController, + minHeight: 96, + maxHeight: 200, + ), + ), + if (widget.customer != null) ...[ + const SizedBox(height: 20), + Text( + '생성일시: ${_formatDateTime(widget.customer!.createdAt)}', + style: theme.textTheme.small, + ), + const SizedBox(height: 4), + Text( + '수정일시: ${_formatDateTime(widget.customer!.updatedAt)}', + style: theme.textTheme.small, + ), + ], + if (_submitError != null) ...[ + const SizedBox(height: 16), + Text( + _submitError!, + style: theme.textTheme.small.copyWith(color: destructiveColor), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isEdit ? '저장' : '등록'), + ), + ), + ], + ); + } + + Future _openPostalSearch() async { + final keyword = _zipcodeController.text.trim(); + final result = await showPostalSearchDialog( + context, + initialKeyword: keyword.isEmpty ? null : keyword, + ); + if (result == null) { + return; + } + _isApplyingPostalSelection = true; + _zipcodeController + ..text = result.zipcode + ..selection = TextSelection.collapsed(offset: result.zipcode.length); + _isApplyingPostalSelection = false; + setState(() { + _selectedPostal = result; + if (result.fullAddress.isNotEmpty) { + _addressController + ..text = result.fullAddress + ..selection = TextSelection.collapsed( + offset: _addressController.text.length, + ); + } + _zipcodeError = null; + }); + } + + void _handleZipcodeChanged() { + if (_isApplyingPostalSelection) { + return; + } + final text = _zipcodeController.text.trim(); + if (text.isEmpty) { + if (_selectedPostal != null) { + setState(() { + _selectedPostal = null; + }); + } + if (_zipcodeError != null) { + setState(() { + _zipcodeError = null; + }); + } + return; + } + if (_selectedPostal != null && _selectedPostal!.zipcode != text) { + setState(() { + _selectedPostal = null; + }); + } + if (_zipcodeError != null) { + setState(() { + _zipcodeError = null; + }); + } + } + + Future _handleSubmit() async { + final code = _codeController.text.trim(); + final name = _nameController.text.trim(); + final contact = _contactController.text.trim(); + final email = _emailController.text.trim(); + final mobile = _mobileController.text.trim(); + final zipcode = _zipcodeController.text.trim(); + final address = _addressController.text.trim(); + final note = _noteController.text.trim(); + + if (!_isPartner && !_isGeneral) { + setState(() { + _isGeneral = true; + }); + } + + setState(() { + _codeError = code.isEmpty ? '고객사코드를 입력하세요.' : null; + _nameError = name.isEmpty ? '고객사명을 입력하세요.' : null; + _typeError = (!_isPartner && !_isGeneral) + ? '파트너/일반 중 하나 이상 선택하세요.' + : null; + _zipcodeError = zipcode.isNotEmpty && _selectedPostal == null + ? '우편번호 검색으로 주소를 선택하세요.' + : null; + _submitError = null; + }); + + if (_codeError != null || + _nameError != null || + _typeError != null || + _zipcodeError != null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + final input = CustomerInput( + customerCode: code, + customerName: name, + contactName: contact.isEmpty ? null : contact, + isPartner: _isPartner, + isGeneral: _isGeneral, + email: email.isEmpty ? null : email, + mobileNo: mobile.isEmpty ? null : mobile, + zipcode: zipcode.isEmpty ? null : zipcode, + addressDetail: address.isEmpty ? null : address, + isActive: _isActive, + note: note.isEmpty ? null : note, + ); + + final navigator = Navigator.of(context, rootNavigator: true); + final result = await widget.onSubmit(input); + + if (!mounted) { + return; + } + + setState(() { + _isSubmitting = false; + _submitError = result == null ? '요청 처리에 실패했습니다.' : null; + }); + + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } + + String _formatDateTime(DateTime? value) { + if (value == null) { + return '-'; + } + return value.toLocal().toIso8601String(); + } +} + +/// 고객 폼 필드의 레이블·컨텐츠 배치를 담당한다. +class _CustomerFormField extends StatelessWidget { + const _CustomerFormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +String _resolveType(Customer customer) { + if (customer.isPartner && customer.isGeneral) { + return '파트너/일반'; + } + if (customer.isPartner) { + return '파트너'; + } + if (customer.isGeneral) { + return '일반'; + } + return '-'; +} + +String _resolveAddress(Customer customer) { + final zipcode = customer.zipcode; + final detail = customer.addressDetail; + if (zipcode == null && (detail == null || detail.isEmpty)) { + return '-'; + } + final buffer = StringBuffer(); + if (zipcode != null) { + buffer.write(zipcode.zipcode); + final components = [ + zipcode.sido, + zipcode.sigungu, + zipcode.roadName, + ].whereType().where((value) => value.isNotEmpty); + if (components.isNotEmpty) { + buffer.write(' (${components.join(' ')})'); + } + } + if (detail != null && detail.isNotEmpty) { + if (buffer.isNotEmpty) { + buffer.write('\n'); + } + buffer.write(detail); + } + return buffer.isEmpty ? '-' : buffer.toString(); +} diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index 104f67b..0f17fbb 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; -import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; -import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; -import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; import 'package:superport_v2/widgets/components/responsive_section.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../domain/entities/customer.dart'; import '../../domain/repositories/customer_repository.dart'; +import '../dialogs/customer_detail_dialog.dart'; import '../controllers/customer_controller.dart'; /// 고객 관리 화면. 기능 플래그에 따라 사양 페이지를 보여주거나 실제 목록을 노출한다. @@ -89,6 +89,84 @@ class CustomerPage extends StatelessWidget { } } +class _CustomerTable extends StatelessWidget { + const _CustomerTable({required this.customers, required this.onCustomerTap}); + + final List customers; + final void Function(Customer customer)? onCustomerTap; + + @override + Widget build(BuildContext context) { + final columns = const [ + Text('ID'), + Text('고객사코드'), + Text('고객사명'), + Text('담당자'), + Text('유형'), + Text('이메일'), + Text('연락처'), + Text('우편번호'), + Text('상세주소'), + Text('사용'), + Text('삭제'), + Text('비고'), + ]; + + String resolveType(Customer customer) { + if (customer.isPartner && customer.isGeneral) { + return '파트너/일반'; + } + if (customer.isPartner) return '파트너'; + if (customer.isGeneral) return '일반'; + return '-'; + } + + final rows = customers + .map( + (customer) => [ + Text(customer.id?.toString() ?? '-'), + Text(customer.customerCode), + Text(customer.customerName), + Text( + customer.contactName?.isEmpty ?? true + ? '-' + : customer.contactName!, + ), + Text(resolveType(customer)), + Text(customer.email?.isEmpty ?? true ? '-' : customer.email!), + Text(customer.mobileNo?.isEmpty ?? true ? '-' : customer.mobileNo!), + Text(customer.zipcode?.zipcode ?? '-'), + Text( + customer.addressDetail?.isEmpty ?? true + ? '-' + : customer.addressDetail!, + ), + Text(customer.isActive ? 'Y' : 'N'), + Text(customer.isDeleted ? 'Y' : '-'), + Text(customer.note?.isEmpty ?? true ? '-' : customer.note!), + ], + ) + .toList(); + + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 56, + maxHeight: 56.0 * (customers.length + 1), + onRowTap: onCustomerTap == null + ? null + : (index) => onCustomerTap!(customers[index]), + columnSpanExtent: (index) => switch (index) { + 2 => const FixedTableSpanExtent(180), + 5 => const FixedTableSpanExtent(200), + 8 => const FixedTableSpanExtent(220), + 11 => const FixedTableSpanExtent(200), + _ => const FixedTableSpanExtent(140), + }, + ); + } +} + /// 고객 관리 기능이 활성화된 경우 사용하는 실제 화면 위젯. class _CustomerEnabledPage extends StatefulWidget { const _CustomerEnabledPage({required this.routeUri}); @@ -104,6 +182,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { late final CustomerController _controller; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocus = FocusNode(); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; String? _lastAppliedRoute; @@ -211,7 +290,7 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { leading: const Icon(LucideIcons.plus, size: 16), onPressed: _controller.isSubmitting ? null - : () => _openCustomerForm(context), + : _openCustomerCreateDialog, child: const Text('신규 등록'), ), ], @@ -346,14 +425,9 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { ) : _CustomerTable( customers: customers, - onEdit: _controller.isSubmitting + onCustomerTap: _controller.isSubmitting ? null - : (customer) => - _openCustomerForm(context, customer: customer), - onDelete: _controller.isSubmitting ? null : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restoreCustomer, + : _openCustomerDetailDialog, ), ), ); @@ -475,6 +549,40 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { } } + Future _openCustomerCreateDialog() async { + final result = await showCustomerDetailDialog( + context: context, + dateFormat: _dateFormat, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + ); + if (result != null && mounted) { + _showSnack(result.message); + } + } + + Future _openCustomerDetailDialog(Customer customer) async { + final customerId = customer.id; + if (customerId == null) { + _showSnack('ID 정보가 없어 상세를 열 수 없습니다.'); + return; + } + final result = await showCustomerDetailDialog( + context: context, + dateFormat: _dateFormat, + customer: customer, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + ); + if (result != null && mounted) { + _showSnack(result.message); + } + } + String _typeLabel(CustomerTypeFilter filter) { switch (filter) { case CustomerTypeFilter.all: @@ -497,546 +605,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { } } - Future _openCustomerForm( - BuildContext context, { - Customer? customer, - }) async { - final existing = customer; - final isEdit = existing != null; - final customerId = existing?.id; - if (isEdit && customerId == null) { - _showSnack('ID 정보가 없어 수정할 수 없습니다.'); - return; - } - - final parentContext = context; - - final codeController = TextEditingController( - text: existing?.customerCode ?? '', - ); - final nameController = TextEditingController( - text: existing?.customerName ?? '', - ); - final contactController = TextEditingController( - text: existing?.contactName ?? '', - ); - final emailController = TextEditingController(text: existing?.email ?? ''); - final mobileController = TextEditingController( - text: existing?.mobileNo ?? '', - ); - final zipcodeController = TextEditingController( - text: existing?.zipcode?.zipcode ?? '', - ); - final addressController = TextEditingController( - text: existing?.addressDetail ?? '', - ); - final noteController = TextEditingController(text: existing?.note ?? ''); - final existingZipcode = existing?.zipcode; - final selectedPostalNotifier = ValueNotifier( - existingZipcode == null - ? null - : PostalSearchResult( - zipcode: existingZipcode.zipcode, - sido: existingZipcode.sido, - sigungu: existingZipcode.sigungu, - roadName: existingZipcode.roadName, - ), - ); - final partnerNotifier = ValueNotifier(existing?.isPartner ?? false); - final generalNotifier = ValueNotifier(existing?.isGeneral ?? true); - final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); - final saving = ValueNotifier(false); - final codeError = ValueNotifier(null); - final nameError = ValueNotifier(null); - final typeError = ValueNotifier(null); - final zipcodeError = ValueNotifier(null); - - var isApplyingPostalSelection = false; - - void handleZipcodeChange() { - if (isApplyingPostalSelection) { - return; - } - final text = zipcodeController.text.trim(); - final selection = selectedPostalNotifier.value; - if (text.isEmpty) { - if (selection != null) { - selectedPostalNotifier.value = null; - } - zipcodeError.value = null; - return; - } - if (selection != null && selection.zipcode != text) { - selectedPostalNotifier.value = null; - } - if (zipcodeError.value != null) { - zipcodeError.value = null; - } - } - - void handlePostalSelectionChange() { - if (selectedPostalNotifier.value != null) { - zipcodeError.value = null; - } - } - - zipcodeController.addListener(handleZipcodeChange); - selectedPostalNotifier.addListener(handlePostalSelectionChange); - - Future openPostalSearch(BuildContext dialogContext) async { - final keyword = zipcodeController.text.trim(); - final result = await showPostalSearchDialog( - dialogContext, - initialKeyword: keyword.isEmpty ? null : keyword, - ); - if (result == null) { - return; - } - isApplyingPostalSelection = true; - zipcodeController - ..text = result.zipcode - ..selection = TextSelection.collapsed(offset: result.zipcode.length); - isApplyingPostalSelection = false; - selectedPostalNotifier.value = result; - if (result.fullAddress.isNotEmpty) { - addressController - ..text = result.fullAddress - ..selection = TextSelection.collapsed( - offset: addressController.text.length, - ); - } - } - - await SuperportDialog.show( - context: parentContext, - dialog: SuperportDialog( - title: isEdit ? '고객사 수정' : '고객사 등록', - description: '고객사 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', - primaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final contact = contactController.text.trim(); - final email = emailController.text.trim(); - final mobile = mobileController.text.trim(); - final zipcode = zipcodeController.text.trim(); - final address = addressController.text.trim(); - final note = noteController.text.trim(); - final partner = partnerNotifier.value; - var general = generalNotifier.value; - final selectedPostal = selectedPostalNotifier.value; - - codeError.value = code.isEmpty ? '고객사코드를 입력하세요.' : null; - nameError.value = name.isEmpty ? '고객사명을 입력하세요.' : null; - zipcodeError.value = - zipcode.isNotEmpty && selectedPostal == null - ? '우편번호 검색으로 주소를 선택하세요.' - : null; - - if (!partner && !general) { - general = true; - generalNotifier.value = true; - } - - typeError.value = (!partner && !general) - ? '파트너/일반 중 하나 이상 선택하세요.' - : null; - - if (codeError.value != null || - nameError.value != null || - zipcodeError.value != null || - typeError.value != null) { - return; - } - - saving.value = true; - final input = CustomerInput( - customerCode: code, - customerName: name, - contactName: contact.isEmpty ? null : contact, - isPartner: partner, - isGeneral: general, - email: email.isEmpty ? null : email, - mobileNo: mobile.isEmpty ? null : mobile, - zipcode: zipcode.isEmpty ? null : zipcode, - addressDetail: address.isEmpty ? null : address, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final navigator = Navigator.of( - context, - rootNavigator: true, - ); - final response = isEdit - ? await _controller.update(customerId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack(isEdit ? '고객사를 수정했습니다.' : '고객사를 등록했습니다.'); - } - navigator.pop(true); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ); - }, - ), - secondaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton.ghost( - onPressed: isSaving - ? null - : () => Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ); - }, - ), - child: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - final theme = ShadTheme.of(context); - final materialTheme = Theme.of(context); - - return SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '고객사코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '고객사명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '담당자', - child: ShadInput(controller: contactController), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: partnerNotifier, - builder: (_, partner, __) { - return ValueListenableBuilder( - valueListenable: generalNotifier, - builder: (_, general, __) { - return ValueListenableBuilder( - valueListenable: typeError, - builder: (_, errorText, __) { - final onChanged = isSaving - ? null - : (bool? value) { - if (value == null) return; - partnerNotifier.value = value; - if (!value && !generalNotifier.value) { - typeError.value = - '파트너/일반 중 하나 이상 선택하세요.'; - } else { - typeError.value = null; - } - }; - final onChangedGeneral = isSaving - ? null - : (bool? value) { - if (value == null) return; - generalNotifier.value = value; - if (!value && !partnerNotifier.value) { - typeError.value = - '파트너/일반 중 하나 이상 선택하세요.'; - } else { - typeError.value = null; - } - }; - return _FormField( - label: '유형', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ShadCheckbox( - value: partner, - onChanged: onChanged, - ), - const SizedBox(width: 8), - const Text('파트너'), - const SizedBox(width: 24), - ShadCheckbox( - value: general, - onChanged: onChangedGeneral, - ), - const SizedBox(width: 8), - const Text('일반'), - ], - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: - materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ); - }, - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '이메일', - child: ShadInput( - controller: emailController, - keyboardType: TextInputType.emailAddress, - ), - ), - const SizedBox(height: 16), - _FormField( - label: '연락처', - child: ShadInput( - controller: mobileController, - keyboardType: TextInputType.phone, - ), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: zipcodeError, - builder: (_, zipcodeErrorText, __) { - return _FormField( - label: '우편번호', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: ShadInput( - controller: zipcodeController, - placeholder: const Text('예: 06000'), - keyboardType: TextInputType.number, - ), - ), - const SizedBox(width: 8), - ShadButton.outline( - onPressed: isSaving - ? null - : () => openPostalSearch(context), - child: const Text('검색'), - ), - ], - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: selectedPostalNotifier, - builder: (_, selection, __) { - if (selection == null) { - return Text( - '검색 버튼을 눌러 주소를 선택하세요.', - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - ), - ); - } - final fullAddress = selection.fullAddress; - return Text( - fullAddress.isEmpty - ? '선택한 우편번호에 주소 정보가 없습니다.' - : fullAddress, - style: theme.textTheme.small, - ); - }, - ), - if (zipcodeErrorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - zipcodeErrorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '상세주소', - child: ShadInput( - controller: addressController, - placeholder: const Text('상세주소 입력'), - ), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: isSaving - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (existing != null) ..._buildAuditInfo(existing, theme), - ], - ), - ); - }, - ), - ), - ); - zipcodeController.removeListener(handleZipcodeChange); - selectedPostalNotifier.removeListener(handlePostalSelectionChange); - - codeController.dispose(); - nameController.dispose(); - contactController.dispose(); - emailController.dispose(); - mobileController.dispose(); - zipcodeController.dispose(); - addressController.dispose(); - noteController.dispose(); - selectedPostalNotifier.dispose(); - partnerNotifier.dispose(); - generalNotifier.dispose(); - isActiveNotifier.dispose(); - saving.dispose(); - codeError.dispose(); - nameError.dispose(); - typeError.dispose(); - zipcodeError.dispose(); - } - - Future _confirmDelete(Customer customer) async { - final confirmed = await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '고객사 삭제', - description: '"${customer.customerName}" 고객사를 삭제하시겠습니까?', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('삭제'), - ), - ], - ), - ); - - if (confirmed == true && customer.id != null) { - final success = await _controller.delete(customer.id!); - if (success && mounted) { - _showSnack('고객사를 삭제했습니다.'); - } - } - } - - Future _restoreCustomer(Customer customer) async { - if (customer.id == null) return; - final restored = await _controller.restore(customer.id!); - if (restored != null && mounted) { - _showSnack('고객사를 복구했습니다.'); - } - } - void _showSnack(String message) { if (!mounted) { return; @@ -1047,143 +615,4 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { } messenger.showSnackBar(SnackBar(content: Text(message))); } - - String _formatDateTime(DateTime? value) { - if (value == null) return '-'; - return value.toLocal().toIso8601String(); - } - - List _buildAuditInfo(Customer customer, ShadThemeData theme) { - return [ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(customer.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(customer.updatedAt)}', - style: theme.textTheme.small, - ), - ]; - } -} - -class _CustomerTable extends StatelessWidget { - const _CustomerTable({ - required this.customers, - required this.onEdit, - required this.onDelete, - required this.onRestore, - }); - - final List customers; - final void Function(Customer customer)? onEdit; - final void Function(Customer customer)? onDelete; - final void Function(Customer customer)? onRestore; - - @override - Widget build(BuildContext context) { - final header = [ - 'ID', - '고객사코드', - '고객사명', - '담당자', - '유형', - '이메일', - '연락처', - '우편번호', - '상세주소', - '사용', - '삭제', - '비고', - '동작', - ].map((text) => ShadTableCell.header(child: Text(text))).toList(); - - String resolveType(Customer customer) { - if (customer.isPartner && customer.isGeneral) { - return '파트너/일반'; - } - if (customer.isPartner) return '파트너'; - if (customer.isGeneral) return '일반'; - return '-'; - } - - final rows = customers.map((customer) { - return [ - customer.id?.toString() ?? '-', - customer.customerCode, - customer.customerName, - customer.contactName?.isEmpty ?? true ? '-' : customer.contactName!, - resolveType(customer), - customer.email?.isEmpty ?? true ? '-' : customer.email!, - customer.mobileNo?.isEmpty ?? true ? '-' : customer.mobileNo!, - customer.zipcode?.zipcode ?? '-', - customer.addressDetail?.isEmpty ?? true ? '-' : customer.addressDetail!, - customer.isActive ? 'Y' : 'N', - customer.isDeleted ? 'Y' : '-', - customer.note?.isEmpty ?? true ? '-' : customer.note!, - ].map((text) => ShadTableCell(child: Text(text))).toList()..add( - ShadTableCell( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(customer), - child: const Icon(LucideIcons.pencil, size: 16), - ), - const SizedBox(width: 8), - customer.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(customer), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(customer), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], - ), - ), - ); - }).toList(); - - return SizedBox( - height: 56.0 * (customers.length + 1), - child: ShadTable.list( - header: header, - children: rows, - columnSpanExtent: (index) => index == 11 - ? const FixedTableSpanExtent(160) - : const FixedTableSpanExtent(140), - ), - ); - } -} - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.small), - const SizedBox(height: 6), - child, - ], - ); - } } diff --git a/lib/features/masters/group/presentation/dialogs/group_detail_dialog.dart b/lib/features/masters/group/presentation/dialogs/group_detail_dialog.dart new file mode 100644 index 0000000..41d5856 --- /dev/null +++ b/lib/features/masters/group/presentation/dialogs/group_detail_dialog.dart @@ -0,0 +1,501 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../domain/entities/group.dart'; + +/// 그룹 상세 다이얼로그 사용자 액션 유형이다. +enum GroupDetailDialogAction { created, updated, deleted, restored } + +/// 그룹 상세 다이얼로그 종료 시 반환되는 결과이다. +class GroupDetailDialogResult { + const GroupDetailDialogResult({required this.action, required this.message}); + + final GroupDetailDialogAction action; + final String message; +} + +typedef GroupCreateCallback = Future Function(GroupInput input); +typedef GroupUpdateCallback = Future Function(int id, GroupInput input); +typedef GroupDeleteCallback = Future Function(int id); +typedef GroupRestoreCallback = Future Function(int id); + +/// 그룹 상세 다이얼로그를 호출해 생성/수정/삭제/복구를 통합 처리한다. +Future showGroupDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + Group? group, + required GroupCreateCallback onCreate, + required GroupUpdateCallback onUpdate, + required GroupDeleteCallback onDelete, + required GroupRestoreCallback onRestore, +}) { + final groupValue = group; + final isEdit = groupValue != null; + final title = isEdit ? '그룹 상세' : '그룹 등록'; + final description = isEdit + ? '그룹 기본 정보와 권한 상태를 확인하고 관리합니다.' + : '새로운 그룹 정보를 입력하세요.'; + + final metadata = groupValue == null + ? const [] + : [ + SuperportDetailMetadata.text( + label: 'ID', + value: groupValue.id?.toString() ?? '-', + ), + SuperportDetailMetadata.text( + label: '기본 여부', + value: groupValue.isDefault ? '기본 그룹' : '일반 그룹', + ), + SuperportDetailMetadata.text( + label: '사용 여부', + value: groupValue.isActive ? '사용중' : '미사용', + ), + SuperportDetailMetadata.text( + label: '삭제 여부', + value: groupValue.isDeleted ? '삭제됨' : '정상', + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: groupValue.createdAt == null + ? '-' + : dateFormat.format(groupValue.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '변경일시', + value: groupValue.updatedAt == null + ? '-' + : dateFormat.format(groupValue.updatedAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '비고', + value: groupValue.note?.isEmpty ?? true ? '-' : groupValue.note!, + ), + ]; + + return showSuperportDetailDialog( + context: context, + title: title, + description: description, + summary: groupValue == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + groupValue.groupName, + style: ShadTheme.of(context).textTheme.h4, + ), + if (groupValue.description?.isNotEmpty ?? false) ...[ + const SizedBox(height: 4), + Text( + groupValue.description!, + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ], + ), + summaryBadges: groupValue == null + ? const [] + : [ + if (groupValue.isDefault) + const ShadBadge(child: Text('기본 그룹')) + else + const ShadBadge.outline(child: Text('일반 그룹')), + if (groupValue.isActive) + const ShadBadge(child: Text('사용중')) + else + const ShadBadge.outline(child: Text('미사용')), + if (groupValue.isDeleted) + const ShadBadge.destructive(child: Text('삭제됨')), + ], + metadata: metadata, + sections: [ + _GroupEditSection( + id: isEdit ? _GroupDetailSections.edit : _GroupDetailSections.create, + label: isEdit ? '수정' : '등록', + group: groupValue, + onSubmit: (input) async { + if (groupValue == null) { + final created = await onCreate(input); + if (created == null) { + return null; + } + return const GroupDetailDialogResult( + action: GroupDetailDialogAction.created, + message: '그룹을 등록했습니다.', + ); + } + final groupId = groupValue.id; + if (groupId == null) { + return null; + } + final updated = await onUpdate(groupId, input); + if (updated == null) { + return null; + } + return const GroupDetailDialogResult( + action: GroupDetailDialogAction.updated, + message: '그룹을 수정했습니다.', + ); + }, + ), + if (groupValue != null) + SuperportDetailDialogSection( + id: groupValue.isDeleted + ? _GroupDetailSections.restore + : _GroupDetailSections.delete, + label: groupValue.isDeleted ? '복구' : '삭제', + icon: groupValue.isDeleted ? LucideIcons.history : LucideIcons.trash2, + builder: (_) => _GroupDangerSection( + group: groupValue, + onDelete: () async { + final groupId = groupValue.id; + if (groupId == null) { + return null; + } + final success = await onDelete(groupId); + if (!success) { + return null; + } + return const GroupDetailDialogResult( + action: GroupDetailDialogAction.deleted, + message: '그룹을 삭제했습니다.', + ); + }, + onRestore: () async { + final groupId = groupValue.id; + if (groupId == null) { + return null; + } + final restored = await onRestore(groupId); + if (restored == null) { + return null; + } + return const GroupDetailDialogResult( + action: GroupDetailDialogAction.restored, + message: '그룹을 복구했습니다.', + ); + }, + ), + ), + ], + emptyPlaceholder: const Text('표시할 그룹 정보가 없습니다.'), + initialSectionId: groupValue == null + ? _GroupDetailSections.create + : _GroupDetailSections.edit, + ); +} + +/// 그룹 상세 다이얼로그 섹션 식별자이다. +class _GroupDetailSections { + static const edit = 'edit'; + static const delete = 'delete'; + static const restore = 'restore'; + static const create = 'create'; +} + +/// 그룹 생성/수정을 담당하는 섹션이다. +class _GroupEditSection extends SuperportDetailDialogSection { + _GroupEditSection({ + required super.id, + required super.label, + required Group? group, + required Future Function(GroupInput input) + onSubmit, + }) : super( + icon: LucideIcons.pencil, + builder: (context) => _GroupForm(group: group, onSubmit: onSubmit), + ); +} + +/// 그룹 삭제/복구를 실행하는 위험 영역이다. +class _GroupDangerSection extends StatelessWidget { + const _GroupDangerSection({ + required this.group, + required this.onDelete, + required this.onRestore, + }); + + final Group group; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final isDeleted = group.isDeleted; + final description = isDeleted + ? '복구하면 그룹이 다시 목록에 노출되고 권한 동기화가 재개됩니다.' + : '삭제하면 그룹이 목록에서 숨겨지며 기존 데이터는 보존됩니다.'; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + if (isDeleted) + ShadButton( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onRestore(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: const Text('복구'), + ) + else + ShadButton.destructive( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onDelete(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: const Text('삭제'), + ), + ], + ), + ); + } +} + +/// 그룹 입력 폼을 구성하는 위젯이다. +class _GroupForm extends StatefulWidget { + const _GroupForm({required this.group, required this.onSubmit}); + + final Group? group; + final Future Function(GroupInput input) onSubmit; + + bool get _isEdit => group != null; + + @override + State<_GroupForm> createState() => _GroupFormState(); +} + +class _GroupFormState extends State<_GroupForm> { + late final TextEditingController _nameController; + late final TextEditingController _descriptionController; + late final TextEditingController _noteController; + late final ValueNotifier _isDefaultNotifier; + late final ValueNotifier _isActiveNotifier; + String? _nameError; + String? _submitError; + bool _isSubmitting = false; + + bool get _isEdit => widget._isEdit; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController( + text: widget.group?.groupName ?? '', + ); + _descriptionController = TextEditingController( + text: widget.group?.description ?? '', + ); + _noteController = TextEditingController(text: widget.group?.note ?? ''); + _isDefaultNotifier = ValueNotifier(widget.group?.isDefault ?? false); + _isActiveNotifier = ValueNotifier(widget.group?.isActive ?? true); + } + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _noteController.dispose(); + _isDefaultNotifier.dispose(); + _isActiveNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _GroupFormField( + label: '그룹명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _nameController, + readOnly: _isEdit, + onChanged: (_) { + if (_nameController.text.trim().isNotEmpty) { + setState(() { + _nameError = null; + }); + } + }, + ), + if (_nameError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _nameError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _GroupFormField( + label: '설명', + child: ShadTextarea( + controller: _descriptionController, + minHeight: 96, + maxHeight: 220, + ), + ), + const SizedBox(height: 16), + _GroupFormField( + label: '기본 여부', + child: ValueListenableBuilder( + valueListenable: _isDefaultNotifier, + builder: (_, value, __) { + return Row( + children: [ + ShadSwitch( + value: value, + onChanged: _isSubmitting + ? null + : (next) => _isDefaultNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '기본 그룹' : '일반 그룹'), + ], + ); + }, + ), + ), + const SizedBox(height: 16), + _GroupFormField( + label: '사용 여부', + child: ValueListenableBuilder( + valueListenable: _isActiveNotifier, + builder: (_, value, __) { + return Row( + children: [ + ShadSwitch( + value: value, + onChanged: _isSubmitting + ? null + : (next) => _isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ); + }, + ), + ), + const SizedBox(height: 16), + _GroupFormField( + label: '비고', + child: ShadTextarea( + controller: _noteController, + minHeight: 96, + maxHeight: 220, + ), + ), + if (_submitError != null) ...[ + const SizedBox(height: 16), + Text( + _submitError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isEdit ? '저장' : '등록'), + ), + ), + ], + ); + } + + Future _handleSubmit() async { + final name = _nameController.text.trim(); + + setState(() { + _nameError = name.isEmpty ? '그룹명을 입력하세요.' : null; + _submitError = null; + }); + + if (_nameError != null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + final description = _descriptionController.text.trim(); + final note = _noteController.text.trim(); + final input = GroupInput( + groupName: name, + description: description.isEmpty ? null : description, + isDefault: _isDefaultNotifier.value, + isActive: _isActiveNotifier.value, + note: note.isEmpty ? null : note, + ); + final navigator = Navigator.of(context, rootNavigator: true); + final result = await widget.onSubmit(input); + + if (!mounted) { + return; + } + + setState(() { + _isSubmitting = false; + _submitError = result == null ? '요청 처리에 실패했습니다.' : null; + }); + + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } +} + +/// 그룹 폼 필드 공통 레이아웃이다. +class _GroupFormField extends StatelessWidget { + const _GroupFormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +/// 개요 섹션에서 사용하는 키-값 구조체이다. diff --git a/lib/features/masters/group/presentation/pages/group_page.dart b/lib/features/masters/group/presentation/pages/group_page.dart index 668760d..32415ae 100644 --- a/lib/features/masters/group/presentation/pages/group_page.dart +++ b/lib/features/masters/group/presentation/pages/group_page.dart @@ -5,13 +5,14 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; -import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../domain/entities/group.dart'; import '../../domain/repositories/group_repository.dart'; +import '../dialogs/group_detail_dialog.dart'; import '../controllers/group_controller.dart'; /// 권한 그룹 관리 페이지. 기능 플래그에 따라 사양 화면 또는 실제 목록을 보여준다. @@ -151,7 +152,7 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { leading: const Icon(LucideIcons.plus, size: 16), onPressed: _controller.isSubmitting ? null - : () => _openGroupForm(context), + : _openGroupCreateDialog, child: const Text('신규 등록'), ), ], @@ -274,11 +275,9 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { : _GroupTable( groups: groups, dateFormat: _dateFormat, - onEdit: _controller.isSubmitting + onRowTap: _controller.isSubmitting ? null - : (group) => _openGroupForm(context, group: group), - onDelete: _controller.isSubmitting ? null : _confirmDelete, - onRestore: _controller.isSubmitting ? null : _restoreGroup, + : _openGroupDetailDialog, ), ), ); @@ -313,265 +312,37 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { } } - Future _openGroupForm(BuildContext context, {Group? group}) async { - final existingGroup = group; - final isEdit = existingGroup != null; - final groupId = existingGroup?.id; - if (isEdit && groupId == null) { - _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + Future _openGroupCreateDialog() async { + final result = await showGroupDetailDialog( + context: context, + dateFormat: _dateFormat, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + ); + if (result != null && mounted) { + _showSnack(result.message); + } + } + + Future _openGroupDetailDialog(Group group) async { + final groupId = group.id; + if (groupId == null) { + _showSnack('ID 정보가 없어 상세를 열 수 없습니다.'); return; } - - final nameController = TextEditingController( - text: existingGroup?.groupName ?? '', - ); - final descriptionController = TextEditingController( - text: existingGroup?.description ?? '', - ); - final noteController = TextEditingController( - text: existingGroup?.note ?? '', - ); - final isDefaultNotifier = ValueNotifier( - existingGroup?.isDefault ?? false, - ); - final isActiveNotifier = ValueNotifier( - existingGroup?.isActive ?? true, - ); - final saving = ValueNotifier(false); - final nameError = ValueNotifier(null); - - await SuperportDialog.show( + final result = await showGroupDetailDialog( context: context, - dialog: SuperportDialog( - title: isEdit ? '그룹 수정' : '그룹 등록', - description: '그룹 정보를 ${isEdit ? '수정' : '입력'}하세요.', - constraints: const BoxConstraints(maxWidth: 540), - actions: [ - ValueListenableBuilder( - valueListenable: saving, - builder: (dialogContext, isSaving, __) { - return ShadButton.ghost( - onPressed: isSaving - ? null - : () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(), - child: const Text('취소'), - ); - }, - ), - ValueListenableBuilder( - valueListenable: saving, - builder: (dialogContext, isSaving, __) { - return ShadButton( - onPressed: isSaving - ? null - : () async { - final name = nameController.text.trim(); - final description = descriptionController.text.trim(); - final note = noteController.text.trim(); - - nameError.value = name.isEmpty ? '그룹명을 입력하세요.' : null; - - if (nameError.value != null) { - return; - } - - saving.value = true; - final input = GroupInput( - groupName: name, - description: description.isEmpty ? null : description, - isDefault: isDefaultNotifier.value, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final navigator = Navigator.of( - dialogContext, - rootNavigator: true, - ); - final response = isEdit - ? await _controller.update(groupId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack(isEdit ? '그룹을 수정했습니다.' : '그룹을 등록했습니다.'); - } - navigator.pop(); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ); - }, - ), - ], - child: StatefulBuilder( - builder: (dialogContext, _) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - - return SizedBox( - width: double.infinity, - child: SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '그룹명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - readOnly: isEdit, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '설명', - child: ShadTextarea(controller: descriptionController), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isDefaultNotifier, - builder: (_, value, __) { - return _FormField( - label: '기본여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isDefaultNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '기본 그룹' : '일반 그룹'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (existingGroup != null) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existingGroup.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existingGroup.updatedAt)}', - style: theme.textTheme.small, - ), - ], - ], - ), - ), - ); - }, - ), - ), + dateFormat: _dateFormat, + group: group, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, ); - - nameController.dispose(); - descriptionController.dispose(); - noteController.dispose(); - isDefaultNotifier.dispose(); - isActiveNotifier.dispose(); - saving.dispose(); - nameError.dispose(); - } - - Future _confirmDelete(Group group) async { - final confirmed = await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('그룹 삭제'), - content: Text('"${group.groupName}" 그룹을 삭제하시겠습니까?'), - actions: [ - TextButton( - onPressed: () => - Navigator.of(dialogContext, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - TextButton( - onPressed: () => - Navigator.of(dialogContext, rootNavigator: true).pop(true), - child: const Text('삭제'), - ), - ], - ); - }, - ); - - if (confirmed == true && group.id != null) { - final success = await _controller.delete(group.id!); - if (success && mounted) { - _showSnack('그룹을 삭제했습니다.'); - } - } - } - - Future _restoreGroup(Group group) async { - if (group.id == null) return; - final restored = await _controller.restore(group.id!); - if (restored != null && mounted) { - _showSnack('그룹을 복구했습니다.'); + if (result != null && mounted) { + _showSnack(result.message); } } @@ -585,129 +356,70 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { } messenger.showSnackBar(SnackBar(content: Text(message))); } - - String _formatDateTime(DateTime? value) { - if (value == null) { - return '-'; - } - return _dateFormat.format(value.toLocal()); - } } class _GroupTable extends StatelessWidget { const _GroupTable({ required this.groups, - required this.onEdit, - required this.onDelete, - required this.onRestore, required this.dateFormat, + required this.onRowTap, }); final List groups; - final void Function(Group group)? onEdit; - final void Function(Group group)? onDelete; - final void Function(Group group)? onRestore; final DateFormat dateFormat; + final void Function(Group group)? onRowTap; @override Widget build(BuildContext context) { - final header = [ - 'ID', - '그룹명', - '설명', - '기본', - '사용', - '삭제', - '비고', - '변경일시', - '동작', - ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + final columns = const [ + Text('ID'), + Text('그룹명'), + Text('설명'), + Text('기본'), + Text('사용'), + Text('삭제'), + Text('비고'), + Text('변경일시'), + ]; - final rows = groups.map((group) { - final cells = [ - group.id?.toString() ?? '-', - group.groupName, - (group.description?.isEmpty ?? true) ? '-' : group.description!, - group.isDefault ? 'Y' : 'N', - group.isActive ? 'Y' : 'N', - group.isDeleted ? 'Y' : '-', - (group.note?.isEmpty ?? true) ? '-' : group.note!, - group.updatedAt == null - ? '-' - : dateFormat.format(group.updatedAt!.toLocal()), - ].map((text) => ShadTableCell(child: Text(text))).toList(); + final rows = groups + .map( + (group) => [ + Text(group.id?.toString() ?? '-'), + Text(group.groupName), + Text(group.description?.isEmpty ?? true ? '-' : group.description!), + Text(group.isDefault ? 'Y' : 'N'), + Text(group.isActive ? 'Y' : 'N'), + Text(group.isDeleted ? 'Y' : '-'), + Text(group.note?.isEmpty ?? true ? '-' : group.note!), + Text( + group.updatedAt == null + ? '-' + : dateFormat.format(group.updatedAt!.toLocal()), + ), + ], + ) + .toList(); - cells.add( - ShadTableCell( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(group), - child: const Icon(LucideIcons.pencil, size: 16), - ), - const SizedBox(width: 8), - group.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(group), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(group), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], - ), - ), - ); - return cells; - }).toList(); - - return SizedBox( - height: 56.0 * (groups.length + 1), - child: ShadTable.list( - header: header, - children: rows, - columnSpanExtent: (index) { - if (index == 8) { - return const FixedTableSpanExtent(160); - } - if (index == 2) { - return const FixedTableSpanExtent(220); - } - if (index == 6) { - return const FixedTableSpanExtent(200); - } - return const FixedTableSpanExtent(120); - }, - ), - ); - } -} - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.small), - const SizedBox(height: 6), - child, - ], + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 56, + maxHeight: 56.0 * (groups.length + 1), + onRowTap: onRowTap == null + ? null + : (index) { + if (index < 0 || index >= groups.length) { + return; + } + onRowTap!(groups[index]); + }, + columnSpanExtent: (index) => switch (index) { + 2 => const FixedTableSpanExtent(220), + 6 => const FixedTableSpanExtent(200), + 7 => const FixedTableSpanExtent(160), + _ => const FixedTableSpanExtent(120), + }, ); } } diff --git a/lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart b/lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart new file mode 100644 index 0000000..64e4ac4 --- /dev/null +++ b/lib/features/masters/group_permission/presentation/dialogs/group_permission_detail_dialog.dart @@ -0,0 +1,708 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../../group/domain/entities/group.dart'; +import '../../../menu/domain/entities/menu.dart'; +import '../../domain/entities/group_permission.dart'; + +/// 그룹 권한 상세 다이얼로그 결과 유형이다. +enum GroupPermissionDetailDialogAction { created, updated, deleted, restored } + +/// 그룹 권한 상세 다이얼로그에서 반환되는 결과이다. +class GroupPermissionDetailDialogResult { + const GroupPermissionDetailDialogResult({ + required this.action, + required this.message, + }); + + final GroupPermissionDetailDialogAction action; + final String message; +} + +typedef GroupPermissionCreateCallback = + Future Function(GroupPermissionInput input); +typedef GroupPermissionUpdateCallback = + Future Function(int id, GroupPermissionInput input); +typedef GroupPermissionDeleteCallback = Future Function(int id); +typedef GroupPermissionRestoreCallback = + Future Function(int id); + +/// 그룹 권한 상세 다이얼로그를 표시한다. +Future showGroupPermissionDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + GroupPermission? permission, + required List groups, + required bool isLoadingGroups, + required List menus, + required bool isLoadingMenus, + required GroupPermissionCreateCallback onCreate, + required GroupPermissionUpdateCallback onUpdate, + required GroupPermissionDeleteCallback onDelete, + required GroupPermissionRestoreCallback onRestore, +}) { + final permissionValue = permission; + final isEdit = permissionValue != null; + final title = isEdit ? '그룹 권한 상세' : '그룹 권한 등록'; + final description = isEdit + ? '그룹과 메뉴에 부여된 CRUD 권한을 확인하고 변경합니다.' + : '그룹-메뉴 권한을 신규로 연결하세요.'; + + List buildSummaryBadges(GroupPermission? value) { + if (value == null) { + return const []; + } + Widget badge(String label, bool enabled, {bool destructive = false}) { + if (enabled) { + if (destructive) { + return ShadBadge.destructive(child: Text(label)); + } + return ShadBadge(child: Text(label)); + } + return ShadBadge.outline(child: Text(label)); + } + + return [ + badge('사용중', value.isActive), + if (value.isDeleted) const ShadBadge.destructive(child: Text('삭제됨')), + badge('생성', value.canCreate), + badge('조회', value.canRead), + badge('수정', value.canUpdate), + badge('삭제', value.canDelete, destructive: true), + ]; + } + + final metadata = permissionValue == null + ? const [] + : [ + SuperportDetailMetadata.text( + label: 'ID', + value: permissionValue.id?.toString() ?? '-', + ), + SuperportDetailMetadata.text( + label: '그룹', + value: permissionValue.group.groupName, + ), + SuperportDetailMetadata.text( + label: '메뉴', + value: permissionValue.menu.menuName, + ), + SuperportDetailMetadata.text( + label: '메뉴 경로', + value: permissionValue.menu.path?.isEmpty ?? true + ? '-' + : permissionValue.menu.path!, + ), + SuperportDetailMetadata.text( + label: '생성 권한', + value: permissionValue.canCreate ? '허용' : '차단', + ), + SuperportDetailMetadata.text( + label: '조회 권한', + value: permissionValue.canRead ? '허용' : '차단', + ), + SuperportDetailMetadata.text( + label: '수정 권한', + value: permissionValue.canUpdate ? '허용' : '차단', + ), + SuperportDetailMetadata.text( + label: '삭제 권한', + value: permissionValue.canDelete ? '허용' : '차단', + ), + SuperportDetailMetadata.text( + label: '사용 여부', + value: permissionValue.isActive ? '사용중' : '미사용', + ), + SuperportDetailMetadata.text( + label: '삭제 여부', + value: permissionValue.isDeleted ? '삭제됨' : '정상', + ), + SuperportDetailMetadata.text( + label: '비고', + value: permissionValue.note?.isEmpty ?? true + ? '-' + : permissionValue.note!, + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: permissionValue.createdAt == null + ? '-' + : dateFormat.format(permissionValue.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '변경일시', + value: permissionValue.updatedAt == null + ? '-' + : dateFormat.format(permissionValue.updatedAt!.toLocal()), + ), + ]; + + return showSuperportDetailDialog( + context: context, + title: title, + description: description, + summary: permissionValue == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + permissionValue.group.groupName, + style: ShadTheme.of(context).textTheme.h4, + ), + const SizedBox(height: 4), + Text( + '→ ${permissionValue.menu.menuName}', + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ), + summaryBadges: buildSummaryBadges(permissionValue), + metadata: metadata, + sections: [ + _GroupPermissionEditSection( + id: isEdit + ? _GroupPermissionDetailSections.edit + : _GroupPermissionDetailSections.create, + label: isEdit ? '수정' : '등록', + permission: permissionValue, + groups: groups, + menus: menus, + isLoadingGroups: isLoadingGroups, + isLoadingMenus: isLoadingMenus, + onSubmit: (input) async { + if (permissionValue == null) { + final created = await onCreate(input); + if (created == null) { + return null; + } + return const GroupPermissionDetailDialogResult( + action: GroupPermissionDetailDialogAction.created, + message: '권한을 등록했습니다.', + ); + } + final permissionId = permissionValue.id; + if (permissionId == null) { + return null; + } + final updated = await onUpdate(permissionId, input); + if (updated == null) { + return null; + } + return const GroupPermissionDetailDialogResult( + action: GroupPermissionDetailDialogAction.updated, + message: '권한을 수정했습니다.', + ); + }, + ), + if (permissionValue != null) + SuperportDetailDialogSection( + id: permissionValue.isDeleted + ? _GroupPermissionDetailSections.restore + : _GroupPermissionDetailSections.delete, + label: permissionValue.isDeleted ? '복구' : '삭제', + icon: permissionValue.isDeleted + ? LucideIcons.history + : LucideIcons.trash2, + builder: (_) => _GroupPermissionDangerSection( + permission: permissionValue, + onDelete: () async { + final permissionId = permissionValue.id; + if (permissionId == null) { + return null; + } + final success = await onDelete(permissionId); + if (!success) { + return null; + } + return const GroupPermissionDetailDialogResult( + action: GroupPermissionDetailDialogAction.deleted, + message: '권한을 삭제했습니다.', + ); + }, + onRestore: () async { + final permissionId = permissionValue.id; + if (permissionId == null) { + return null; + } + final restored = await onRestore(permissionId); + if (restored == null) { + return null; + } + return const GroupPermissionDetailDialogResult( + action: GroupPermissionDetailDialogAction.restored, + message: '권한을 복구했습니다.', + ); + }, + ), + ), + ], + emptyPlaceholder: const Text('표시할 권한 정보가 없습니다.'), + initialSectionId: permissionValue == null + ? _GroupPermissionDetailSections.create + : _GroupPermissionDetailSections.edit, + ); +} + +/// 그룹 권한 상세 섹션 식별자. +class _GroupPermissionDetailSections { + static const edit = 'edit'; + static const delete = 'delete'; + static const restore = 'restore'; + static const create = 'create'; +} + +/// 그룹 권한 입력 섹션이다. +class _GroupPermissionEditSection extends SuperportDetailDialogSection { + _GroupPermissionEditSection({ + required super.id, + required super.label, + required GroupPermission? permission, + required List groups, + required List menus, + required bool isLoadingGroups, + required bool isLoadingMenus, + required Future Function( + GroupPermissionInput input, + ) + onSubmit, + }) : super( + icon: LucideIcons.pencil, + builder: (context) => _GroupPermissionForm( + permission: permission, + groups: groups, + menus: menus, + isLoadingGroups: isLoadingGroups, + isLoadingMenus: isLoadingMenus, + onSubmit: onSubmit, + ), + ); +} + +/// 권한 삭제/복구용 위험 섹션이다. +class _GroupPermissionDangerSection extends StatelessWidget { + const _GroupPermissionDangerSection({ + required this.permission, + required this.onDelete, + required this.onRestore, + }); + + final GroupPermission permission; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final description = permission.isDeleted + ? '복구하면 권한이 다시 활성화되고 권한 동기화가 진행됩니다.' + : '삭제하면 권한이 비활성화되어 접근이 차단됩니다.'; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + if (permission.isDeleted) + ShadButton( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onRestore(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: const Text('복구'), + ) + else + ShadButton.destructive( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onDelete(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: const Text('삭제'), + ), + ], + ), + ); + } +} + +/// 그룹 권한 입력 폼을 담당한다. +class _GroupPermissionForm extends StatefulWidget { + const _GroupPermissionForm({ + required this.permission, + required this.groups, + required this.menus, + required this.isLoadingGroups, + required this.isLoadingMenus, + required this.onSubmit, + }); + + final GroupPermission? permission; + final List groups; + final List menus; + final bool isLoadingGroups; + final bool isLoadingMenus; + final Future Function( + GroupPermissionInput input, + ) + onSubmit; + + bool get _isEdit => permission != null; + + @override + State<_GroupPermissionForm> createState() => _GroupPermissionFormState(); +} + +class _GroupPermissionFormState extends State<_GroupPermissionForm> { + int? _selectedGroup; + int? _selectedMenu; + late bool _canCreate; + late bool _canRead; + late bool _canUpdate; + late bool _canDelete; + late bool _isActive; + late final TextEditingController _noteController; + String? _groupError; + String? _menuError; + String? _submitError; + bool _isSubmitting = false; + + bool get _isEdit => widget._isEdit; + + @override + void initState() { + super.initState(); + final permission = widget.permission; + _selectedGroup = permission?.group.id; + _selectedMenu = permission?.menu.id; + _canCreate = permission?.canCreate ?? false; + _canRead = permission?.canRead ?? true; + _canUpdate = permission?.canUpdate ?? false; + _canDelete = permission?.canDelete ?? false; + _isActive = permission?.isActive ?? true; + _noteController = TextEditingController(text: permission?.note ?? ''); + } + + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _GroupPermissionFormField( + label: '그룹', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: _selectedGroup, + placeholder: Text( + widget.isLoadingGroups ? '그룹 로딩중...' : '그룹 선택', + ), + selectedOptionBuilder: (_, value) { + final label = _resolveGroupLabel(value); + return Text(label); + }, + onChanged: _isSubmitting || widget.isLoadingGroups + ? null + : (value) { + setState(() { + _selectedGroup = value; + _groupError = null; + }); + }, + options: widget.groups + .map( + (group) => ShadOption( + value: group.id!, + child: Text(group.groupName), + ), + ) + .toList(), + ), + if (_groupError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _groupError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _GroupPermissionFormField( + label: '메뉴', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadSelect( + initialValue: _selectedMenu, + placeholder: Text( + widget.isLoadingMenus ? '메뉴 로딩중...' : '메뉴 선택', + ), + selectedOptionBuilder: (_, value) { + final label = _resolveMenuLabel(value); + return Text(label); + }, + onChanged: _isSubmitting || widget.isLoadingMenus + ? null + : (value) { + setState(() { + _selectedMenu = value; + _menuError = null; + }); + }, + options: widget.menus + .map( + (menu) => ShadOption( + value: menu.id!, + child: Text(menu.menuName), + ), + ) + .toList(), + ), + if (_menuError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _menuError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _GroupPermissionToggleRow( + label: '생성 권한', + value: _canCreate, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _canCreate = value; + }); + }, + ), + const SizedBox(height: 12), + _GroupPermissionToggleRow( + label: '조회 권한', + value: _canRead, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _canRead = value; + }); + }, + ), + const SizedBox(height: 12), + _GroupPermissionToggleRow( + label: '수정 권한', + value: _canUpdate, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _canUpdate = value; + }); + }, + ), + const SizedBox(height: 12), + _GroupPermissionToggleRow( + label: '삭제 권한', + value: _canDelete, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _canDelete = value; + }); + }, + ), + const SizedBox(height: 16), + _GroupPermissionFormField( + label: '사용 여부', + child: Row( + children: [ + ShadSwitch( + value: _isActive, + onChanged: _isSubmitting + ? null + : (value) { + setState(() { + _isActive = value; + }); + }, + ), + const SizedBox(width: 8), + Text(_isActive ? '사용' : '미사용'), + ], + ), + ), + const SizedBox(height: 16), + _GroupPermissionFormField( + label: '비고', + child: ShadTextarea( + controller: _noteController, + minHeight: 96, + maxHeight: 220, + ), + ), + if (_submitError != null) ...[ + const SizedBox(height: 16), + Text( + _submitError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isEdit ? '저장' : '등록'), + ), + ), + ], + ); + } + + Future _handleSubmit() async { + setState(() { + _groupError = _selectedGroup == null ? '그룹을 선택하세요.' : null; + _menuError = _selectedMenu == null ? '메뉴를 선택하세요.' : null; + _submitError = null; + }); + + if (_groupError != null || _menuError != null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + final note = _noteController.text.trim(); + final input = GroupPermissionInput( + groupId: _selectedGroup!, + menuId: _selectedMenu!, + canCreate: _canCreate, + canRead: _canRead, + canUpdate: _canUpdate, + canDelete: _canDelete, + isActive: _isActive, + note: note.isEmpty ? null : note, + ); + final navigator = Navigator.of(context, rootNavigator: true); + final result = await widget.onSubmit(input); + + if (!mounted) { + return; + } + + setState(() { + _isSubmitting = false; + _submitError = result == null ? '요청 처리에 실패했습니다.' : null; + }); + + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } + + String _resolveGroupLabel(int? id) { + if (id == null) { + return widget.isLoadingGroups ? '그룹 로딩중...' : '그룹 선택'; + } + final group = widget.groups.firstWhere( + (item) => item.id == id, + orElse: () => Group(id: id, groupName: '알 수 없음'), + ); + return group.groupName; + } + + String _resolveMenuLabel(int? id) { + if (id == null) { + return widget.isLoadingMenus ? '메뉴 로딩중...' : '메뉴 선택'; + } + final menu = widget.menus.firstWhere( + (item) => item.id == id, + orElse: () => MenuItem(id: id, menuCode: '', menuName: '알 수 없음'), + ); + return menu.menuName; + } +} + +/// 폼 필드 레이아웃을 제공하는 위젯이다. +class _GroupPermissionFormField extends StatelessWidget { + const _GroupPermissionFormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +/// 토글 행을 그리는 위젯이다. +class _GroupPermissionToggleRow extends StatelessWidget { + const _GroupPermissionToggleRow({ + required this.label, + required this.value, + required this.onChanged, + }); + + final String label; + final bool value; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + ShadSwitch(value: value, onChanged: onChanged), + ], + ); + } +} + +/// 개요 섹션에서 사용하는 키-값 구조체이다. diff --git a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart index 6be5912..a31afe2 100644 --- a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart +++ b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart @@ -6,8 +6,8 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; -import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/permissions/permission_manager.dart'; @@ -19,6 +19,7 @@ import '../../../menu/domain/repositories/menu_repository.dart'; import '../../domain/entities/group_permission.dart'; import '../../domain/repositories/group_permission_repository.dart'; import '../controllers/group_permission_controller.dart'; +import '../dialogs/group_permission_detail_dialog.dart'; String _menuDisplayLabelFromPath(String? path, String fallback) { if (path != null && path.isNotEmpty) { @@ -216,7 +217,7 @@ class _GroupPermissionEnabledPageState leading: const Icon(LucideIcons.plus, size: 16), onPressed: _controller.isSubmitting ? null - : () => _openPermissionForm(context), + : _openPermissionCreateDialog, child: const Text('신규 등록'), ), ], @@ -399,16 +400,9 @@ class _GroupPermissionEnabledPageState : _PermissionTable( permissions: permissions, dateFormat: _dateFormat, - onEdit: _controller.isSubmitting + onRowTap: _controller.isSubmitting ? null - : (permission) => _openPermissionForm( - context, - permission: permission, - ), - onDelete: _controller.isSubmitting ? null : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restorePermission, + : _openPermissionDetailDialog, ), ), ); @@ -435,368 +429,45 @@ class _GroupPermissionEnabledPageState return _menuDisplayLabelFromPath(menu.path, menu.menuName); } - Future _openPermissionForm( - BuildContext context, { - GroupPermission? permission, - }) async { - final existingPermission = permission; - final isEdit = existingPermission != null; - final permissionId = existingPermission?.id; - if (isEdit && permissionId == null) { - _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + Future _openPermissionCreateDialog() async { + final result = await showGroupPermissionDetailDialog( + context: context, + dateFormat: _dateFormat, + groups: _controller.groups, + isLoadingGroups: _controller.isLoadingGroups, + menus: _controller.menus, + isLoadingMenus: _controller.isLoadingMenus, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + ); + if (result != null && mounted) { + _showSnack(result.message); + } + } + + Future _openPermissionDetailDialog(GroupPermission permission) async { + final permissionId = permission.id; + if (permissionId == null) { + _showSnack('ID 정보가 없어 상세를 열 수 없습니다.'); return; } - - final groupNotifier = ValueNotifier(existingPermission?.group.id); - final menuNotifier = ValueNotifier(existingPermission?.menu.id); - final createNotifier = ValueNotifier( - existingPermission?.canCreate ?? false, - ); - final readNotifier = ValueNotifier( - existingPermission?.canRead ?? true, - ); - final updateNotifier = ValueNotifier( - existingPermission?.canUpdate ?? false, - ); - final deleteNotifier = ValueNotifier( - existingPermission?.canDelete ?? false, - ); - final activeNotifier = ValueNotifier( - existingPermission?.isActive ?? true, - ); - final noteController = TextEditingController( - text: existingPermission?.note ?? '', - ); - final saving = ValueNotifier(false); - final groupError = ValueNotifier(null); - final menuError = ValueNotifier(null); - - await SuperportDialog.show( + final result = await showGroupPermissionDetailDialog( context: context, - dialog: SuperportDialog( - title: isEdit ? '권한 수정' : '권한 등록', - description: '그룹과 메뉴의 권한을 ${isEdit ? '수정' : '등록'}하세요.', - constraints: const BoxConstraints(maxWidth: 600), - secondaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (dialogContext, isSaving, __) { - return ShadButton.ghost( - onPressed: isSaving - ? null - : () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(false), - child: const Text('취소'), - ); - }, - ), - primaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (dialogContext, isSaving, __) { - return ShadButton( - onPressed: isSaving - ? null - : () async { - final groupId = groupNotifier.value; - final menuId = menuNotifier.value; - groupError.value = groupId == null ? '그룹을 선택하세요.' : null; - menuError.value = menuId == null ? '메뉴를 선택하세요.' : null; - if (groupError.value != null || menuError.value != null) { - return; - } - - saving.value = true; - final trimmedNote = noteController.text.trim(); - final input = GroupPermissionInput( - groupId: groupId!, - menuId: menuId!, - canCreate: createNotifier.value, - canRead: readNotifier.value, - canUpdate: updateNotifier.value, - canDelete: deleteNotifier.value, - isActive: activeNotifier.value, - note: trimmedNote.isEmpty ? null : trimmedNote, - ); - final navigator = Navigator.of( - dialogContext, - rootNavigator: true, - ); - final response = isEdit - ? await _controller.update(permissionId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack(isEdit ? '권한을 수정했습니다.' : '권한을 등록했습니다.'); - } - navigator.pop(true); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ); - }, - ), - child: ValueListenableBuilder( - valueListenable: saving, - builder: (dialogContext, isSaving, __) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - return SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: groupError, - builder: (_, errorText, __) { - return _FormField( - label: '그룹', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadSelect( - initialValue: groupNotifier.value, - placeholder: const Text('그룹을 선택하세요'), - selectedOptionBuilder: (context, value) { - if (value == null) { - return const Text('그룹을 선택하세요'); - } - final groupId = value; - final group = _controller.groups.firstWhere( - (g) => g.id == groupId, - orElse: () => - Group(id: groupId, groupName: ''), - ); - return Text( - group.groupName.isEmpty - ? '그룹을 선택하세요' - : group.groupName, - ); - }, - onChanged: isSaving || isEdit - ? null - : (value) { - groupNotifier.value = value; - if (value != null) { - groupError.value = null; - } - }, - options: [ - ..._controller.groups.map( - (group) => ShadOption( - value: group.id, - child: Text(group.groupName), - ), - ), - ], - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: menuError, - builder: (_, errorText, __) { - return _FormField( - label: '메뉴', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadSelect( - initialValue: menuNotifier.value, - placeholder: const Text('메뉴를 선택하세요'), - selectedOptionBuilder: (context, value) { - if (value == null) { - return const Text('메뉴를 선택하세요'); - } - final menuId = value; - final menu = _controller.menus.firstWhere( - (m) => m.id == menuId, - orElse: () => MenuItem( - id: menuId, - menuCode: '', - menuName: '', - ), - ); - return Text( - menu.menuName.isEmpty - ? '메뉴를 선택하세요' - : menu.menuName, - ); - }, - onChanged: isSaving || isEdit - ? null - : (value) { - menuNotifier.value = value; - if (value != null) { - menuError.value = null; - } - }, - options: [ - ..._controller.menus.map( - (menu) => ShadOption( - value: menu.id, - child: Text(menu.menuName), - ), - ), - ], - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 20), - _PermissionToggleRow( - label: '생성권한', - notifier: createNotifier, - enabled: !isSaving, - ), - const SizedBox(height: 12), - _PermissionToggleRow( - label: '조회권한', - notifier: readNotifier, - enabled: !isSaving, - ), - const SizedBox(height: 12), - _PermissionToggleRow( - label: '수정권한', - notifier: updateNotifier, - enabled: !isSaving, - ), - const SizedBox(height: 12), - _PermissionToggleRow( - label: '삭제권한', - notifier: deleteNotifier, - enabled: !isSaving, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: activeNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: isSaving - ? null - : (next) => activeNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (existingPermission != null) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existingPermission.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existingPermission.updatedAt)}', - style: theme.textTheme.small, - ), - ], - ], - ), - ); - }, - ), - ), + dateFormat: _dateFormat, + permission: permission, + groups: _controller.groups, + isLoadingGroups: _controller.isLoadingGroups, + menus: _controller.menus, + isLoadingMenus: _controller.isLoadingMenus, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, ); - - groupNotifier.dispose(); - menuNotifier.dispose(); - createNotifier.dispose(); - readNotifier.dispose(); - updateNotifier.dispose(); - deleteNotifier.dispose(); - activeNotifier.dispose(); - noteController.dispose(); - saving.dispose(); - groupError.dispose(); - menuError.dispose(); - } - - Future _confirmDelete(GroupPermission permission) async { - final confirmed = await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '권한 삭제', - description: - '"${permission.group.groupName}" → "${permission.menu.menuName}" 권한을 삭제하시겠습니까?', - secondaryAction: Builder( - builder: (dialogContext) { - return ShadButton.ghost( - onPressed: () => - Navigator.of(dialogContext, rootNavigator: true).pop(false), - child: const Text('취소'), - ); - }, - ), - primaryAction: Builder( - builder: (dialogContext) { - return ShadButton.destructive( - onPressed: () => - Navigator.of(dialogContext, rootNavigator: true).pop(true), - child: const Text('삭제'), - ); - }, - ), - ), - ); - - if (confirmed == true && permission.id != null) { - final success = await _controller.delete(permission.id!); - if (success && mounted) { - _showSnack('권한을 삭제했습니다.'); - } - } - } - - Future _restorePermission(GroupPermission permission) async { - if (permission.id == null) return; - final restored = await _controller.restore(permission.id!); - if (restored != null && mounted) { - _showSnack('권한을 복구했습니다.'); + if (result != null && mounted) { + _showSnack(result.message); } } @@ -810,179 +481,84 @@ class _GroupPermissionEnabledPageState } messenger.showSnackBar(SnackBar(content: Text(message))); } - - String _formatDateTime(DateTime? value) { - if (value == null) { - return '-'; - } - return _dateFormat.format(value.toLocal()); - } } class _PermissionTable extends StatelessWidget { const _PermissionTable({ required this.permissions, required this.dateFormat, - required this.onEdit, - required this.onDelete, - required this.onRestore, + required this.onRowTap, }); final List permissions; final intl.DateFormat dateFormat; - final void Function(GroupPermission permission)? onEdit; - final void Function(GroupPermission permission)? onDelete; - final void Function(GroupPermission permission)? onRestore; + final void Function(GroupPermission permission)? onRowTap; @override Widget build(BuildContext context) { - final header = [ - 'ID', - '그룹명', - '메뉴명', - '라우트 경로', - '생성', - '조회', - '수정', - '삭제', - '사용', - '삭제', - '비고', - '변경일시', - '동작', - ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + final columns = const [ + Text('ID'), + Text('그룹명'), + Text('메뉴명'), + Text('라우트 경로'), + Text('생성'), + Text('조회'), + Text('수정'), + Text('삭제'), + Text('사용'), + Text('삭제'), + Text('비고'), + Text('변경일시'), + ]; - final rows = permissions.map((permission) { - final cells = [ - permission.id?.toString() ?? '-', - permission.group.groupName, - _menuDisplayLabelFromPath( - permission.menu.path, - permission.menu.menuName, - ), - permission.menu.path ?? '-', - permission.canCreate ? 'Y' : '-', - permission.canRead ? 'Y' : '-', - permission.canUpdate ? 'Y' : '-', - permission.canDelete ? 'Y' : '-', - permission.isActive ? 'Y' : 'N', - permission.isDeleted ? 'Y' : '-', - permission.note?.isEmpty ?? true ? '-' : permission.note!, - permission.updatedAt == null - ? '-' - : dateFormat.format(permission.updatedAt!.toLocal()), - ].map((text) => ShadTableCell(child: Text(text))).toList(); - - cells.add( - ShadTableCell( - child: Align( - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(permission), - child: const Icon(LucideIcons.pencil, size: 16), - ), - const SizedBox(width: 8), - permission.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(permission), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(permission), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], - ), - ), - ), - ); - - return cells; - }).toList(); - - return SizedBox( - height: 56.0 * (permissions.length + 1), - child: ShadTable.list( - header: header, - children: rows, - columnSpanExtent: (index) { - switch (index) { - case 1: - case 2: - return const FixedTableSpanExtent(180); - case 9: - return const FixedTableSpanExtent(220); - case 11: - return const FixedTableSpanExtent(200); - default: - return const FixedTableSpanExtent(110); - } - }, - ), - ); - } -} - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.small), - const SizedBox(height: 6), - child, - ], - ); - } -} - -class _PermissionToggleRow extends StatelessWidget { - const _PermissionToggleRow({ - required this.label, - required this.notifier, - required this.enabled, - }); - - final String label; - final ValueNotifier notifier; - final bool enabled; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return ValueListenableBuilder( - valueListenable: notifier, - builder: (_, value, __) { - return _FormField( - label: label, - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: !enabled ? null : (next) => notifier.value = next, + final rows = permissions + .map( + (permission) => [ + Text(permission.id?.toString() ?? '-'), + Text(permission.group.groupName), + Text( + _menuDisplayLabelFromPath( + permission.menu.path, + permission.menu.menuName, ), - const SizedBox(width: 8), - Text(value ? '허용' : '차단', style: theme.textTheme.small), - ], - ), - ); + ), + Text(permission.menu.path ?? '-'), + Text(permission.canCreate ? 'Y' : '-'), + Text(permission.canRead ? 'Y' : '-'), + Text(permission.canUpdate ? 'Y' : '-'), + Text(permission.canDelete ? 'Y' : '-'), + Text(permission.isActive ? 'Y' : 'N'), + Text(permission.isDeleted ? 'Y' : '-'), + Text(permission.note?.isEmpty ?? true ? '-' : permission.note!), + Text( + permission.updatedAt == null + ? '-' + : dateFormat.format(permission.updatedAt!.toLocal()), + ), + ], + ) + .toList(); + + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 56, + maxHeight: 56.0 * (permissions.length + 1), + onRowTap: onRowTap == null + ? null + : (index) { + if (index < 0 || index >= permissions.length) { + return; + } + onRowTap!(permissions[index]); + }, + columnSpanExtent: (index) => switch (index) { + 1 => const FixedTableSpanExtent(180), + 2 => const FixedTableSpanExtent(200), + 3 => const FixedTableSpanExtent(220), + 10 => const FixedTableSpanExtent(220), + 11 => const FixedTableSpanExtent(200), + _ => const FixedTableSpanExtent(110), }, ); } diff --git a/lib/features/masters/menu/presentation/dialogs/menu_detail_dialog.dart b/lib/features/masters/menu/presentation/dialogs/menu_detail_dialog.dart new file mode 100644 index 0000000..28b4ae5 --- /dev/null +++ b/lib/features/masters/menu/presentation/dialogs/menu_detail_dialog.dart @@ -0,0 +1,589 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../domain/entities/menu.dart'; + +/// 메뉴 상세 다이얼로그에서 발생한 액션 종류이다. +enum MenuDetailDialogAction { created, updated, deleted, restored } + +/// 메뉴 상세 다이얼로그 결과를 표현한다. +class MenuDetailDialogResult { + const MenuDetailDialogResult({required this.action, required this.message}); + + final MenuDetailDialogAction action; + final String message; +} + +typedef MenuCreateCallback = Future Function(MenuInput input); +typedef MenuUpdateCallback = + Future Function(int id, MenuInput input); +typedef MenuDeleteCallback = Future Function(int id); +typedef MenuRestoreCallback = Future Function(int id); + +/// 메뉴 상세 다이얼로그를 띄워 CRUD 플로우를 통합한다. +Future showMenuDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + MenuItem? menu, + required List parents, + required bool isLoadingParents, + required MenuCreateCallback onCreate, + required MenuUpdateCallback onUpdate, + required MenuDeleteCallback onDelete, + required MenuRestoreCallback onRestore, +}) { + final menuValue = menu; + final isEdit = menuValue != null; + final title = isEdit ? '메뉴 상세' : '메뉴 등록'; + final description = isEdit + ? '메뉴 계층과 경로, 권한 관련 메타데이터를 확인합니다.' + : '신규 메뉴를 등록할 정보를 입력하세요.'; + final parentLabel = menuValue?.parent?.menuName ?? '최상위'; + + return showSuperportDetailDialog( + context: context, + title: title, + description: description, + summary: menuValue == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + menuValue.menuName, + style: ShadTheme.of(context).textTheme.h4, + ), + const SizedBox(height: 4), + Text( + menuValue.path?.isEmpty ?? true ? '경로 없음' : menuValue.path!, + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ), + summaryBadges: menuValue == null + ? const [] + : [ + ShadBadge.outline(child: Text('상위: $parentLabel')), + if (menuValue.isActive) + const ShadBadge(child: Text('사용중')) + else + const ShadBadge.outline(child: Text('미사용')), + if (menuValue.isDeleted) + const ShadBadge.destructive(child: Text('삭제됨')), + ], + metadata: menuValue == null + ? const [] + : [ + SuperportDetailMetadata.text( + label: 'ID', + value: menuValue.id?.toString() ?? '-', + ), + SuperportDetailMetadata.text( + label: '메뉴코드', + value: menuValue.menuCode, + ), + SuperportDetailMetadata.text( + label: '표시순서', + value: menuValue.displayOrder?.toString() ?? '-', + ), + SuperportDetailMetadata.text( + label: '비고', + value: menuValue.note?.isEmpty ?? true ? '-' : menuValue.note!, + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: menuValue.createdAt == null + ? '-' + : dateFormat.format(menuValue.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '변경일시', + value: menuValue.updatedAt == null + ? '-' + : dateFormat.format(menuValue.updatedAt!.toLocal()), + ), + ], + sections: [ + _MenuEditSection( + id: isEdit ? _MenuDetailSections.edit : _MenuDetailSections.create, + label: isEdit ? '수정' : '등록', + menu: menuValue, + parents: parents, + isLoadingParents: isLoadingParents, + onSubmit: (input) async { + if (menuValue == null) { + final created = await onCreate(input); + if (created == null) { + return null; + } + return const MenuDetailDialogResult( + action: MenuDetailDialogAction.created, + message: '메뉴를 등록했습니다.', + ); + } + final menuId = menuValue.id; + if (menuId == null) { + return null; + } + final updated = await onUpdate(menuId, input); + if (updated == null) { + return null; + } + return const MenuDetailDialogResult( + action: MenuDetailDialogAction.updated, + message: '메뉴를 수정했습니다.', + ); + }, + ), + if (menuValue != null) + SuperportDetailDialogSection( + id: menuValue.isDeleted + ? _MenuDetailSections.restore + : _MenuDetailSections.delete, + label: menuValue.isDeleted ? '복구' : '삭제', + icon: menuValue.isDeleted ? LucideIcons.history : LucideIcons.trash2, + builder: (_) => _MenuDangerSection( + menu: menuValue, + onDelete: () async { + final menuId = menuValue.id; + if (menuId == null) { + return null; + } + final success = await onDelete(menuId); + if (!success) { + return null; + } + return const MenuDetailDialogResult( + action: MenuDetailDialogAction.deleted, + message: '메뉴를 삭제했습니다.', + ); + }, + onRestore: () async { + final menuId = menuValue.id; + if (menuId == null) { + return null; + } + final restored = await onRestore(menuId); + if (restored == null) { + return null; + } + return const MenuDetailDialogResult( + action: MenuDetailDialogAction.restored, + message: '메뉴를 복구했습니다.', + ); + }, + ), + ), + ], + emptyPlaceholder: const Text('표시할 메뉴 정보가 없습니다.'), + initialSectionId: menuValue == null + ? _MenuDetailSections.create + : _MenuDetailSections.edit, + ); +} + +/// 메뉴 상세 다이얼로그 섹션 식별자 모음이다. +class _MenuDetailSections { + static const edit = 'edit'; + static const delete = 'delete'; + static const restore = 'restore'; + static const create = 'create'; +} + +/// 메뉴 입력 섹션을 정의한다. +class _MenuEditSection extends SuperportDetailDialogSection { + _MenuEditSection({ + required super.id, + required super.label, + required MenuItem? menu, + required List parents, + required bool isLoadingParents, + required Future Function(MenuInput input) onSubmit, + }) : super( + icon: LucideIcons.pencil, + builder: (context) => _MenuForm( + menu: menu, + parents: parents, + isLoadingParents: isLoadingParents, + onSubmit: onSubmit, + ), + ); +} + +/// 메뉴 삭제/복구 경고 및 실행을 담당한다. +class _MenuDangerSection extends StatelessWidget { + const _MenuDangerSection({ + required this.menu, + required this.onDelete, + required this.onRestore, + }); + + final MenuItem menu; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final description = menu.isDeleted + ? '복구하면 메뉴가 다시 탐색 트리에 노출됩니다.' + : '삭제하면 메뉴가 숨겨지지만 하위 데이터는 유지됩니다.'; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + if (menu.isDeleted) + ShadButton( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onRestore(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: const Text('복구'), + ) + else + ShadButton.destructive( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onDelete(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: const Text('삭제'), + ), + ], + ), + ); + } +} + +/// 메뉴 입력 폼을 구성하는 위젯이다. +class _MenuForm extends StatefulWidget { + const _MenuForm({ + required this.menu, + required this.parents, + required this.isLoadingParents, + required this.onSubmit, + }); + + final MenuItem? menu; + final List parents; + final bool isLoadingParents; + final Future Function(MenuInput input) onSubmit; + + bool get _isEdit => menu != null; + + @override + State<_MenuForm> createState() => _MenuFormState(); +} + +class _MenuFormState extends State<_MenuForm> { + late final TextEditingController _codeController; + late final TextEditingController _nameController; + late final TextEditingController _pathController; + late final TextEditingController _orderController; + late final TextEditingController _noteController; + late bool _isActive; + int? _selectedParent; + String? _codeError; + String? _nameError; + String? _orderError; + String? _submitError; + bool _isSubmitting = false; + + bool get _isEdit => widget._isEdit; + + @override + void initState() { + super.initState(); + final menu = widget.menu; + _codeController = TextEditingController(text: menu?.menuCode ?? ''); + _nameController = TextEditingController(text: menu?.menuName ?? ''); + _pathController = TextEditingController(text: menu?.path ?? ''); + _orderController = TextEditingController( + text: menu?.displayOrder?.toString() ?? '', + ); + _noteController = TextEditingController(text: menu?.note ?? ''); + _isActive = menu?.isActive ?? true; + _selectedParent = menu?.parent?.id; + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + _pathController.dispose(); + _orderController.dispose(); + _noteController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MenuFormField( + label: '메뉴코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _codeController, + readOnly: _isEdit, + onChanged: (_) { + if (_codeController.text.trim().isNotEmpty) { + setState(() { + _codeError = null; + }); + } + }, + ), + if (_codeError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _codeError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _MenuFormField( + label: '메뉴명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _nameController, + onChanged: (_) { + if (_nameController.text.trim().isNotEmpty) { + setState(() { + _nameError = null; + }); + } + }, + ), + if (_nameError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _nameError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _MenuFormField( + label: '상위메뉴', + child: ShadSelect( + initialValue: _selectedParent, + placeholder: Text(widget.isLoadingParents ? '상위 로딩중...' : '최상위'), + selectedOptionBuilder: (_, value) { + if (value == null) { + return Text(widget.isLoadingParents ? '상위 로딩중...' : '최상위'); + } + final menu = widget.parents.firstWhere( + (item) => item.id == value, + orElse: () => MenuItem(id: value, menuCode: '', menuName: ''), + ); + final label = menu.menuName.isEmpty ? '최상위' : menu.menuName; + return Text(label); + }, + onChanged: _isSubmitting || widget.isLoadingParents + ? null + : (next) { + setState(() { + _selectedParent = next; + }); + }, + options: [ + const ShadOption(value: null, child: Text('최상위')), + ...widget.parents + .where((item) => item.id != widget.menu?.id) + .map( + (item) => ShadOption( + value: item.id, + child: Text(item.menuName), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _MenuFormField( + label: '경로', + child: ShadInput(controller: _pathController), + ), + const SizedBox(height: 16), + _MenuFormField( + label: '표시순서', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _orderController, + keyboardType: TextInputType.number, + ), + if (_orderError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _orderError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _MenuFormField( + label: '사용 여부', + child: Row( + children: [ + ShadSwitch( + value: _isActive, + onChanged: _isSubmitting + ? null + : (next) { + setState(() { + _isActive = next; + }); + }, + ), + const SizedBox(width: 8), + Text(_isActive ? '사용' : '미사용'), + ], + ), + ), + const SizedBox(height: 16), + _MenuFormField( + label: '비고', + child: ShadTextarea( + controller: _noteController, + minHeight: 96, + maxHeight: 220, + ), + ), + if (_submitError != null) ...[ + const SizedBox(height: 16), + Text( + _submitError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isEdit ? '저장' : '등록'), + ), + ), + ], + ); + } + + Future _handleSubmit() async { + final code = _codeController.text.trim(); + final name = _nameController.text.trim(); + final orderText = _orderController.text.trim(); + + int? orderValue; + if (orderText.isNotEmpty) { + orderValue = int.tryParse(orderText); + } + + setState(() { + _codeError = code.isEmpty ? '메뉴코드를 입력하세요.' : null; + _nameError = name.isEmpty ? '메뉴명을 입력하세요.' : null; + _orderError = orderText.isEmpty || orderValue != null + ? null + : '표시순서는 숫자여야 합니다.'; + _submitError = null; + }); + + if (_codeError != null || _nameError != null || _orderError != null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + final path = _pathController.text.trim(); + final note = _noteController.text.trim(); + final input = MenuInput( + menuCode: code, + menuName: name, + parentMenuId: _selectedParent, + path: path.isEmpty ? null : path, + displayOrder: orderValue, + isActive: _isActive, + note: note.isEmpty ? null : note, + ); + final navigator = Navigator.of(context, rootNavigator: true); + final result = await widget.onSubmit(input); + + if (!mounted) { + return; + } + + setState(() { + _isSubmitting = false; + _submitError = result == null ? '요청 처리에 실패했습니다.' : null; + }); + + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } +} + +/// 메뉴 폼 필드 레이아웃을 제공한다. +class _MenuFormField extends StatelessWidget { + const _MenuFormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +/// 개요 섹션에서 사용하는 키-값 구조체이다. diff --git a/lib/features/masters/menu/presentation/pages/menu_page.dart b/lib/features/masters/menu/presentation/pages/menu_page.dart index fea6f2f..40ac785 100644 --- a/lib/features/masters/menu/presentation/pages/menu_page.dart +++ b/lib/features/masters/menu/presentation/pages/menu_page.dart @@ -5,14 +5,15 @@ import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; -import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../domain/entities/menu.dart'; import '../../domain/repositories/menu_repository.dart'; import '../controllers/menu_controller.dart' as menu; +import '../dialogs/menu_detail_dialog.dart'; /// 메뉴 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다. class MenuPage extends StatelessWidget { @@ -173,7 +174,7 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { leading: const Icon(LucideIcons.plus, size: 16), onPressed: _controller.isSubmitting ? null - : () => _openMenuForm(context), + : _openMenuCreateDialog, child: const Text('신규 등록'), ), ], @@ -330,11 +331,9 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { : _MenuTable( menus: menus, dateFormat: _dateFormat, - onEdit: _controller.isSubmitting + onRowTap: _controller.isSubmitting ? null - : (menuItem) => _openMenuForm(context, menu: menuItem), - onDelete: _controller.isSubmitting ? null : _confirmDelete, - onRestore: _controller.isSubmitting ? null : _restoreMenu, + : _openMenuDetailDialog, ), ), ); @@ -358,377 +357,41 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { } } - Future _openMenuForm(BuildContext context, {MenuItem? menu}) async { - final existingMenu = menu; - final isEdit = existingMenu != null; - final menuId = existingMenu?.id; - if (isEdit && menuId == null) { - _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + Future _openMenuCreateDialog() async { + final result = await showMenuDetailDialog( + context: context, + dateFormat: _dateFormat, + parents: _controller.parents, + isLoadingParents: _controller.isLoadingParents, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + ); + if (result != null && mounted) { + _showSnack(result.message); + } + } + + Future _openMenuDetailDialog(MenuItem menu) async { + final menuId = menu.id; + if (menuId == null) { + _showSnack('ID 정보가 없어 상세를 열 수 없습니다.'); return; } - - final codeController = TextEditingController( - text: existingMenu?.menuCode ?? '', - ); - final nameController = TextEditingController( - text: existingMenu?.menuName ?? '', - ); - final pathController = TextEditingController( - text: existingMenu?.path ?? '', - ); - final orderController = TextEditingController( - text: existingMenu?.displayOrder?.toString() ?? '', - ); - final noteController = TextEditingController( - text: existingMenu?.note ?? '', - ); - final parentNotifier = ValueNotifier(existingMenu?.parent?.id); - final isActiveNotifier = ValueNotifier( - existingMenu?.isActive ?? true, - ); - final saving = ValueNotifier(false); - final codeError = ValueNotifier(null); - final nameError = ValueNotifier(null); - final orderError = ValueNotifier(null); - - await SuperportDialog.show( + final result = await showMenuDetailDialog( context: context, - dialog: SuperportDialog( - title: isEdit ? '메뉴 수정' : '메뉴 등록', - description: '메뉴 정보를 ${isEdit ? '수정' : '입력'}하세요.', - constraints: const BoxConstraints(maxWidth: 560), - secondaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (dialogContext, isSaving, __) { - return ShadButton.ghost( - onPressed: isSaving - ? null - : () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(false), - child: const Text('취소'), - ); - }, - ), - primaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (dialogContext, isSaving, __) { - return ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final path = pathController.text.trim(); - final orderText = orderController.text.trim(); - final note = noteController.text.trim(); - - codeError.value = code.isEmpty ? '메뉴코드를 입력하세요.' : null; - nameError.value = name.isEmpty ? '메뉴명을 입력하세요.' : null; - - int? orderValue; - if (orderText.isNotEmpty) { - orderValue = int.tryParse(orderText); - orderError.value = orderValue == null - ? '표시순서는 숫자여야 합니다.' - : null; - } else { - orderError.value = null; - } - - if (codeError.value != null || - nameError.value != null || - orderError.value != null) { - return; - } - - saving.value = true; - final input = MenuInput( - menuCode: code, - menuName: name, - parentMenuId: parentNotifier.value, - path: path.isEmpty ? null : path, - displayOrder: orderValue, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final navigator = Navigator.of( - dialogContext, - rootNavigator: true, - ); - final response = isEdit - ? await _controller.update(menuId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack(isEdit ? '메뉴를 수정했습니다.' : '메뉴를 등록했습니다.'); - } - navigator.pop(true); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ); - }, - ), - child: ValueListenableBuilder( - valueListenable: saving, - builder: (dialogContext, isSaving, __) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - return SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '메뉴코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '메뉴명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: parentNotifier, - builder: (_, value, __) { - return _FormField( - label: '상위메뉴', - child: ShadSelect( - initialValue: value, - placeholder: const Text('최상위'), - selectedOptionBuilder: (context, selected) { - if (selected == null) { - return const Text('최상위'); - } - final target = _controller.parents.firstWhere( - (item) => item.id == selected, - orElse: () => MenuItem( - id: selected, - menuCode: '', - menuName: '', - ), - ); - final label = target.menuName.isEmpty - ? '최상위' - : target.menuName; - return Text(label); - }, - onChanged: isSaving - ? null - : (next) => parentNotifier.value = next, - options: [ - const ShadOption( - value: null, - child: Text('최상위'), - ), - ..._controller.parents - .where((item) => item.id != menuId) - .map( - (menuItem) => ShadOption( - value: menuItem.id, - child: Text(menuItem.menuName), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '경로', - child: ShadInput(controller: pathController), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: orderError, - builder: (_, errorText, __) { - return _FormField( - label: '표시순서', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: orderController, - keyboardType: TextInputType.number, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: isSaving - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (existingMenu != null) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existingMenu.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existingMenu.updatedAt)}', - style: theme.textTheme.small, - ), - ], - ], - ), - ); - }, - ), - ), + dateFormat: _dateFormat, + menu: menu, + parents: _controller.parents, + isLoadingParents: _controller.isLoadingParents, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, ); - - codeController.dispose(); - nameController.dispose(); - pathController.dispose(); - orderController.dispose(); - noteController.dispose(); - parentNotifier.dispose(); - isActiveNotifier.dispose(); - saving.dispose(); - codeError.dispose(); - nameError.dispose(); - orderError.dispose(); - } - - Future _confirmDelete(MenuItem menu) async { - final confirmed = await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '메뉴 삭제', - description: '"${menu.menuName}" 메뉴를 삭제하시겠습니까?', - secondaryAction: Builder( - builder: (dialogContext) { - return ShadButton.ghost( - onPressed: () => - Navigator.of(dialogContext, rootNavigator: true).pop(false), - child: const Text('취소'), - ); - }, - ), - primaryAction: Builder( - builder: (dialogContext) { - return ShadButton.destructive( - onPressed: () => - Navigator.of(dialogContext, rootNavigator: true).pop(true), - child: const Text('삭제'), - ); - }, - ), - ), - ); - - if (confirmed == true && menu.id != null) { - final success = await _controller.delete(menu.id!); - if (success && mounted) { - _showSnack('메뉴를 삭제했습니다.'); - } - } - } - - Future _restoreMenu(MenuItem menu) async { - if (menu.id == null) return; - final restored = await _controller.restore(menu.id!); - if (restored != null && mounted) { - _showSnack('메뉴를 복구했습니다.'); + if (result != null && mounted) { + _showSnack(result.message); } } @@ -742,131 +405,73 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { } messenger.showSnackBar(SnackBar(content: Text(message))); } - - String _formatDateTime(DateTime? value) { - if (value == null) { - return '-'; - } - return _dateFormat.format(value.toLocal()); - } } class _MenuTable extends StatelessWidget { const _MenuTable({ required this.menus, - required this.onEdit, - required this.onDelete, - required this.onRestore, required this.dateFormat, + required this.onRowTap, }); final List menus; - final void Function(MenuItem menu)? onEdit; - final void Function(MenuItem menu)? onDelete; - final void Function(MenuItem menu)? onRestore; final DateFormat dateFormat; + final void Function(MenuItem menu)? onRowTap; @override Widget build(BuildContext context) { - final header = [ - 'ID', - '메뉴코드', - '메뉴명', - '상위메뉴', - '경로', - '사용', - '삭제', - '비고', - '변경일시', - '동작', - ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + final columns = const [ + Text('ID'), + Text('메뉴코드'), + Text('메뉴명'), + Text('상위메뉴'), + Text('경로'), + Text('사용'), + Text('삭제'), + Text('비고'), + Text('변경일시'), + ]; - final rows = menus.map((item) { - final cells = [ - item.id?.toString() ?? '-', - item.menuCode, - item.menuName, - item.parent?.menuName ?? '-', - item.path?.isEmpty ?? true ? '-' : item.path!, - item.isActive ? 'Y' : 'N', - item.isDeleted ? 'Y' : '-', - item.note?.isEmpty ?? true ? '-' : item.note!, - item.updatedAt == null - ? '-' - : dateFormat.format(item.updatedAt!.toLocal()), - ].map((text) => ShadTableCell(child: Text(text))).toList(); + final rows = menus + .map( + (item) => [ + Text(item.id?.toString() ?? '-'), + Text(item.menuCode), + Text(item.menuName), + Text(item.parent?.menuName ?? '-'), + Text(item.path?.isEmpty ?? true ? '-' : item.path!), + Text(item.isActive ? 'Y' : 'N'), + Text(item.isDeleted ? 'Y' : '-'), + Text(item.note?.isEmpty ?? true ? '-' : item.note!), + Text( + item.updatedAt == null + ? '-' + : dateFormat.format(item.updatedAt!.toLocal()), + ), + ], + ) + .toList(); - cells.add( - ShadTableCell( - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(item), - child: const Icon(LucideIcons.pencil, size: 16), - ), - const SizedBox(width: 8), - item.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(item), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(item), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], - ), - ), - ); - return cells; - }).toList(); - - return SizedBox( - height: 56.0 * (menus.length + 1), - child: ShadTable.list( - header: header, - children: rows, - columnSpanExtent: (index) { - switch (index) { - case 4: - return const FixedTableSpanExtent(200); - case 7: - return const FixedTableSpanExtent(200); - case 9: - return const FixedTableSpanExtent(160); - default: - return const FixedTableSpanExtent(120); - } - }, - ), - ); - } -} - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.small), - const SizedBox(height: 6), - child, - ], + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 56, + maxHeight: 56.0 * (menus.length + 1), + onRowTap: onRowTap == null + ? null + : (index) { + if (index < 0 || index >= menus.length) { + return; + } + onRowTap!(menus[index]); + }, + columnSpanExtent: (index) => switch (index) { + 3 => const FixedTableSpanExtent(180), + 4 => const FixedTableSpanExtent(200), + 7 => const FixedTableSpanExtent(200), + 8 => const FixedTableSpanExtent(160), + _ => const FixedTableSpanExtent(120), + }, ); } } diff --git a/lib/features/masters/product/presentation/dialogs/product_detail_dialog.dart b/lib/features/masters/product/presentation/dialogs/product_detail_dialog.dart new file mode 100644 index 0000000..f61e28a --- /dev/null +++ b/lib/features/masters/product/presentation/dialogs/product_detail_dialog.dart @@ -0,0 +1,760 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../../uom/domain/entities/uom.dart'; +import '../../../vendor/domain/entities/vendor.dart'; +import '../../domain/entities/product.dart'; +import '../widgets/uom_autocomplete_field.dart'; +import '../widgets/vendor_autocomplete_field.dart'; + +/// 제품 상세 다이얼로그에서 발생 가능한 액션 종류이다. +enum ProductDetailDialogAction { created, updated, deleted, restored } + +/// 제품 상세 다이얼로그가 반환하는 결과 모델이다. +class ProductDetailDialogResult { + const ProductDetailDialogResult({ + required this.action, + required this.message, + this.product, + }); + + final ProductDetailDialogAction action; + final String message; + + /// 갱신된 제품 엔터티. 삭제 동작의 경우 null일 수 있다. + final Product? product; +} + +typedef ProductCreateCallback = Future Function(ProductInput input); +typedef ProductUpdateCallback = + Future Function(int id, ProductInput input); +typedef ProductDeleteCallback = Future Function(int id); +typedef ProductRestoreCallback = Future Function(int id); + +/// 제품 상세 다이얼로그를 노출한다. +Future showProductDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + Product? product, + required List vendorOptions, + required List uomOptions, + required ProductCreateCallback onCreate, + required ProductUpdateCallback onUpdate, + required ProductDeleteCallback onDelete, + required ProductRestoreCallback onRestore, +}) { + final metadata = product == null + ? const [] + : [ + SuperportDetailMetadata.text( + label: 'ID', + value: product.id?.toString() ?? '-', + ), + SuperportDetailMetadata.text( + label: '제품코드', + value: product.productCode, + ), + SuperportDetailMetadata.text( + label: '사용 상태', + value: product.isActive ? '사용중' : '미사용', + ), + SuperportDetailMetadata.text( + label: '삭제 여부', + value: product.isDeleted ? '삭제됨' : '정상', + ), + SuperportDetailMetadata.text( + label: '제조사', + value: product.vendor == null + ? '-' + : '${product.vendor!.vendorName} (${product.vendor!.vendorCode})', + ), + SuperportDetailMetadata.text( + label: '단위', + value: product.uom?.uomName ?? '-', + ), + SuperportDetailMetadata.text( + label: '비고', + value: product.note?.isEmpty ?? true ? '-' : product.note!, + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: product.createdAt == null + ? '-' + : dateFormat.format(product.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '변경일시', + value: product.updatedAt == null + ? '-' + : dateFormat.format(product.updatedAt!.toLocal()), + ), + ]; + + return showSuperportDetailDialog( + context: context, + title: product == null ? '제품 등록' : '제품 상세', + description: product == null + ? '새로운 장비 모델(제품) 정보를 입력하세요.' + : '제품 기본 정보와 연결된 제조사·단위 정보를 확인하고 관리합니다.', + sections: [ + if (product != null) + SuperportDetailDialogSection( + id: _ProductDetailSections.relations, + label: '연결 관계', + icon: LucideIcons.link, + builder: (_) => _ProductRelationsSection(product: product), + ), + if (product != null) + SuperportDetailDialogSection( + id: _ProductDetailSections.history, + label: '히스토리', + icon: LucideIcons.clock3, + builder: (_) => const _ProductHistorySection(), + ), + _ProductFormSection( + id: product == null + ? _ProductDetailSections.create + : _ProductDetailSections.edit, + label: product == null ? '등록' : '수정', + product: product, + vendorOptions: vendorOptions, + uomOptions: uomOptions, + onSubmit: (input) async { + if (product == null) { + final created = await onCreate(input); + if (created == null) { + return null; + } + return ProductDetailDialogResult( + action: ProductDetailDialogAction.created, + message: '제품을 등록했습니다.', + product: created, + ); + } + final updated = await onUpdate(product.id!, input); + if (updated == null) { + return null; + } + return ProductDetailDialogResult( + action: ProductDetailDialogAction.updated, + message: '제품을 수정했습니다.', + product: updated, + ); + }, + ), + if (product != null) + _ProductDangerSection( + product: product, + onDelete: () async { + final id = product.id; + if (id == null) { + return null; + } + final success = await onDelete(id); + if (!success) { + return null; + } + return ProductDetailDialogResult( + action: ProductDetailDialogAction.deleted, + message: '제품을 삭제했습니다.', + product: product, + ); + }, + onRestore: () async { + final id = product.id; + if (id == null) { + return null; + } + final restored = await onRestore(id); + if (restored == null) { + return null; + } + return ProductDetailDialogResult( + action: ProductDetailDialogAction.restored, + message: '제품을 복구했습니다.', + product: restored, + ); + }, + ), + ], + summary: product == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.productName, + style: ShadTheme.of(context).textTheme.h4, + ), + const SizedBox(height: 4), + Text( + '제품코드 ${product.productCode}', + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ), + summaryBadges: product == null + ? const [] + : [ + if (product.isActive) + const ShadBadge(child: Text('사용중')) + else + const ShadBadge.outline(child: Text('미사용')), + if (product.isDeleted) + const ShadBadge.destructive(child: Text('삭제됨')), + ], + metadata: metadata, + initialSectionId: product == null + ? _ProductDetailSections.create + : _ProductDetailSections.relations, + emptyPlaceholder: const Text('표시할 세부 정보가 없습니다.'), + ); +} + +/// 제품 상세 다이얼로그 섹션 정의 모음이다. +class _ProductDetailSections { + static const relations = 'relations'; + static const history = 'history'; + static const edit = 'edit'; + static const delete = 'delete'; + static const restore = 'restore'; + static const create = 'create'; +} + +/// 제품과 연결된 제조사/단위 정보를 표시하는 섹션이다. +class _ProductRelationsSection extends StatelessWidget { + const _ProductRelationsSection({required this.product}); + + final Product product; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final vendor = product.vendor; + final uom = product.uom; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _RelationTile( + icon: LucideIcons.factory, + title: '제조사', + description: vendor == null + ? '연결된 제조사가 없습니다.' + : '${vendor.vendorName} (${vendor.vendorCode})', + ), + const SizedBox(height: 16), + _RelationTile( + icon: LucideIcons.scale, + title: '단위(UOM)', + description: uom == null ? '연결된 단위 정보가 없습니다.' : uom.uomName, + ), + const SizedBox(height: 16), + Text( + '하단의 수정 섹션에서 제조사와 단위를 변경할 수 있습니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ); + } +} + +/// 제품 변경 이력을 안내하는 섹션이다. +class _ProductHistorySection extends StatelessWidget { + const _ProductHistorySection(); + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('현재 제품 변경 이력 데이터는 준비 중입니다.', style: theme.textTheme.small), + const SizedBox(height: 12), + Text( + '재고 입출고 추적 기능이 추가되면 히스토리를 통해 상태 변화를 확인할 수 있습니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ); + } +} + +/// 제품 등록/수정 폼을 제공하는 섹션이다. +class _ProductFormSection extends SuperportDetailDialogSection { + _ProductFormSection({ + required super.id, + required super.label, + required Product? product, + required this.vendorOptions, + required this.uomOptions, + required this.onSubmit, + }) : super( + icon: LucideIcons.pencil, + builder: (context) => _ProductForm( + product: product, + vendorOptions: vendorOptions, + uomOptions: uomOptions, + onSubmit: onSubmit, + ), + ); + + final List vendorOptions; + final List uomOptions; + final Future Function(ProductInput input) + onSubmit; +} + +/// 제품 삭제/복구 액션을 제공하는 섹션이다. +class _ProductDangerSection extends SuperportDetailDialogSection { + _ProductDangerSection({ + required this.product, + required this.onDelete, + required this.onRestore, + }) : super( + id: product.isDeleted + ? _ProductDetailSections.restore + : _ProductDetailSections.delete, + label: product.isDeleted ? '복구' : '삭제', + icon: product.isDeleted ? LucideIcons.history : LucideIcons.trash2, + builder: (context) => _ProductDangerContent( + product: product, + onDelete: onDelete, + onRestore: onRestore, + ), + scrollable: false, + ); + + final Product product; + final Future Function() onDelete; + final Future Function() onRestore; +} + +/// 제품 삭제/복구 안내 레이아웃이다. +class _ProductDangerContent extends StatelessWidget { + const _ProductDangerContent({ + required this.product, + required this.onDelete, + required this.onRestore, + }); + + final Product product; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final isDeleted = product.isDeleted; + final description = isDeleted + ? '제품을 복구하면 다시 목록에 노출되고 재고 등록 시 선택할 수 있습니다.' + : '삭제하면 제품이 목록에서 숨겨지며 기존 데이터는 유지됩니다.'; + final confirmLabel = isDeleted ? '복구' : '삭제'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + if (isDeleted) + ShadButton( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onRestore(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: Text(confirmLabel), + ) + else + ShadButton.destructive( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onDelete(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: Text(confirmLabel), + ), + ], + ); + } +} + +/// 제품 입력 폼 본문이다. +class _ProductForm extends StatefulWidget { + const _ProductForm({ + required this.product, + required this.vendorOptions, + required this.uomOptions, + required this.onSubmit, + }); + + final Product? product; + final List vendorOptions; + final List uomOptions; + final Future Function(ProductInput input) + onSubmit; + + @override + State<_ProductForm> createState() => _ProductFormState(); +} + +class _ProductFormState extends State<_ProductForm> { + late final TextEditingController _codeController; + late final TextEditingController _nameController; + late final TextEditingController _noteController; + late final ValueNotifier _vendorNotifier; + late final ValueNotifier _uomNotifier; + late final ValueNotifier _isActiveNotifier; + String? _codeError; + String? _nameError; + String? _vendorError; + String? _uomError; + String? _submitError; + bool _isSubmitting = false; + + bool get _isEdit => widget.product != null; + + @override + void initState() { + super.initState(); + final product = widget.product; + _codeController = TextEditingController(text: product?.productCode ?? ''); + _nameController = TextEditingController(text: product?.productName ?? ''); + _noteController = TextEditingController(text: product?.note ?? ''); + _vendorNotifier = ValueNotifier(product?.vendor?.id); + _uomNotifier = ValueNotifier(product?.uom?.id); + _isActiveNotifier = ValueNotifier(product?.isActive ?? true); + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + _noteController.dispose(); + _vendorNotifier.dispose(); + _uomNotifier.dispose(); + _isActiveNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ProductFormField( + label: '제품코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _codeController, + readOnly: _isEdit, + onChanged: (_) { + if (_codeController.text.trim().isNotEmpty) { + setState(() { + _codeError = null; + }); + } + }, + ), + if (_codeError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _codeError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _ProductFormField( + label: '제품명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _nameController, + onChanged: (_) { + if (_nameController.text.trim().isNotEmpty) { + setState(() { + _nameError = null; + }); + } + }, + ), + if (_nameError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _nameError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _ProductFormField( + label: '제조사', + child: ValueListenableBuilder( + valueListenable: _vendorNotifier, + builder: (_, vendorId, __) { + final initialLabel = widget.product?.vendor == null + ? null + : '${widget.product!.vendor!.vendorName} ' + '(${widget.product!.vendor!.vendorCode})'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + VendorAutocompleteField( + initialOptions: widget.vendorOptions, + selectedVendorId: vendorId, + initialLabel: initialLabel, + onSelected: (id) { + _vendorNotifier.value = id; + setState(() { + _vendorError = null; + }); + }, + ), + if (_vendorError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _vendorError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 16), + _ProductFormField( + label: '단위', + child: ValueListenableBuilder( + valueListenable: _uomNotifier, + builder: (_, uomId, __) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + UomAutocompleteField( + initialOptions: widget.uomOptions, + selectedUomId: uomId, + initialLabel: widget.product?.uom?.uomName, + onSelected: (id) { + _uomNotifier.value = id; + setState(() { + _uomError = null; + }); + }, + ), + if (_uomError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _uomError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ); + }, + ), + ), + const SizedBox(height: 16), + _ProductFormField( + label: '사용여부', + child: ValueListenableBuilder( + valueListenable: _isActiveNotifier, + builder: (_, value, __) { + return Row( + children: [ + ShadSwitch( + value: value, + onChanged: _isSubmitting + ? null + : (next) => _isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ); + }, + ), + ), + const SizedBox(height: 16), + _ProductFormField( + label: '비고', + child: ShadTextarea( + controller: _noteController, + minHeight: 96, + maxHeight: 240, + ), + ), + if (_submitError != null) ...[ + const SizedBox(height: 16), + Text( + _submitError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isEdit ? '저장' : '등록'), + ), + ), + ], + ); + } + + Future _handleSubmit() async { + final code = _codeController.text.trim(); + final name = _nameController.text.trim(); + final vendorId = _vendorNotifier.value; + final uomId = _uomNotifier.value; + + setState(() { + _codeError = code.isEmpty ? '제품코드를 입력하세요.' : null; + _nameError = name.isEmpty ? '제품명을 입력하세요.' : null; + _vendorError = vendorId == null ? '제조사를 선택하세요.' : null; + _uomError = uomId == null ? '단위를 선택하세요.' : null; + _submitError = null; + }); + + if (_codeError != null || + _nameError != null || + _vendorError != null || + _uomError != null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + final input = ProductInput( + productCode: code, + productName: name, + vendorId: vendorId!, + uomId: uomId!, + isActive: _isActiveNotifier.value, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + ); + final navigator = Navigator.of(context, rootNavigator: true); + final result = await widget.onSubmit(input); + + if (!mounted) { + return; + } + + setState(() { + _isSubmitting = false; + _submitError = result == null ? '요청 처리에 실패했습니다.' : null; + }); + + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } +} + +/// 폼 필드 레이아웃을 제공하는 헬퍼 위젯이다. +class _ProductFormField extends StatelessWidget { + const _ProductFormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +/// 관계 타일을 렌더링하는 위젯이다. +class _RelationTile extends StatelessWidget { + const _RelationTile({ + required this.icon, + required this.title, + required this.description, + }); + + final IconData icon; + final String title; + final String description; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return ShadCard( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(right: 12, top: 4), + child: Icon(icon, size: 18), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text(description, style: theme.textTheme.small), + ], + ), + ), + ], + ), + ); + } +} + +/// 키-값 행 데이터를 표현하는 모델이다. diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 39e0a61..e98a654 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -1,12 +1,14 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; -import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import 'package:superport_v2/widgets/components/superport_table.dart'; import 'package:superport_v2/widgets/components/responsive_section.dart'; @@ -20,8 +22,7 @@ import '../../../vendor/domain/repositories/vendor_repository.dart'; import '../../domain/entities/product.dart'; import '../../domain/repositories/product_repository.dart'; import '../controllers/product_controller.dart'; -import '../widgets/uom_autocomplete_field.dart'; -import '../widgets/vendor_autocomplete_field.dart'; +import '../dialogs/product_detail_dialog.dart'; /// 제품 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다. class ProductPage extends StatelessWidget { @@ -94,7 +95,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { late final ProductController _controller; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocus = FocusNode(); - final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); bool _lookupsLoaded = false; String? _lastError; String? _lastRouteSignature; @@ -178,7 +179,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { leading: const Icon(LucideIcons.plus, size: 16), onPressed: _controller.isSubmitting ? null - : () => _openProductForm(context), + : _openProductCreateDialog, child: const Text('신규 등록'), ), ], @@ -348,14 +349,9 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { : _ProductTable( products: products, dateFormat: _dateFormat, - onEdit: _controller.isSubmitting + onProductTap: _controller.isSubmitting ? null - : (product) => - _openProductForm(context, product: product), - onDelete: _controller.isSubmitting ? null : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restoreProduct, + : _openProductDetailDialog, ), ), ); @@ -510,369 +506,61 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { } } - Future _openProductForm( - BuildContext context, { - Product? product, - }) async { - final existing = product; - final isEdit = existing != null; - final productId = existing?.id; - if (isEdit && productId == null) { - _showSnack('ID 정보가 없어 수정할 수 없습니다.'); - return; - } - + Future _openProductCreateDialog() async { if (!_lookupsLoaded) { _showSnack('선택 목록을 불러오는 중입니다. 잠시 후 다시 시도하세요.'); return; } - final parentContext = context; - - final codeController = TextEditingController( - text: existing?.productCode ?? '', - ); - final nameController = TextEditingController( - text: existing?.productName ?? '', - ); - final noteController = TextEditingController(text: existing?.note ?? ''); - final vendorNotifier = ValueNotifier(existing?.vendor?.id); - final uomNotifier = ValueNotifier(existing?.uom?.id); - final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); - final saving = ValueNotifier(false); - final codeError = ValueNotifier(null); - final nameError = ValueNotifier(null); - final vendorError = ValueNotifier(null); - final uomError = ValueNotifier(null); - - await SuperportDialog.show( - context: parentContext, - dialog: SuperportDialog( - title: isEdit ? '제품 수정' : '제품 등록', - description: '제품 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', - primaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final note = noteController.text.trim(); - final vendorId = vendorNotifier.value; - final uomId = uomNotifier.value; - - codeError.value = code.isEmpty ? '제품코드를 입력하세요.' : null; - nameError.value = name.isEmpty ? '제품명을 입력하세요.' : null; - vendorError.value = vendorId == null - ? '제조사를 선택하세요.' - : null; - uomError.value = uomId == null ? '단위를 선택하세요.' : null; - - if (codeError.value != null || - nameError.value != null || - vendorError.value != null || - uomError.value != null) { - return; - } - - saving.value = true; - final input = ProductInput( - productCode: code, - productName: name, - vendorId: vendorId!, - uomId: uomId!, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final navigator = Navigator.of( - context, - rootNavigator: true, - ); - final response = isEdit - ? await _controller.update(productId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null && mounted) { - if (!navigator.mounted) { - return; - } - _showSnack(isEdit ? '제품을 수정했습니다.' : '제품을 등록했습니다.'); - navigator.pop(true); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ); - }, - ), - secondaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton.ghost( - onPressed: isSaving - ? null - : () => Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ); - }, - ), - child: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - final theme = ShadTheme.of(context); - final materialTheme = Theme.of(context); - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '제품코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '제품명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: vendorNotifier, - builder: (_, value, __) { - return ValueListenableBuilder( - valueListenable: vendorError, - builder: (_, errorText, __) { - final vendorLabel = () { - final vendor = existing?.vendor; - if (vendor == null) { - return null; - } - return '${vendor.vendorName} (${vendor.vendorCode})'; - }(); - return _FormField( - label: '제조사', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - VendorAutocompleteField( - initialOptions: _controller.vendorOptions, - selectedVendorId: value, - initialLabel: vendorLabel, - enabled: !isSaving, - placeholder: const Text('제조사를 선택하세요'), - onSelected: (id) { - debugPrint( - '[ProductForm] 제조사 선택 -> id=$id', - ); - vendorNotifier.value = id; - vendorError.value = null; - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: uomNotifier, - builder: (_, value, __) { - return ValueListenableBuilder( - valueListenable: uomError, - builder: (_, errorText, __) { - final uomLabel = existing?.uom?.uomName; - return _FormField( - label: '단위', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - UomAutocompleteField( - initialOptions: _controller.uomOptions, - selectedUomId: value, - initialLabel: uomLabel, - enabled: !isSaving, - placeholder: const Text('단위를 선택하세요'), - onSelected: (id) { - debugPrint('[ProductForm] 단위 선택 -> id=$id'); - uomNotifier.value = id; - uomError.value = null; - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existing.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existing.updatedAt)}', - style: theme.textTheme.small, - ), - ], - ], - ), - ); - }, - ), - ), - ); - - codeController.dispose(); - nameController.dispose(); - noteController.dispose(); - vendorNotifier.dispose(); - uomNotifier.dispose(); - isActiveNotifier.dispose(); - saving.dispose(); - codeError.dispose(); - nameError.dispose(); - vendorError.dispose(); - uomError.dispose(); - } - - Future _confirmDelete(Product product) async { - final bool? confirmed = await SuperportDialog.show( + final result = await showProductDetailDialog( context: context, - dialog: SuperportDialog( - title: '제품 삭제', - description: '"${product.productName}" 제품을 삭제하시겠습니까?', - primaryAction: ShadButton.destructive( - onPressed: () => Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('삭제'), - ), - secondaryAction: ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ), + dateFormat: _dateFormat, + vendorOptions: _controller.vendorOptions, + uomOptions: _controller.uomOptions, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, ); - if (confirmed == true && product.id != null) { - final success = await _controller.delete(product.id!); - if (success && mounted) { - _showSnack('제품을 삭제했습니다.'); + if (result != null && mounted) { + _showSnack(result.message); + switch (result.action) { + case ProductDetailDialogAction.created: + unawaited(_controller.fetch(page: 1)); + break; + case ProductDetailDialogAction.updated: + case ProductDetailDialogAction.deleted: + case ProductDetailDialogAction.restored: + final currentPage = _controller.result?.page ?? 1; + unawaited(_controller.fetch(page: currentPage)); + break; } } } - Future _restoreProduct(Product product) async { - if (product.id == null) return; - final restored = await _controller.restore(product.id!); - if (restored != null && mounted) { - _showSnack('제품을 복구했습니다.'); + Future _openProductDetailDialog(Product product) async { + final productId = product.id; + if (productId == null) { + _showSnack('ID 정보가 없어 상세를 열 수 없습니다.'); + return; + } + + final result = await showProductDetailDialog( + context: context, + dateFormat: _dateFormat, + product: product, + vendorOptions: _controller.vendorOptions, + uomOptions: _controller.uomOptions, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + ); + + if (result != null && mounted) { + _showSnack(result.message); + unawaited(_controller.fetch(page: _controller.result?.page ?? 1)); } } @@ -886,27 +574,18 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { } messenger.showSnackBar(SnackBar(content: Text(message))); } - - String _formatDateTime(DateTime? value) { - if (value == null) return '-'; - return _dateFormat.format(value.toLocal()); - } } class _ProductTable extends StatelessWidget { const _ProductTable({ required this.products, required this.dateFormat, - required this.onEdit, - required this.onDelete, - required this.onRestore, + required this.onProductTap, }); final List products; - final DateFormat dateFormat; - final void Function(Product product)? onEdit; - final void Function(Product product)? onDelete; - final void Function(Product product)? onRestore; + final intl.DateFormat dateFormat; + final void Function(Product product)? onProductTap; @override Widget build(BuildContext context) { @@ -920,88 +599,41 @@ class _ProductTable extends StatelessWidget { Text('삭제'), Text('비고'), Text('변경일시'), - Text('동작'), ]; - final rows = products.map((product) { - final cells = [ - Text(product.id?.toString() ?? '-'), - Text(product.productCode), - Text(product.productName), - Text(product.vendor?.vendorName ?? '-'), - Text(product.uom?.uomName ?? '-'), - Text(product.isActive ? 'Y' : 'N'), - Text(product.isDeleted ? 'Y' : '-'), - Text(product.note?.isEmpty ?? true ? '-' : product.note!), - Text( - product.updatedAt == null - ? '-' - : dateFormat.format(product.updatedAt!.toLocal()), - ), - ]; - - cells.add( - ShadTableCell( - alignment: Alignment.centerRight, - child: Wrap( - spacing: 8, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(product), - child: const Icon(LucideIcons.pencil, size: 16), - ), - product.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(product), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(product), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], - ), - ), - ); - - return cells; - }).toList(); - return SuperportTable( columns: columns, - rows: rows, + rows: products + .map( + (product) => [ + Text(product.id?.toString() ?? '-'), + Text(product.productCode), + Text(product.productName), + Text(product.vendor?.vendorName ?? '-'), + Text(product.uom?.uomName ?? '-'), + Text(product.isActive ? 'Y' : 'N'), + Text(product.isDeleted ? 'Y' : '-'), + Text(product.note?.isEmpty ?? true ? '-' : product.note!), + Text( + product.updatedAt == null + ? '-' + : dateFormat.format(product.updatedAt!.toLocal()), + ), + ], + ) + .toList(), rowHeight: 56, maxHeight: 520, - columnSpanExtent: (index) => index == 9 - ? const FixedTableSpanExtent(160) - : const FixedTableSpanExtent(140), - ); - } -} - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.small), - const SizedBox(height: 6), - child, - ], + onRowTap: onProductTap == null + ? null + : (index) => onProductTap!(products[index]), + columnSpanExtent: (index) => switch (index) { + 2 => const FixedTableSpanExtent(200), + 3 => const FixedTableSpanExtent(180), + 7 => const FixedTableSpanExtent(220), + 8 => const FixedTableSpanExtent(160), + _ => const FixedTableSpanExtent(120), + }, ); } } diff --git a/lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart b/lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart new file mode 100644 index 0000000..f5d2faf --- /dev/null +++ b/lib/features/masters/user/presentation/dialogs/user_detail_dialog.dart @@ -0,0 +1,1101 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../core/validation/password_rules.dart'; +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../../../../widgets/components/superport_dialog.dart'; +import '../../../group/domain/entities/group.dart'; +import '../../domain/entities/user.dart'; + +/// 사용자 상세 다이얼로그에서 발생 가능한 액션 종류이다. +enum UserDetailDialogAction { + created, + updated, + deleted, + restored, + passwordReset, +} + +/// 사용자 상세 다이얼로그가 반환하는 결과 모델이다. +class UserDetailDialogResult { + const UserDetailDialogResult({ + required this.action, + required this.message, + this.user, + }); + + final UserDetailDialogAction action; + final String message; + + /// 최신 사용자 엔티티. 삭제/비밀번호 초기화 시 null일 수 있다. + final UserAccount? user; +} + +typedef UserCreateCallback = Future Function(UserInput input); +typedef UserUpdateCallback = Future Function( + int id, + UserInput input, +); +typedef UserDeleteCallback = Future Function(int id); +typedef UserRestoreCallback = Future Function(int id); +typedef UserResetPasswordCallback = Future Function(int id); + +/// 사용자 상세 다이얼로그를 노출한다. +Future showUserDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + required List groupOptions, + UserAccount? user, + required UserCreateCallback onCreate, + required UserUpdateCallback onUpdate, + required UserDeleteCallback onDelete, + required UserRestoreCallback onRestore, + required UserResetPasswordCallback onResetPassword, +}) { + final isDetail = user != null; + final detailUser = user; + + Future handleCreate(UserInput input) async { + final created = await onCreate(input); + if (created == null) { + return null; + } + return UserDetailDialogResult( + action: UserDetailDialogAction.created, + message: '사용자를 등록했습니다.', + user: created, + ); + } + + Future handleUpdate(UserInput input) async { + final userId = user!.id; + if (userId == null) { + return null; + } + final updated = await onUpdate(userId, input); + if (updated == null) { + return null; + } + return UserDetailDialogResult( + action: UserDetailDialogAction.updated, + message: '사용자를 수정했습니다.', + user: updated, + ); + } + + Future handleDelete() async { + final current = detailUser; + final userId = current?.id; + if (userId == null) { + return null; + } + final success = await onDelete(userId); + if (!success) { + return null; + } + return const UserDetailDialogResult( + action: UserDetailDialogAction.deleted, + message: '사용자를 삭제했습니다.', + ); + } + + Future handleRestore() async { + final current = detailUser; + final userId = current?.id; + if (userId == null) { + return null; + } + final restored = await onRestore(userId); + if (restored == null) { + return null; + } + return UserDetailDialogResult( + action: UserDetailDialogAction.restored, + message: '사용자를 복구했습니다.', + user: restored, + ); + } + + Future handleResetPassword() async { + final current = detailUser; + final userId = current?.id; + if (userId == null) { + return null; + } + final updated = await onResetPassword(userId); + if (updated == null) { + return null; + } + return const UserDetailDialogResult( + action: UserDetailDialogAction.passwordReset, + message: '임시 비밀번호를 이메일로 발송했습니다.', + ); + } + + final sections = [ + if (isDetail && detailUser != null) + SuperportDetailDialogSection( + id: _UserDetailSections.overview, + label: '상세', + icon: LucideIcons.info, + builder: (_) => _UserOverviewSection( + user: detailUser, + dateFormat: dateFormat, + ), + ), + if (isDetail) + SuperportDetailDialogSection( + id: _UserDetailSections.activity, + label: '최근 활동', + icon: LucideIcons.history, + builder: (_) => const _UserActivitySection(), + ), + if (isDetail && detailUser != null) + _UserSecuritySection( + user: detailUser, + dateFormat: dateFormat, + onResetPassword: handleResetPassword, + ), + _UserFormSection( + id: isDetail ? _UserDetailSections.edit : _UserDetailSections.create, + label: isDetail ? '수정' : '등록', + user: detailUser, + groupOptions: groupOptions, + onSubmit: isDetail ? handleUpdate : handleCreate, + ), + if (isDetail && detailUser != null) + _UserDangerSection( + user: detailUser, + onDelete: handleDelete, + onRestore: handleRestore, + ), + ]; + + final summary = detailUser == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + detailUser.employeeName, + style: ShadTheme.of(context).textTheme.h4, + ), + const SizedBox(height: 4), + Text( + '사번 ${detailUser.employeeNo}', + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ); + + final summaryBadges = detailUser == null + ? const [] + : [ + if (detailUser.isActive) + const ShadBadge(child: Text('사용중')) + else + const ShadBadge.outline(child: Text('미사용')), + if (detailUser.isDeleted) + const ShadBadge.destructive(child: Text('삭제됨')), + if (detailUser.forcePasswordChange) + const ShadBadge.outline(child: Text('비밀번호 변경 필요')), + ]; + + final metadata = detailUser == null + ? const [] + : [ + SuperportDetailMetadata.text( + label: 'ID', + value: '${detailUser.id ?? '-'}', + ), + SuperportDetailMetadata.text( + label: '그룹', + value: detailUser.group?.groupName ?? '-', + ), + SuperportDetailMetadata.text( + label: '이메일', + value: detailUser.email?.isEmpty ?? true + ? '-' + : detailUser.email!, + ), + SuperportDetailMetadata.text( + label: '연락처', + value: detailUser.mobileNo?.isEmpty ?? true + ? '-' + : detailUser.mobileNo!, + ), + SuperportDetailMetadata.text( + label: '비고', + value: detailUser.note?.isEmpty ?? true + ? '-' + : detailUser.note!, + ), + SuperportDetailMetadata.text( + label: '비밀번호 변경일시', + value: detailUser.passwordUpdatedAt == null + ? '-' + : dateFormat.format( + detailUser.passwordUpdatedAt!.toLocal(), + ), + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: detailUser.createdAt == null + ? '-' + : dateFormat.format(detailUser.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '수정일시', + value: detailUser.updatedAt == null + ? '-' + : dateFormat.format(detailUser.updatedAt!.toLocal()), + ), + ]; + + return showSuperportDetailDialog( + context: context, + title: isDetail ? '사용자 상세' : '사용자 등록', + description: isDetail + ? '사용자 기본 정보와 권한 상태를 확인하고 관리합니다.' + : '새 사용자 계정을 등록합니다.', + sections: sections, + summary: summary, + summaryBadges: summaryBadges, + metadata: metadata, + emptyPlaceholder: const Text('표시할 사용자 정보가 없습니다.'), + initialSectionId: isDetail + ? _UserDetailSections.overview + : _UserDetailSections.create, + ); +} + +/// 사용자 상세 다이얼로그 섹션 ID 모음이다. +class _UserDetailSections { + static const overview = 'overview'; + static const activity = 'activity'; + static const security = 'security'; + static const edit = 'edit'; + static const delete = 'delete'; + static const restore = 'restore'; + static const create = 'create'; +} + +/// 사용자 주요 정보를 표시하는 섹션이다. +class _UserOverviewSection extends StatelessWidget { + const _UserOverviewSection({ + required this.user, + required this.dateFormat, + }); + + final UserAccount user; + final intl.DateFormat dateFormat; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final rows = <_KeyValueRow>[ + _KeyValueRow('사번', user.employeeNo), + _KeyValueRow('이메일', user.email?.isEmpty ?? true ? '-' : user.email!), + _KeyValueRow( + '연락처', + user.mobileNo?.isEmpty ?? true ? '-' : user.mobileNo!, + ), + _KeyValueRow('그룹', user.group?.groupName ?? '-'), + _KeyValueRow('사용 여부', user.isActive ? 'Y' : 'N'), + _KeyValueRow('삭제 여부', user.isDeleted ? 'Y' : 'N'), + _KeyValueRow('비고', user.note?.isEmpty ?? true ? '-' : user.note!), + _KeyValueRow( + '생성일시', + user.createdAt == null + ? '-' + : dateFormat.format(user.createdAt!.toLocal()), + ), + _KeyValueRow( + '수정일시', + user.updatedAt == null + ? '-' + : dateFormat.format(user.updatedAt!.toLocal()), + ), + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < rows.length; i++) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 140, + child: Text( + rows[i].label, + style: theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + rows[i].value, + style: theme.textTheme.small, + ), + ), + ], + ), + if (i < rows.length - 1) const SizedBox(height: 12), + ], + ], + ); + } +} + +/// 사용자 최근 활동 섹션. 실제 데이터가 준비되기 전까지 가이드 문구를 제공한다. +class _UserActivitySection extends StatelessWidget { + const _UserActivitySection(); + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('최근 활동 데이터는 준비 중입니다.', style: theme.textTheme.small), + const SizedBox(height: 8), + Text( + '결재/입출고 이력과 같은 사용자 활동은 추후 통합 대시보드에서 확인할 수 있도록 연동될 예정입니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ); + } +} + +/// 사용자 비밀번호 상태 및 재설정을 포함한 섹션이다. +class _UserSecuritySection extends SuperportDetailDialogSection { + _UserSecuritySection({ + required this.user, + required this.dateFormat, + required this.onResetPassword, + }) : super( + id: _UserDetailSections.security, + label: '보안', + icon: LucideIcons.shieldCheck, + builder: (context) => _UserSecurityContent( + user: user, + dateFormat: dateFormat, + onResetPassword: onResetPassword, + ), + ); + + final UserAccount user; + final intl.DateFormat dateFormat; + final Future Function() onResetPassword; +} + +class _UserSecurityContent extends StatefulWidget { + const _UserSecurityContent({ + required this.user, + required this.dateFormat, + required this.onResetPassword, + }); + + final UserAccount user; + final intl.DateFormat dateFormat; + final Future Function() onResetPassword; + + @override + State<_UserSecurityContent> createState() => _UserSecurityContentState(); +} + +class _UserSecurityContentState extends State<_UserSecurityContent> { + bool _isProcessing = false; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _KeyValueColumn( + label: '비밀번호 변경일시', + value: widget.user.passwordUpdatedAt == null + ? '-' + : widget.dateFormat + .format(widget.user.passwordUpdatedAt!.toLocal()), + ), + const SizedBox(height: 12), + _KeyValueColumn( + label: '비밀번호 변경 필요', + value: widget.user.forcePasswordChange ? '예' : '아니오', + ), + const SizedBox(height: 16), + Text( + '임시 비밀번호를 발급하면 기존 비밀번호는 즉시 무효화되고, 사용자에게 이메일이 발송됩니다.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: ShadButton( + onPressed: _isProcessing ? null : _handleResetPassword, + child: _isProcessing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('비밀번호 재설정'), + ), + ), + ], + ); + } + + Future _handleResetPassword() async { + final confirmed = await SuperportDialog.show( + context: context, + dialog: SuperportDialog( + title: '비밀번호 재설정', + description: + '"${widget.user.employeeName}" 사용자의 비밀번호를 재설정하고 임시 비밀번호를 이메일로 발송합니다.', + actions: [ + ShadButton.ghost( + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), + child: const Text('취소'), + ), + ShadButton( + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), + child: const Text('재설정'), + ), + ], + ), + ); + + if (!mounted || confirmed != true) { + return; + } + + setState(() { + _isProcessing = true; + }); + final navigator = Navigator.of(context, rootNavigator: true); + final result = await widget.onResetPassword(); + if (!mounted) { + return; + } + setState(() { + _isProcessing = false; + }); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } +} + +/// 사용자 등록/수정 폼을 제공하는 섹션이다. +class _UserFormSection extends SuperportDetailDialogSection { + _UserFormSection({ + required super.id, + required super.label, + required this.user, + required this.groupOptions, + required this.onSubmit, + }) : super( + icon: LucideIcons.pencil, + builder: (context) => _UserForm( + user: user, + groupOptions: groupOptions, + onSubmit: onSubmit, + ), + ); + + final UserAccount? user; + final List groupOptions; + final Future Function(UserInput input) onSubmit; +} + +class _UserForm extends StatefulWidget { + const _UserForm({ + required this.user, + required this.groupOptions, + required this.onSubmit, + }); + + final UserAccount? user; + final List groupOptions; + final Future Function(UserInput input) onSubmit; + + @override + State<_UserForm> createState() => _UserFormState(); +} + +class _UserFormState extends State<_UserForm> { + late final TextEditingController _employeeController; + late final TextEditingController _nameController; + late final TextEditingController _emailController; + late final TextEditingController _phoneController; + late final TextEditingController _passwordController; + late final TextEditingController _noteController; + late final ValueNotifier _groupIdNotifier; + late final ValueNotifier _isActiveNotifier; + String? _employeeError; + String? _nameError; + String? _emailError; + String? _phoneError; + String? _groupError; + String? _passwordError; + bool _isSubmitting = false; + + bool get _isEdit => widget.user != null; + + @override + void initState() { + super.initState(); + final user = widget.user; + _employeeController = TextEditingController(text: user?.employeeNo ?? ''); + _nameController = TextEditingController(text: user?.employeeName ?? ''); + _emailController = TextEditingController(text: user?.email ?? ''); + _phoneController = TextEditingController(text: user?.mobileNo ?? ''); + _passwordController = TextEditingController(); + _noteController = TextEditingController(text: user?.note ?? ''); + _groupIdNotifier = ValueNotifier(user?.group?.id); + _isActiveNotifier = ValueNotifier(user?.isActive ?? true); + + if (_groupIdNotifier.value == null && + widget.groupOptions.length == 1) { + _groupIdNotifier.value = widget.groupOptions.first.id; + } + } + + @override + void dispose() { + _employeeController.dispose(); + _nameController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + _passwordController.dispose(); + _noteController.dispose(); + _groupIdNotifier.dispose(); + _isActiveNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final materialTheme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _FormField( + label: '사번', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + key: const ValueKey('user_form_employee'), + controller: _employeeController, + readOnly: _isEdit, + onChanged: (_) { + if (_employeeController.text.trim().isNotEmpty) { + setState(() => _employeeError = null); + } + }, + ), + if (_employeeError != null) + _ErrorText(_employeeError!), + ], + ), + ), + const SizedBox(height: 16), + _FormField( + label: '성명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + key: const ValueKey('user_form_name'), + controller: _nameController, + onChanged: (_) { + if (_nameController.text.trim().isNotEmpty) { + setState(() => _nameError = null); + } + }, + ), + if (_nameError != null) _ErrorText(_nameError!), + ], + ), + ), + if (!_isEdit) ...[ + const SizedBox(height: 16), + _FormField( + label: '임시 비밀번호', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + key: const ValueKey('user_form_password'), + controller: _passwordController, + obscureText: true, + obscuringCharacter: '●', + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + isDense: true, + constraints: const BoxConstraints( + minHeight: 44, + maxHeight: 44, + ), + ), + style: materialTheme.textTheme.bodyMedium, + onChanged: (_) { + setState(() { + _passwordError = null; + }); + }, + ), + const SizedBox(height: 6), + Text( + '8~24자, 대소문자/숫자/특수문자 각 1자 이상 포함', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + if (_passwordError != null) _ErrorText(_passwordError!), + ], + ), + ), + ], + const SizedBox(height: 16), + _FormField( + label: '이메일', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + key: const ValueKey('user_form_email'), + controller: _emailController, + keyboardType: TextInputType.emailAddress, + onChanged: (_) { + if (_emailController.text.trim().isNotEmpty) { + setState(() => _emailError = null); + } + }, + ), + if (_emailError != null) _ErrorText(_emailError!), + ], + ), + ), + const SizedBox(height: 16), + _FormField( + label: '연락처', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + key: const ValueKey('user_form_phone'), + controller: _phoneController, + keyboardType: TextInputType.phone, + onChanged: (_) { + if (_phoneController.text.trim().isNotEmpty) { + setState(() => _phoneError = null); + } + }, + ), + if (_phoneError != null) _ErrorText(_phoneError!), + ], + ), + ), + const SizedBox(height: 16), + _FormField( + label: '그룹', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ValueListenableBuilder( + valueListenable: _groupIdNotifier, + builder: (_, value, __) { + return ShadSelect( + key: const ValueKey('user_form_group_select'), + initialValue: value, + placeholder: const Text('그룹을 선택하세요'), + selectedOptionBuilder: (context, selected) { + if (selected == null) { + return const Text('그룹을 선택하세요'); + } + final group = widget.groupOptions.firstWhere( + (g) => g.id == selected, + orElse: () => Group(id: selected, groupName: ''), + ); + return Text(group.groupName); + }, + onChanged: _isSubmitting + ? null + : (next) { + _groupIdNotifier.value = next; + setState(() => _groupError = null); + }, + options: widget.groupOptions + .map( + (group) => ShadOption( + value: group.id, + child: Text(group.groupName), + ), + ) + .toList(), + ); + }, + ), + if (_groupError != null) _ErrorText(_groupError!), + ], + ), + ), + const SizedBox(height: 16), + _FormField( + label: '사용 여부', + child: ValueListenableBuilder( + valueListenable: _isActiveNotifier, + builder: (_, value, __) { + return ShadSwitch( + value: value, + onChanged: _isSubmitting + ? null + : (next) => _isActiveNotifier.value = next, + ); + }, + ), + ), + const SizedBox(height: 16), + _FormField( + label: '비고', + child: ShadTextarea( + controller: _noteController, + minHeight: 96, + maxHeight: 200, + ), + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isEdit ? '저장' : '등록'), + ), + ), + ], + ); + } + + Future _handleSubmit() async { + final employeeNo = _employeeController.text.trim(); + final name = _nameController.text.trim(); + final email = _emailController.text.trim(); + final phone = _phoneController.text.trim(); + final note = _noteController.text.trim(); + final groupId = _groupIdNotifier.value; + + setState(() { + _employeeError = employeeNo.isEmpty ? '사번을 입력하세요.' : null; + _nameError = name.isEmpty ? '성명을 입력하세요.' : null; + _emailError = email.isEmpty ? '이메일을 입력하세요.' : null; + _phoneError = phone.isEmpty ? '연락처를 입력하세요.' : null; + _groupError = groupId == null ? '그룹을 선택하세요.' : null; + if (!_isEdit) { + final password = _passwordController.text; + if (password.isEmpty) { + _passwordError = '임시 비밀번호를 입력하세요.'; + } else { + final violations = PasswordRules.validate(password); + _passwordError = violations.isEmpty + ? null + : _describePasswordViolations(violations); + } + } else { + _passwordError = null; + } + }); + + if (_employeeError != null || + _nameError != null || + _emailError != null || + _phoneError != null || + _groupError != null || + _passwordError != null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + final navigator = Navigator.of(context, rootNavigator: true); + final input = UserInput( + employeeNo: employeeNo, + employeeName: name, + groupId: groupId!, + email: email.isEmpty ? null : email, + mobileNo: phone.isEmpty ? null : phone, + isActive: _isActiveNotifier.value, + password: _isEdit ? null : _passwordController.text, + forcePasswordChange: _isEdit ? null : true, + note: note.isEmpty ? null : note, + ); + + final result = await widget.onSubmit(input); + if (!mounted) { + return; + } + setState(() { + _isSubmitting = false; + }); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } + + String _describePasswordViolations(List violations) { + final messages = []; + for (final violation in violations) { + switch (violation) { + case PasswordRuleViolation.tooShort: + messages.add('최소 8자 이상 입력해야 합니다.'); + break; + case PasswordRuleViolation.tooLong: + messages.add('최대 24자 이하로 입력해야 합니다.'); + break; + case PasswordRuleViolation.missingUppercase: + messages.add('대문자를 최소 1자 포함해야 합니다.'); + break; + case PasswordRuleViolation.missingLowercase: + messages.add('소문자를 최소 1자 포함해야 합니다.'); + break; + case PasswordRuleViolation.missingDigit: + messages.add('숫자를 최소 1자 포함해야 합니다.'); + break; + case PasswordRuleViolation.missingSpecial: + messages.add('특수문자를 최소 1자 포함해야 합니다.'); + break; + } + } + return messages.join('\n'); + } +} + +/// 사용자 삭제/복구를 안내하는 위험 섹션이다. +class _UserDangerSection extends SuperportDetailDialogSection { + _UserDangerSection({ + required this.user, + required this.onDelete, + required this.onRestore, + }) : super( + id: user.isDeleted + ? _UserDetailSections.restore + : _UserDetailSections.delete, + label: user.isDeleted ? '복구' : '삭제', + icon: user.isDeleted ? LucideIcons.history : LucideIcons.trash2, + builder: (context) => _UserDangerContent( + user: user, + onDelete: onDelete, + onRestore: onRestore, + ), + scrollable: false, + ); + + final UserAccount user; + final Future Function() onDelete; + final Future Function() onRestore; +} + +class _UserDangerContent extends StatefulWidget { + const _UserDangerContent({ + required this.user, + required this.onDelete, + required this.onRestore, + }); + + final UserAccount user; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + State<_UserDangerContent> createState() => _UserDangerContentState(); +} + +class _UserDangerContentState extends State<_UserDangerContent> { + bool _isProcessing = false; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final isDeleted = widget.user.isDeleted; + final description = isDeleted + ? '사용자를 복구하면 다시 목록에 노출되고 서비스 로그인에 사용할 수 있습니다.' + : '삭제하면 사용자가 목록에서 숨겨지고 신규 로그인 시도가 차단됩니다.'; + final actionLabel = isDeleted ? '복구' : '삭제'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + Align( + alignment: Alignment.centerLeft, + child: isDeleted + ? ShadButton( + onPressed: _isProcessing ? null : _handleRestore, + child: _isProcessing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(actionLabel), + ) + : ShadButton.destructive( + onPressed: _isProcessing ? null : _handleDelete, + child: _isProcessing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(actionLabel), + ), + ), + ], + ); + } + + Future _handleDelete() async { + final confirmed = await SuperportDialog.show( + context: context, + dialog: SuperportDialog( + title: '사용자 삭제', + description: '"${widget.user.employeeName}" 사용자를 삭제하시겠습니까?', + actions: [ + ShadButton.ghost( + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(false), + child: const Text('취소'), + ), + ShadButton.destructive( + onPressed: () => + Navigator.of(context, rootNavigator: true).pop(true), + child: const Text('삭제'), + ), + ], + ), + ); + + if (confirmed != true) { + return; + } + + await _runDangerAction(widget.onDelete); + } + + Future _handleRestore() async { + await _runDangerAction(widget.onRestore); + } + + Future _runDangerAction( + Future Function() action, + ) async { + setState(() { + _isProcessing = true; + }); + final navigator = Navigator.of(context, rootNavigator: true); + final result = await action(); + if (!mounted) { + return; + } + setState(() { + _isProcessing = false; + }); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } +} + +class _FormField extends StatelessWidget { + const _FormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +class _ErrorText extends StatelessWidget { + const _ErrorText(this.message); + + final String message; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + message, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ); + } +} + +class _KeyValueRow { + const _KeyValueRow(this.label, this.value); + + final String label; + final String value; +} + +class _KeyValueColumn extends StatelessWidget { + const _KeyValueColumn({required this.label, required this.value}); + + final String label; + final String value; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.small), + ], + ); + } +} diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart index f6b7278..2f24610 100644 --- a/lib/features/masters/user/presentation/pages/user_page.dart +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -1,16 +1,16 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; -import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; +import 'package:superport_v2/widgets/components/superport_table.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/permissions/permission_manager.dart'; -import '../../../../../core/validation/password_rules.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../group/domain/entities/group.dart'; import '../../../group/domain/repositories/group_repository.dart'; @@ -18,6 +18,7 @@ import '../../../group_permission/domain/repositories/group_permission_repositor import '../../domain/entities/user.dart'; import '../../domain/repositories/user_repository.dart'; import '../controllers/user_controller.dart'; +import '../dialogs/user_detail_dialog.dart'; /// 사용자 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 보여준다. class UserPage extends StatelessWidget { @@ -101,6 +102,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { bool _groupsLoaded = false; String? _lastError; bool _initialized = false; + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); @override void initState() { @@ -185,7 +187,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { leading: const Icon(LucideIcons.plus, size: 16), onPressed: _controller.isSubmitting ? null - : () => _openUserForm(context), + : _openUserCreateDialog, child: const Text('신규 등록'), ), ], @@ -314,14 +316,10 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { ) : _UserTable( users: users, - onEdit: _controller.isSubmitting + dateFormat: _dateFormat, + onUserTap: _controller.isSubmitting ? null - : (user) => _openUserForm(context, user: user), - onDelete: _controller.isSubmitting ? null : _confirmDelete, - onRestore: _controller.isSubmitting ? null : _restoreUser, - onResetPassword: _controller.isSubmitting - ? null - : _confirmResetPassword, + : _openUserDetailDialog, ), ), ); @@ -345,515 +343,53 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { } } - Future _openUserForm(BuildContext context, {UserAccount? user}) async { - final existing = user; - final isEdit = existing != null; - final userId = existing?.id; - if (isEdit && userId == null) { - _showSnack('ID 정보가 없어 수정할 수 없습니다.'); - return; - } - + Future _openUserCreateDialog() async { if (!_groupsLoaded) { _showSnack('그룹 정보를 불러오는 중입니다. 잠시 후 다시 시도하세요.'); return; } - final parentContext = context; - - final codeController = TextEditingController( - text: existing?.employeeNo ?? '', - ); - final nameController = TextEditingController( - text: existing?.employeeName ?? '', - ); - final emailController = TextEditingController(text: existing?.email ?? ''); - final mobileController = TextEditingController( - text: existing?.mobileNo ?? '', - ); - final passwordController = TextEditingController(); - final noteController = TextEditingController(text: existing?.note ?? ''); - final groupNotifier = ValueNotifier(existing?.group?.id); - final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); - final saving = ValueNotifier(false); - final codeError = ValueNotifier(null); - final nameError = ValueNotifier(null); - final emailError = ValueNotifier(null); - final phoneError = ValueNotifier(null); - final groupError = ValueNotifier(null); - final passwordError = ValueNotifier(null); - - if (groupNotifier.value == null && _controller.groups.length == 1) { - groupNotifier.value = _controller.groups.first.id; - } - - await SuperportDialog.show( - context: parentContext, - dialog: SuperportDialog( - title: isEdit ? '사용자 수정' : '사용자 등록', - description: '사용자 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', - primaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final email = emailController.text.trim(); - final mobile = mobileController.text.trim(); - final note = noteController.text.trim(); - final groupId = groupNotifier.value; - - if (!isEdit) { - final password = passwordController.text; - if (password.isEmpty) { - passwordError.value = '임시 비밀번호를 입력하세요.'; - } else { - final violations = PasswordRules.validate(password); - passwordError.value = violations.isEmpty - ? null - : _describePasswordViolations(violations); - } - } - - codeError.value = code.isEmpty ? '사번을 입력하세요.' : null; - nameError.value = name.isEmpty ? '성명을 입력하세요.' : null; - emailError.value = email.isEmpty ? '이메일을 입력하세요.' : null; - phoneError.value = mobile.isEmpty ? '연락처를 입력하세요.' : null; - groupError.value = groupId == null ? '그룹을 선택하세요.' : null; - - if (codeError.value != null || - nameError.value != null || - emailError.value != null || - phoneError.value != null || - groupError.value != null || - (!isEdit && passwordError.value != null)) { - return; - } - - saving.value = true; - final navigator = Navigator.of( - context, - rootNavigator: true, - ); - final input = UserInput( - employeeNo: code, - employeeName: name, - groupId: groupId!, - email: email.isEmpty ? null : email, - mobileNo: mobile.isEmpty ? null : mobile, - isActive: isActiveNotifier.value, - password: isEdit ? null : passwordController.text, - forcePasswordChange: isEdit ? null : true, - note: note.isEmpty ? null : note, - ); - final response = isEdit - ? await _controller.update(userId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null) { - if (!navigator.mounted) { - return; - } - if (mounted) { - _showSnack(isEdit ? '사용자를 수정했습니다.' : '사용자를 등록했습니다.'); - } - navigator.pop(true); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ); - }, - ), - secondaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - final navigator = Navigator.of(context, rootNavigator: true); - return ShadButton.ghost( - onPressed: isSaving ? null : () => navigator.pop(false), - child: const Text('취소'), - ); - }, - ), - child: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - final theme = ShadTheme.of(context); - final materialTheme = Theme.of(context); - - return SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '사번', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - key: const ValueKey('user_form_employee'), - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '성명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - key: const ValueKey('user_form_name'), - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - if (!isEdit) ...[ - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: passwordError, - builder: (_, errorText, __) { - return _FormField( - label: '임시 비밀번호', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - key: const ValueKey('user_form_password'), - controller: passwordController, - obscureText: true, - placeholder: const Text('임시 비밀번호를 입력하세요'), - onChanged: (_) { - final value = passwordController.text; - if (value.isEmpty) { - passwordError.value = null; - return; - } - if (PasswordRules.isValid(value)) { - passwordError.value = null; - } - }, - ), - const SizedBox(height: 6), - Text( - '비밀번호는 8~24자이며 대문자, 소문자, 숫자, 특수문자를 각각 1자 이상 포함해야 합니다.', - style: theme.textTheme.muted, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - ], - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: emailError, - builder: (_, errorText, __) { - return _FormField( - label: '이메일', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - key: const ValueKey('user_form_email'), - controller: emailController, - keyboardType: TextInputType.emailAddress, - onChanged: (_) { - if (emailController.text.trim().isNotEmpty) { - emailError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: phoneError, - builder: (_, errorText, __) { - return _FormField( - label: '연락처', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - key: const ValueKey('user_form_phone'), - controller: mobileController, - keyboardType: TextInputType.phone, - onChanged: (_) { - if (mobileController.text.trim().isNotEmpty) { - phoneError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: groupNotifier, - builder: (_, value, __) { - return ValueListenableBuilder( - valueListenable: groupError, - builder: (_, errorText, __) { - return _FormField( - label: '그룹', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadSelect( - initialValue: value, - onChanged: isSaving - ? null - : (next) { - groupNotifier.value = next; - groupError.value = null; - }, - options: _controller.groups - .map( - (group) => ShadOption( - value: group.id, - child: Text(group.groupName), - ), - ) - .toList(), - placeholder: const Text('그룹을 선택하세요'), - selectedOptionBuilder: (context, selected) { - if (selected == null) { - return const Text('그룹을 선택하세요'); - } - final group = _controller.groups.firstWhere( - (g) => g.id == selected, - orElse: () => - Group(id: selected, groupName: ''), - ); - return Text(group.groupName); - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: isSaving - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (existing != null) ..._buildAuditInfo(existing, theme), - ], - ), - ); - }, - ), - ), - ); - - codeController.dispose(); - nameController.dispose(); - emailController.dispose(); - mobileController.dispose(); - passwordController.dispose(); - noteController.dispose(); - groupNotifier.dispose(); - isActiveNotifier.dispose(); - saving.dispose(); - codeError.dispose(); - nameError.dispose(); - emailError.dispose(); - phoneError.dispose(); - groupError.dispose(); - passwordError.dispose(); - } - - Future _confirmDelete(UserAccount user) async { - final confirmed = await SuperportDialog.show( + final result = await showUserDetailDialog( context: context, - dialog: SuperportDialog( - title: '사용자 삭제', - description: '"${user.employeeName}" 사용자를 삭제하시겠습니까?', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('삭제'), - ), - ], - ), + dateFormat: _dateFormat, + groupOptions: _controller.groups, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + onResetPassword: _controller.resetPassword, ); - if (confirmed == true && user.id != null) { - final success = await _controller.delete(user.id!); - if (success && mounted) { - _showSnack('사용자를 삭제했습니다.'); - } + if (result != null && mounted) { + _showSnack(result.message); } } - Future _restoreUser(UserAccount user) async { - if (user.id == null) return; - final restored = await _controller.restore(user.id!); - if (restored != null && mounted) { - _showSnack('사용자를 복구했습니다.'); - } - } - - Future _confirmResetPassword(UserAccount user) async { + Future _openUserDetailDialog(UserAccount user) async { final userId = user.id; if (userId == null) { - _showSnack('ID 정보가 없어 비밀번호를 재설정할 수 없습니다.'); + _showSnack('ID 정보가 없어 상세를 열 수 없습니다.'); return; } - final confirmed = await SuperportDialog.show( + if (!_groupsLoaded) { + _showSnack('그룹 정보를 불러오는 중입니다. 잠시 후 다시 시도하세요.'); + return; + } + + final result = await showUserDetailDialog( context: context, - dialog: SuperportDialog( - title: '비밀번호 재설정', - description: - '"${user.employeeName}" 사용자의 비밀번호를 재설정하고 임시 비밀번호를 이메일로 발송합니다.', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('재설정'), - ), - ], - ), + dateFormat: _dateFormat, + user: user, + groupOptions: _controller.groups, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + onResetPassword: _controller.resetPassword, ); - if (confirmed == true) { - final updated = await _controller.resetPassword(userId); - if (!mounted) { - return; - } - if (updated != null) { - _showSnack('임시 비밀번호를 이메일로 발송했습니다.'); - } else if (_controller.errorMessage != null) { - _showSnack(_controller.errorMessage!); - _controller.clearError(); - } + if (result != null && mounted) { + _showSnack(result.message); } } @@ -867,172 +403,76 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { } messenger.showSnackBar(SnackBar(content: Text(message))); } - - String _describePasswordViolations(List violations) { - final messages = []; - for (final violation in violations) { - switch (violation) { - case PasswordRuleViolation.tooShort: - messages.add('최소 8자 이상 입력해야 합니다.'); - break; - case PasswordRuleViolation.tooLong: - messages.add('최대 24자 이하로 입력해야 합니다.'); - break; - case PasswordRuleViolation.missingUppercase: - messages.add('대문자를 최소 1자 포함해야 합니다.'); - break; - case PasswordRuleViolation.missingLowercase: - messages.add('소문자를 최소 1자 포함해야 합니다.'); - break; - case PasswordRuleViolation.missingDigit: - messages.add('숫자를 최소 1자 포함해야 합니다.'); - break; - case PasswordRuleViolation.missingSpecial: - messages.add('특수문자를 최소 1자 포함해야 합니다.'); - break; - } - } - return messages.join('\n'); - } - - List _buildAuditInfo(UserAccount user, ShadThemeData theme) { - return [ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(user.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(user.updatedAt)}', - style: theme.textTheme.small, - ), - ]; - } - - String _formatDateTime(DateTime? value) { - if (value == null) return '-'; - return value.toLocal().toIso8601String(); - } } class _UserTable extends StatelessWidget { const _UserTable({ required this.users, - required this.onEdit, - required this.onDelete, - required this.onRestore, - required this.onResetPassword, + required this.dateFormat, + required this.onUserTap, }); final List users; - final void Function(UserAccount user)? onEdit; - final void Function(UserAccount user)? onDelete; - final void Function(UserAccount user)? onRestore; - final void Function(UserAccount user)? onResetPassword; + final intl.DateFormat dateFormat; + final void Function(UserAccount user)? onUserTap; @override Widget build(BuildContext context) { - final header = [ - 'ID', - '사번', - '성명', - '이메일', - '연락처', - '그룹', - '사용', - '삭제', - '비고', - '변경일시', - '동작', - ].map((text) => ShadTableCell.header(child: Text(text))).toList(); + final columns = const [ + Text('ID'), + Text('사번'), + Text('성명'), + Text('이메일'), + Text('연락처'), + Text('그룹'), + Text('사용'), + Text('삭제'), + Text('비고'), + Text('변경일시'), + ]; - final rows = users.map((user) { - return [ - user.id?.toString() ?? '-', - user.employeeNo, - user.employeeName, - user.email?.isEmpty ?? true ? '-' : user.email!, - user.mobileNo?.isEmpty ?? true ? '-' : user.mobileNo!, - user.group?.groupName ?? '-', - user.isActive ? 'Y' : 'N', - user.isDeleted ? 'Y' : '-', - user.note?.isEmpty ?? true ? '-' : user.note!, - user.updatedAt == null - ? '-' - : user.updatedAt!.toLocal().toIso8601String(), - ].map((text) => ShadTableCell(child: Text(text))).toList()..add( - ShadTableCell( - child: Align( - alignment: Alignment.centerRight, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onResetPassword == null - ? null - : () => onResetPassword!(user), - child: const Icon(LucideIcons.refreshCcw, size: 16), - ), - const SizedBox(width: 8), - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(user), - child: const Icon(LucideIcons.pencil, size: 16), - ), - const SizedBox(width: 8), - user.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(user), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(user), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], + final rows = users + .map( + (user) => [ + Text(user.id?.toString() ?? '-'), + Text(user.employeeNo), + Text(user.employeeName), + Text(user.email?.isEmpty ?? true ? '-' : user.email!), + Text(user.mobileNo?.isEmpty ?? true ? '-' : user.mobileNo!), + Text(user.group?.groupName ?? '-'), + Text(user.isActive ? 'Y' : 'N'), + Text(user.isDeleted ? 'Y' : '-'), + Text(user.note?.isEmpty ?? true ? '-' : user.note!), + Text( + user.updatedAt == null + ? '-' + : dateFormat.format(user.updatedAt!.toLocal()), ), - ), - ), - ); - }).toList(); + ], + ) + .toList(); - return SizedBox( - height: 56.0 * (users.length + 1), - child: ShadTable.list( - header: header, - children: rows, - columnSpanExtent: (index) => index == 10 - ? const FixedTableSpanExtent(200) - : const FixedTableSpanExtent(140), - ), - ); - } -} - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.small), - const SizedBox(height: 6), - child, - ], + return SuperportTable( + columns: columns, + rows: rows, + rowHeight: 56, + maxHeight: 56.0 * (users.length + 1), + onRowTap: onUserTap == null + ? null + : (index) { + if (index < 0 || index >= users.length) { + return; + } + onUserTap!(users[index]); + }, + columnSpanExtent: (index) => switch (index) { + 2 => const FixedTableSpanExtent(180), + 3 => const FixedTableSpanExtent(220), + 4 => const FixedTableSpanExtent(180), + 8 => const FixedTableSpanExtent(220), + 9 => const FixedTableSpanExtent(180), + _ => const FixedTableSpanExtent(140), + }, ); } } diff --git a/lib/features/masters/vendor/presentation/dialogs/vendor_detail_dialog.dart b/lib/features/masters/vendor/presentation/dialogs/vendor_detail_dialog.dart new file mode 100644 index 0000000..e7fb3ca --- /dev/null +++ b/lib/features/masters/vendor/presentation/dialogs/vendor_detail_dialog.dart @@ -0,0 +1,466 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../../vendor/domain/entities/vendor.dart'; + +/// 벤더 상세 다이얼로그에서 발생한 사용자 액션 종류이다. +enum VendorDetailDialogAction { created, updated, deleted, restored } + +/// 벤더 상세 다이얼로그 결과를 담는 모델이다. +class VendorDetailDialogResult { + const VendorDetailDialogResult({required this.action, required this.message}); + + final VendorDetailDialogAction action; + final String message; +} + +typedef VendorCreateCallback = Future Function(VendorInput input); +typedef VendorUpdateCallback = + Future Function(int id, VendorInput input); +typedef VendorDeleteCallback = Future Function(int id); +typedef VendorRestoreCallback = Future Function(int id); + +/// 벤더 상세 다이얼로그를 표시한다. +Future showVendorDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + Vendor? vendor, + required VendorCreateCallback onCreate, + required VendorUpdateCallback onUpdate, + required VendorDeleteCallback onDelete, + required VendorRestoreCallback onRestore, +}) { + final metadata = vendor == null + ? const [] + : [ + SuperportDetailMetadata.text( + label: 'ID', + value: vendor.id?.toString() ?? '-', + ), + SuperportDetailMetadata.text(label: '코드', value: vendor.vendorCode), + SuperportDetailMetadata.text( + label: '상태', + value: vendor.isActive ? '사용중' : '미사용', + ), + SuperportDetailMetadata.text( + label: '삭제 상태', + value: vendor.isDeleted ? '삭제됨' : '정상', + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: vendor.createdAt == null + ? '-' + : dateFormat.format(vendor.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '변경일시', + value: vendor.updatedAt == null + ? '-' + : dateFormat.format(vendor.updatedAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '비고', + value: vendor.note?.isEmpty ?? true ? '-' : vendor.note!, + ), + ]; + + return showSuperportDetailDialog( + context: context, + title: vendor == null ? '벤더 등록' : '벤더 상세', + description: vendor == null + ? '새로운 벤더 정보를 입력하세요.' + : '벤더 기본 정보와 상태를 확인하고 관리합니다.', + sections: [ + _VendorEditSection( + id: vendor == null + ? _VendorDetailSections.create + : _VendorDetailSections.edit, + label: vendor == null ? '등록' : '수정', + vendor: vendor, + onSubmit: (input) async { + if (vendor == null) { + final created = await onCreate(input); + if (created == null) { + return null; + } + return VendorDetailDialogResult( + action: VendorDetailDialogAction.created, + message: '벤더를 등록했습니다.', + ); + } + final updated = await onUpdate(vendor.id!, input); + if (updated == null) { + return null; + } + return VendorDetailDialogResult( + action: VendorDetailDialogAction.updated, + message: '벤더를 수정했습니다.', + ); + }, + ), + if (vendor != null) + SuperportDetailDialogSection( + id: vendor.isDeleted + ? _VendorDetailSections.restore + : _VendorDetailSections.delete, + label: vendor.isDeleted ? '복구' : '삭제', + icon: vendor.isDeleted ? LucideIcons.history : LucideIcons.trash2, + builder: (_) => _VendorDangerSection( + vendor: vendor, + onDelete: () async { + final success = await onDelete(vendor.id!); + if (!success) { + return null; + } + return VendorDetailDialogResult( + action: VendorDetailDialogAction.deleted, + message: '벤더를 삭제했습니다.', + ); + }, + onRestore: () async { + final restored = await onRestore(vendor.id!); + if (restored == null) { + return null; + } + return VendorDetailDialogResult( + action: VendorDetailDialogAction.restored, + message: '벤더를 복구했습니다.', + ); + }, + ), + ), + ], + summary: vendor == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + vendor.vendorName, + style: ShadTheme.of(context).textTheme.h4, + ), + ], + ), + summaryBadges: vendor == null + ? const [] + : [ + if (vendor.isActive) + const ShadBadge(child: Text('사용중')) + else + const ShadBadge.outline(child: Text('미사용')), + if (vendor.isDeleted) + const ShadBadge.destructive(child: Text('삭제됨')), + ], + metadata: metadata, + emptyPlaceholder: const Text('표시할 상세 정보가 없습니다.'), + initialSectionId: vendor == null + ? _VendorDetailSections.create + : _VendorDetailSections.edit, + ); +} + +/// 다이얼로그 섹션 ID 모음. +class _VendorDetailSections { + static const edit = 'edit'; + static const delete = 'delete'; + static const restore = 'restore'; + static const create = 'create'; +} + +/// 벤더 등록/수정 폼 섹션이다. +class _VendorEditSection extends SuperportDetailDialogSection { + _VendorEditSection({ + required super.id, + required super.label, + required this.vendor, + required this.onSubmit, + }) : super( + icon: LucideIcons.pencil, + builder: (context) => _VendorForm(vendor: vendor, onSubmit: onSubmit), + ); + + final Vendor? vendor; + final Future Function(VendorInput input) onSubmit; +} + +/// 삭제/복구를 안내하는 위험 섹션이다. +class _VendorDangerSection extends StatelessWidget { + const _VendorDangerSection({ + required this.vendor, + required this.onDelete, + required this.onRestore, + }); + + final Vendor vendor; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final isDeleted = vendor.isDeleted; + final description = isDeleted + ? '벤더를 복구하면 다시 목록에 노출되고 신규 트랜잭션에서 선택이 가능합니다.' + : '삭제하면 벤더가 목록에서 숨겨지고 관련 데이터는 보존됩니다.'; + final confirmLabel = isDeleted ? '복구' : '삭제'; + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + if (isDeleted) + ShadButton( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onRestore(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: Text(confirmLabel), + ) + else + ShadButton.destructive( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onDelete(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: Text(confirmLabel), + ), + ], + ), + ); + } +} + +/// 벤더 입력 폼을 담당하는 위젯이다. +class _VendorForm extends StatefulWidget { + const _VendorForm({required this.vendor, required this.onSubmit}); + + final Vendor? vendor; + final Future Function(VendorInput input) onSubmit; + + @override + State<_VendorForm> createState() => _VendorFormState(); +} + +class _VendorFormState extends State<_VendorForm> { + late final TextEditingController _codeController; + late final TextEditingController _nameController; + late final TextEditingController _noteController; + late final ValueNotifier _isActiveNotifier; + String? _codeError; + String? _nameError; + String? _submitError; + bool _isSubmitting = false; + + bool get _isEdit => widget.vendor != null; + + @override + void initState() { + super.initState(); + _codeController = TextEditingController( + text: widget.vendor?.vendorCode ?? '', + ); + _nameController = TextEditingController( + text: widget.vendor?.vendorName ?? '', + ); + _noteController = TextEditingController(text: widget.vendor?.note ?? ''); + _isActiveNotifier = ValueNotifier(widget.vendor?.isActive ?? true); + } + + @override + void dispose() { + _codeController.dispose(); + _nameController.dispose(); + _noteController.dispose(); + _isActiveNotifier.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _VendorFormField( + label: '벤더코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _codeController, + readOnly: _isEdit, + onChanged: (_) { + if (_codeController.text.trim().isNotEmpty) { + setState(() { + _codeError = null; + }); + } + }, + ), + if (_codeError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _codeError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _VendorFormField( + label: '벤더명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _nameController, + onChanged: (_) { + if (_nameController.text.trim().isNotEmpty) { + setState(() { + _nameError = null; + }); + } + }, + ), + if (_nameError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _nameError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _VendorFormField( + label: '사용 여부', + child: ValueListenableBuilder( + valueListenable: _isActiveNotifier, + builder: (_, value, __) { + return ShadSwitch( + value: value, + onChanged: _isSubmitting + ? null + : (next) => _isActiveNotifier.value = next, + ); + }, + ), + ), + const SizedBox(height: 16), + _VendorFormField( + label: '비고', + child: ShadTextarea( + controller: _noteController, + minHeight: 96, + maxHeight: 200, + ), + ), + if (_submitError != null) ...[ + const SizedBox(height: 16), + Text( + _submitError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isEdit ? '저장' : '등록'), + ), + ), + ], + ); + } + + Future _handleSubmit() async { + final code = _codeController.text.trim(); + final name = _nameController.text.trim(); + + setState(() { + _codeError = code.isEmpty ? '벤더코드를 입력하세요.' : null; + _nameError = name.isEmpty ? '벤더명을 입력하세요.' : null; + _submitError = null; + }); + + if (_codeError != null || _nameError != null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + final input = VendorInput( + vendorCode: code, + vendorName: name, + isActive: _isActiveNotifier.value, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + ); + final navigator = Navigator.of(context, rootNavigator: true); + final result = await widget.onSubmit(input); + + if (!mounted) { + return; + } + + setState(() { + _isSubmitting = false; + _submitError = result == null ? '요청 처리에 실패했습니다.' : null; + }); + + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } +} + +/// 벤더 폼 필드 레이블/컨텐츠 레이아웃을 제공한다. +class _VendorFormField extends StatelessWidget { + const _VendorFormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +/// 요약 섹션에서 사용할 단순 키-값 모델이다. diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index 1889468..d4acf95 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; -import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import 'package:superport_v2/widgets/components/superport_table.dart'; import 'package:superport_v2/widgets/components/responsive_section.dart'; @@ -15,6 +15,7 @@ import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../../vendor/domain/entities/vendor.dart'; import '../../../vendor/domain/repositories/vendor_repository.dart'; +import '../dialogs/vendor_detail_dialog.dart'; import '../controllers/vendor_controller.dart'; /// 벤더 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다. @@ -85,7 +86,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { late final VendorController _controller; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); - final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; String? _lastRouteSignature; @@ -165,7 +166,7 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { leading: const Icon(LucideIcons.plus, size: 16), onPressed: _controller.isSubmitting ? null - : () => _openVendorForm(context), + : _openVendorCreateDialog, child: const Text('신규 등록'), ), ], @@ -279,12 +280,10 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { ) : _VendorTable( vendors: vendors, - onEdit: _controller.isSubmitting - ? null - : (vendor) => _openVendorForm(context, vendor: vendor), - onDelete: _controller.isSubmitting ? null : _confirmDelete, - onRestore: _controller.isSubmitting ? null : _restoreVendor, dateFormat: _dateFormat, + onVendorTap: _controller.isSubmitting + ? null + : _openVendorDetailDialog, ), ), ); @@ -408,256 +407,37 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { } } - Future _openVendorForm(BuildContext context, {Vendor? vendor}) async { - final existingVendor = vendor; - final isEdit = existingVendor != null; - final vendorId = existingVendor?.id; - if (isEdit && vendorId == null) { - _showSnack('ID 정보가 없어 수정할 수 없습니다.'); + Future _openVendorCreateDialog() async { + final result = await showVendorDetailDialog( + context: context, + dateFormat: _dateFormat, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + ); + if (result != null && mounted) { + _showSnack(result.message); + } + } + + Future _openVendorDetailDialog(Vendor vendor) async { + final vendorId = vendor.id; + if (vendorId == null) { + _showSnack('ID 정보가 없어 상세를 열 수 없습니다.'); return; } - - final codeController = TextEditingController( - text: existingVendor?.vendorCode ?? '', - ); - final nameController = TextEditingController( - text: existingVendor?.vendorName ?? '', - ); - final noteController = TextEditingController( - text: existingVendor?.note ?? '', - ); - final isActiveNotifier = ValueNotifier( - existingVendor?.isActive ?? true, - ); - final saving = ValueNotifier(false); - final codeError = ValueNotifier(null); - final nameError = ValueNotifier(null); - - await SuperportDialog.show( + final result = await showVendorDetailDialog( context: context, - dialog: SuperportDialog( - title: isEdit ? '벤더 수정' : '벤더 등록', - description: '벤더 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', - constraints: const BoxConstraints(maxWidth: 520), - primaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final note = noteController.text.trim(); - - codeError.value = code.isEmpty ? '벤더코드를 입력하세요.' : null; - nameError.value = name.isEmpty ? '벤더명을 입력하세요.' : null; - - if (codeError.value != null || nameError.value != null) { - return; - } - - saving.value = true; - final input = VendorInput( - vendorCode: code, - vendorName: name, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final navigator = Navigator.of( - context, - rootNavigator: true, - ); - final response = isEdit - ? await _controller.update(vendorId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null && mounted) { - if (!navigator.mounted) { - return; - } - _showSnack(isEdit ? '벤더를 수정했습니다.' : '벤더를 등록했습니다.'); - navigator.pop(true); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ); - }, - ), - secondaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton.ghost( - onPressed: isSaving - ? null - : () => Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ); - }, - ), - child: Builder( - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - return Padding( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '벤더코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '벤더명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existingVendor.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existingVendor.updatedAt)}', - style: theme.textTheme.small, - ), - ], - ], - ), - ); - }, - ), - ), + dateFormat: _dateFormat, + vendor: vendor, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, ); - - codeController.dispose(); - nameController.dispose(); - noteController.dispose(); - isActiveNotifier.dispose(); - saving.dispose(); - codeError.dispose(); - nameError.dispose(); - } - - Future _confirmDelete(Vendor vendor) async { - final confirmed = await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '벤더 삭제', - description: '"${vendor.vendorName}" 벤더를 삭제하시겠습니까?', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('삭제'), - ), - ], - ), - ); - - if (confirmed == true && vendor.id != null) { - final success = await _controller.delete(vendor.id!); - if (success && mounted) { - _showSnack('벤더를 삭제했습니다.'); - } - } - } - - Future _restoreVendor(Vendor vendor) async { - if (vendor.id == null) return; - final restored = await _controller.restore(vendor.id!); - if (restored != null && mounted) { - _showSnack('벤더를 복구했습니다.'); + if (result != null && mounted) { + _showSnack(result.message); } } @@ -672,29 +452,18 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { } messenger.showSnackBar(SnackBar(content: Text(message))); } - - String _formatDateTime(DateTime? value) { - if (value == null) { - return '-'; - } - return _dateFormat.format(value.toLocal()); - } } class _VendorTable extends StatelessWidget { const _VendorTable({ required this.vendors, - required this.onEdit, - required this.onDelete, - required this.onRestore, required this.dateFormat, + required this.onVendorTap, }); final List vendors; - final void Function(Vendor vendor)? onEdit; - final void Function(Vendor vendor)? onDelete; - final void Function(Vendor vendor)? onRestore; - final DateFormat dateFormat; + final intl.DateFormat dateFormat; + final void Function(Vendor vendor)? onVendorTap; @override Widget build(BuildContext context) { @@ -706,86 +475,40 @@ class _VendorTable extends StatelessWidget { Text('삭제'), Text('비고'), Text('변경일시'), - Text('동작'), ]; - final rows = vendors.map((vendor) { - final cells = [ - Text(vendor.id?.toString() ?? '-'), - Text(vendor.vendorCode), - Text(vendor.vendorName), - Text(vendor.isActive ? 'Y' : 'N'), - Text(vendor.isDeleted ? 'Y' : '-'), - Text(vendor.note?.isEmpty ?? true ? '-' : vendor.note!), - Text( - vendor.updatedAt == null - ? '-' - : dateFormat.format(vendor.updatedAt!.toLocal()), - ), - ]; - - cells.add( - ShadTableCell( - alignment: Alignment.centerRight, - child: Wrap( - spacing: 8, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(vendor), - child: const Icon(LucideIcons.pencil, size: 16), - ), - vendor.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(vendor), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(vendor), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], - ), - ), - ); - - return cells; - }).toList(); + final rows = vendors + .map( + (vendor) => [ + Text(vendor.id?.toString() ?? '-'), + Text(vendor.vendorCode), + Text(vendor.vendorName), + Text(vendor.isActive ? 'Y' : 'N'), + Text(vendor.isDeleted ? 'Y' : '-'), + Text(vendor.note?.isEmpty ?? true ? '-' : vendor.note!), + Text( + vendor.updatedAt == null + ? '-' + : dateFormat.format(vendor.updatedAt!.toLocal()), + ), + ], + ) + .toList(); return SuperportTable( columns: columns, rows: rows, rowHeight: 56, maxHeight: 520, - columnSpanExtent: (index) => index == 7 - ? const FixedTableSpanExtent(160) - : const FixedTableSpanExtent(140), - ); - } -} - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.small), - const SizedBox(height: 6), - child, - ], + onRowTap: onVendorTap == null + ? null + : (index) => onVendorTap!(vendors[index]), + columnSpanExtent: (index) => switch (index) { + 2 => const FixedTableSpanExtent(180), + 5 => const FixedTableSpanExtent(220), + 6 => const FixedTableSpanExtent(160), + _ => const FixedTableSpanExtent(120), + }, ); } } diff --git a/lib/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog.dart b/lib/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog.dart new file mode 100644 index 0000000..a4f1f06 --- /dev/null +++ b/lib/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog.dart @@ -0,0 +1,697 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../../../../../widgets/components/superport_detail_dialog.dart'; +import '../../../../../features/util/postal_search/presentation/models/postal_search_result.dart'; +import '../../../../../features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; +import '../../domain/entities/warehouse.dart'; + +/// 창고 상세 다이얼로그에서 발생하는 액션 유형이다. +enum WarehouseDetailDialogAction { created, updated, deleted, restored } + +/// 창고 상세 다이얼로그 결과 모델이다. +class WarehouseDetailDialogResult { + const WarehouseDetailDialogResult({ + required this.action, + required this.message, + this.warehouse, + }); + + final WarehouseDetailDialogAction action; + final String message; + + /// 최신 창고 엔티티. 삭제 액션에서는 null일 수 있다. + final Warehouse? warehouse; +} + +typedef WarehouseCreateCallback = + Future Function(WarehouseInput input); +typedef WarehouseUpdateCallback = + Future Function(int id, WarehouseInput input); +typedef WarehouseDeleteCallback = Future Function(int id); +typedef WarehouseRestoreCallback = Future Function(int id); + +/// 창고 상세 다이얼로그를 표시한다. +Future showWarehouseDetailDialog({ + required BuildContext context, + required intl.DateFormat dateFormat, + Warehouse? warehouse, + required WarehouseCreateCallback onCreate, + required WarehouseUpdateCallback onUpdate, + required WarehouseDeleteCallback onDelete, + required WarehouseRestoreCallback onRestore, +}) { + final metadata = warehouse == null + ? const [] + : [ + SuperportDetailMetadata.text( + label: 'ID', + value: warehouse.id?.toString() ?? '-', + ), + SuperportDetailMetadata.text( + label: '창고코드', + value: warehouse.warehouseCode, + ), + SuperportDetailMetadata.text( + label: '사용 상태', + value: warehouse.isActive ? '사용중' : '미사용', + ), + SuperportDetailMetadata.text( + label: '삭제 여부', + value: warehouse.isDeleted ? '삭제됨' : '정상', + ), + SuperportDetailMetadata.text( + label: '우편번호', + value: warehouse.zipcode?.zipcode ?? '-', + ), + SuperportDetailMetadata.text( + label: '기본주소', + value: _composeFullAddress(warehouse.zipcode), + ), + SuperportDetailMetadata.text( + label: '상세주소', + value: warehouse.addressDetail?.isEmpty ?? true + ? '-' + : warehouse.addressDetail!, + ), + SuperportDetailMetadata.text( + label: '비고', + value: warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!, + ), + SuperportDetailMetadata.text( + label: '생성일시', + value: warehouse.createdAt == null + ? '-' + : dateFormat.format(warehouse.createdAt!.toLocal()), + ), + SuperportDetailMetadata.text( + label: '변경일시', + value: warehouse.updatedAt == null + ? '-' + : dateFormat.format(warehouse.updatedAt!.toLocal()), + ), + ]; + + return showSuperportDetailDialog( + context: context, + title: warehouse == null ? '창고 등록' : '창고 상세', + description: warehouse == null + ? '입고지(창고) 기본 정보를 입력하세요.' + : '창고 기본 정보와 주소를 확인하고 관리할 수 있습니다.', + sections: [ + _WarehouseFormSection( + id: warehouse == null + ? _WarehouseSections.create + : _WarehouseSections.edit, + label: warehouse == null ? '등록' : '수정', + warehouse: warehouse, + onSubmit: (input) async { + if (warehouse == null) { + final created = await onCreate(input); + if (created == null) { + return null; + } + return WarehouseDetailDialogResult( + action: WarehouseDetailDialogAction.created, + message: '창고를 등록했습니다.', + warehouse: created, + ); + } + final updated = await onUpdate(warehouse.id!, input); + if (updated == null) { + return null; + } + return WarehouseDetailDialogResult( + action: WarehouseDetailDialogAction.updated, + message: '창고를 수정했습니다.', + warehouse: updated, + ); + }, + ), + if (warehouse != null) + _WarehouseDangerSection( + warehouse: warehouse, + onDelete: () async { + final id = warehouse.id; + if (id == null) { + return null; + } + final success = await onDelete(id); + if (!success) { + return null; + } + return WarehouseDetailDialogResult( + action: WarehouseDetailDialogAction.deleted, + message: '창고를 삭제했습니다.', + warehouse: warehouse, + ); + }, + onRestore: () async { + final id = warehouse.id; + if (id == null) { + return null; + } + final restored = await onRestore(id); + if (restored == null) { + return null; + } + return WarehouseDetailDialogResult( + action: WarehouseDetailDialogAction.restored, + message: '창고를 복구했습니다.', + warehouse: restored, + ); + }, + ), + ], + summary: warehouse == null + ? null + : Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + warehouse.warehouseName, + style: ShadTheme.of(context).textTheme.h4, + ), + const SizedBox(height: 4), + Text( + '창고코드 ${warehouse.warehouseCode}', + style: ShadTheme.of(context).textTheme.muted, + ), + ], + ), + summaryBadges: warehouse == null + ? const [] + : [ + if (warehouse.isActive) + const ShadBadge(child: Text('사용중')) + else + const ShadBadge.outline(child: Text('미사용')), + if (warehouse.isDeleted) + const ShadBadge.destructive(child: Text('삭제됨')), + ], + metadata: metadata, + initialSectionId: warehouse == null + ? _WarehouseSections.create + : _WarehouseSections.edit, + emptyPlaceholder: const Text('표시할 창고 정보가 없습니다.'), + ); +} + +class _WarehouseSections { + static const edit = 'edit'; + static const delete = 'delete'; + static const restore = 'restore'; + static const create = 'create'; +} + +/// 창고 입력 폼을 제공하는 섹션이다. +class _WarehouseFormSection extends SuperportDetailDialogSection { + _WarehouseFormSection({ + required super.id, + required super.label, + required Warehouse? warehouse, + required this.onSubmit, + }) : super( + icon: LucideIcons.pencil, + builder: (context) => + _WarehouseForm(warehouse: warehouse, onSubmit: onSubmit), + ); + + final Future Function(WarehouseInput input) + onSubmit; +} + +/// 삭제/복구 섹션이다. +class _WarehouseDangerSection extends SuperportDetailDialogSection { + _WarehouseDangerSection({ + required this.warehouse, + required this.onDelete, + required this.onRestore, + }) : super( + id: warehouse.isDeleted + ? _WarehouseSections.restore + : _WarehouseSections.delete, + label: warehouse.isDeleted ? '복구' : '삭제', + icon: warehouse.isDeleted ? LucideIcons.history : LucideIcons.trash2, + builder: (context) => _WarehouseDangerContent( + warehouse: warehouse, + onDelete: onDelete, + onRestore: onRestore, + ), + scrollable: false, + ); + + final Warehouse warehouse; + final Future Function() onDelete; + final Future Function() onRestore; +} + +class _WarehouseDangerContent extends StatelessWidget { + const _WarehouseDangerContent({ + required this.warehouse, + required this.onDelete, + required this.onRestore, + }); + + final Warehouse warehouse; + final Future Function() onDelete; + final Future Function() onRestore; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + final isDeleted = warehouse.isDeleted; + final description = isDeleted + ? '창고를 복구하면 다시 목록에 노출되고 입출고 등록 시 선택할 수 있습니다.' + : '삭제하면 창고가 목록에서 숨겨지며 기존 데이터는 유지됩니다.'; + final confirmLabel = isDeleted ? '복구' : '삭제'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(description, style: theme.textTheme.small), + const SizedBox(height: 16), + if (isDeleted) + ShadButton( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onRestore(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: Text(confirmLabel), + ) + else + ShadButton.destructive( + onPressed: () async { + final navigator = Navigator.of(context, rootNavigator: true); + final result = await onDelete(); + if (result != null && navigator.mounted) { + navigator.pop(result); + } + }, + child: Text(confirmLabel), + ), + ], + ); + } +} + +/// 창고 입력 폼 구현이다. +class _WarehouseForm extends StatefulWidget { + const _WarehouseForm({required this.warehouse, required this.onSubmit}); + + final Warehouse? warehouse; + final Future Function(WarehouseInput input) + onSubmit; + + @override + State<_WarehouseForm> createState() => _WarehouseFormState(); +} + +class _WarehouseFormState extends State<_WarehouseForm> { + late final TextEditingController _codeController; + late final TextEditingController _nameController; + late final TextEditingController _zipcodeController; + late final TextEditingController _addressController; + late final TextEditingController _noteController; + late final ValueNotifier _selectedPostalNotifier; + late final ValueNotifier _isActiveNotifier; + String? _codeError; + String? _nameError; + String? _zipcodeError; + String? _submitError; + bool _isSubmitting = false; + bool _isApplyingPostalSelection = false; + + bool get _isEdit => widget.warehouse != null; + + @override + void initState() { + super.initState(); + final warehouse = widget.warehouse; + _codeController = TextEditingController( + text: warehouse?.warehouseCode ?? '', + ); + _nameController = TextEditingController( + text: warehouse?.warehouseName ?? '', + ); + _zipcodeController = TextEditingController( + text: warehouse?.zipcode?.zipcode ?? '', + ); + _addressController = TextEditingController( + text: warehouse?.addressDetail ?? '', + ); + _noteController = TextEditingController(text: warehouse?.note ?? ''); + _selectedPostalNotifier = ValueNotifier( + warehouse?.zipcode == null + ? null + : PostalSearchResult( + zipcode: warehouse!.zipcode!.zipcode, + sido: warehouse.zipcode!.sido, + sigungu: warehouse.zipcode!.sigungu, + roadName: warehouse.zipcode!.roadName, + ), + ); + _isActiveNotifier = ValueNotifier(warehouse?.isActive ?? true); + + _zipcodeController.addListener(_handleZipcodeChange); + _selectedPostalNotifier.addListener(_handlePostalSelection); + } + + @override + void dispose() { + _zipcodeController.removeListener(_handleZipcodeChange); + _selectedPostalNotifier.removeListener(_handlePostalSelection); + _codeController.dispose(); + _nameController.dispose(); + _zipcodeController.dispose(); + _addressController.dispose(); + _noteController.dispose(); + _selectedPostalNotifier.dispose(); + _isActiveNotifier.dispose(); + super.dispose(); + } + + void _handleZipcodeChange() { + if (_isApplyingPostalSelection) { + return; + } + final text = _zipcodeController.text.trim(); + final selection = _selectedPostalNotifier.value; + if (text.isEmpty) { + if (selection != null) { + _selectedPostalNotifier.value = null; + } + setState(() { + _zipcodeError = null; + }); + return; + } + if (selection != null && selection.zipcode != text) { + _selectedPostalNotifier.value = null; + } + if (_zipcodeError != null) { + setState(() { + _zipcodeError = null; + }); + } + } + + void _handlePostalSelection() { + if (_selectedPostalNotifier.value != null && _zipcodeError != null) { + setState(() { + _zipcodeError = null; + }); + } + } + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _WarehouseFormField( + label: '창고코드', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _codeController, + readOnly: _isEdit, + onChanged: (_) { + if (_codeController.text.trim().isNotEmpty) { + setState(() { + _codeError = null; + }); + } + }, + ), + if (_codeError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _codeError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _WarehouseFormField( + label: '창고명', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ShadInput( + controller: _nameController, + onChanged: (_) { + if (_nameController.text.trim().isNotEmpty) { + setState(() { + _nameError = null; + }); + } + }, + ), + if (_nameError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _nameError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _WarehouseFormField( + label: '우편번호', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: ShadInput( + controller: _zipcodeController, + placeholder: const Text('예: 06000'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + ShadButton.outline( + onPressed: _isSubmitting ? null : _openPostalSearch, + child: const Text('검색'), + ), + ], + ), + const SizedBox(height: 8), + ValueListenableBuilder( + valueListenable: _selectedPostalNotifier, + builder: (_, selection, __) { + if (selection == null) { + return Text( + '검색 버튼을 눌러 주소를 선택하세요.', + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ); + } + return Text( + selection.fullAddress.isEmpty + ? '선택한 우편번호에 주소 정보가 없습니다.' + : selection.fullAddress, + style: theme.textTheme.small, + ); + }, + ), + if (_zipcodeError != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + _zipcodeError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + _WarehouseFormField( + label: '상세주소', + child: ShadInput( + controller: _addressController, + placeholder: const Text('상세주소 입력'), + ), + ), + const SizedBox(height: 16), + _WarehouseFormField( + label: '사용여부', + child: ValueListenableBuilder( + valueListenable: _isActiveNotifier, + builder: (_, value, __) { + return Row( + children: [ + ShadSwitch( + value: value, + onChanged: _isSubmitting + ? null + : (next) => _isActiveNotifier.value = next, + ), + const SizedBox(width: 8), + Text(value ? '사용' : '미사용'), + ], + ); + }, + ), + ), + const SizedBox(height: 16), + _WarehouseFormField( + label: '비고', + child: ShadTextarea( + controller: _noteController, + minHeight: 96, + maxHeight: 240, + ), + ), + if (_submitError != null) ...[ + const SizedBox(height: 16), + Text( + _submitError!, + style: theme.textTheme.small.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: ShadButton( + onPressed: _isSubmitting ? null : _handleSubmit, + child: Text(_isEdit ? '저장' : '등록'), + ), + ), + ], + ); + } + + Future _openPostalSearch() async { + final keyword = _zipcodeController.text.trim(); + final result = await showPostalSearchDialog( + context, + initialKeyword: keyword.isEmpty ? null : keyword, + ); + if (result == null) { + return; + } + _isApplyingPostalSelection = true; + _zipcodeController + ..text = result.zipcode + ..selection = TextSelection.collapsed(offset: result.zipcode.length); + _isApplyingPostalSelection = false; + _selectedPostalNotifier.value = result; + if (result.fullAddress.isNotEmpty) { + _addressController + ..text = result.fullAddress + ..selection = TextSelection.collapsed( + offset: _addressController.text.length, + ); + } + } + + Future _handleSubmit() async { + final code = _codeController.text.trim(); + final name = _nameController.text.trim(); + final zipcode = _zipcodeController.text.trim(); + final address = _addressController.text.trim(); + final selectedPostal = _selectedPostalNotifier.value; + + setState(() { + _codeError = code.isEmpty ? '창고코드를 입력하세요.' : null; + _nameError = name.isEmpty ? '창고명을 입력하세요.' : null; + _zipcodeError = zipcode.isNotEmpty && selectedPostal == null + ? '우편번호 검색으로 주소를 선택하세요.' + : null; + _submitError = null; + }); + + if (_codeError != null || _nameError != null || _zipcodeError != null) { + return; + } + + setState(() { + _isSubmitting = true; + }); + + final input = WarehouseInput( + warehouseCode: code, + warehouseName: name, + zipcode: zipcode.isEmpty ? null : zipcode, + addressDetail: address.isEmpty ? null : address, + isActive: _isActiveNotifier.value, + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + ); + final navigator = Navigator.of(context, rootNavigator: true); + final result = await widget.onSubmit(input); + + if (!mounted) { + return; + } + + setState(() { + _isSubmitting = false; + _submitError = result == null ? '요청 처리에 실패했습니다.' : null; + }); + + if (result != null && navigator.mounted) { + navigator.pop(result); + } + } +} + +class _WarehouseFormField extends StatelessWidget { + const _WarehouseFormField({required this.label, required this.child}); + + final String label; + final Widget child; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: theme.textTheme.small), + const SizedBox(height: 6), + child, + ], + ); + } +} + +String _composeFullAddress(WarehouseZipcode? zipcode) { + if (zipcode == null) { + return '-'; + } + final segments = [zipcode.sido, zipcode.sigungu, zipcode.roadName] + .where((segment) => segment != null && segment.trim().isNotEmpty) + .map((segment) => segment!.trim()); + final result = segments.join(' '); + return result.isEmpty ? '-' : result; +} diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index 75f3dc4..72768f5 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -1,23 +1,24 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart' as intl; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; -import 'package:superport_v2/widgets/components/superport_dialog.dart'; import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import 'package:superport_v2/widgets/components/superport_table.dart'; import 'package:superport_v2/widgets/components/responsive_section.dart'; -import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; -import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; import '../../domain/entities/warehouse.dart'; import '../../domain/repositories/warehouse_repository.dart'; import '../controllers/warehouse_controller.dart'; +import '../dialogs/warehouse_detail_dialog.dart'; /// 창고 관리 페이지. 기능 플래그에 따라 사양/실제 화면을 전환한다. class WarehousePage extends StatelessWidget { @@ -99,7 +100,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { late final WarehouseController _controller; final TextEditingController _searchController = TextEditingController(); final FocusNode _searchFocus = FocusNode(); - final DateFormat _dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + final intl.DateFormat _dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); String? _lastError; String? _lastAppliedRoute; @@ -178,7 +179,7 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { leading: const Icon(LucideIcons.plus, size: 16), onPressed: _controller.isSubmitting ? null - : () => _openWarehouseForm(context), + : _openWarehouseCreateDialog, child: const Text('신규 등록'), ), ], @@ -292,14 +293,9 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { : _WarehouseTable( warehouses: warehouses, dateFormat: _dateFormat, - onEdit: _controller.isSubmitting + onWarehouseTap: _controller.isSubmitting ? null - : (warehouse) => - _openWarehouseForm(context, warehouse: warehouse), - onDelete: _controller.isSubmitting ? null : _confirmDelete, - onRestore: _controller.isSubmitting - ? null - : _restoreWarehouse, + : _openWarehouseDetailDialog, ), ), ); @@ -427,441 +423,51 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { } } - Future _openWarehouseForm( - BuildContext context, { - Warehouse? warehouse, - }) async { - final existing = warehouse; - final isEdit = existing != null; - final warehouseId = existing?.id; - if (isEdit && warehouseId == null) { - _showSnack('ID 정보가 없어 수정할 수 없습니다.'); - return; - } - - final codeController = TextEditingController( - text: existing?.warehouseCode ?? '', - ); - final nameController = TextEditingController( - text: existing?.warehouseName ?? '', - ); - final zipcodeController = TextEditingController( - text: existing?.zipcode?.zipcode ?? '', - ); - final addressController = TextEditingController( - text: existing?.addressDetail ?? '', - ); - final noteController = TextEditingController(text: existing?.note ?? ''); - final existingZipcode = existing?.zipcode; - final selectedPostalNotifier = ValueNotifier( - existingZipcode == null - ? null - : PostalSearchResult( - zipcode: existingZipcode.zipcode, - sido: existingZipcode.sido, - sigungu: existingZipcode.sigungu, - roadName: existingZipcode.roadName, - ), - ); - final isActiveNotifier = ValueNotifier(existing?.isActive ?? true); - final saving = ValueNotifier(false); - final codeError = ValueNotifier(null); - final nameError = ValueNotifier(null); - final zipcodeError = ValueNotifier(null); - - var isApplyingPostalSelection = false; - - void handleZipcodeChange() { - if (isApplyingPostalSelection) { - return; - } - final text = zipcodeController.text.trim(); - final selection = selectedPostalNotifier.value; - if (text.isEmpty) { - if (selection != null) { - selectedPostalNotifier.value = null; - } - zipcodeError.value = null; - return; - } - if (selection != null && selection.zipcode != text) { - selectedPostalNotifier.value = null; - } - if (zipcodeError.value != null) { - zipcodeError.value = null; - } - } - - void handlePostalSelectionChange() { - if (selectedPostalNotifier.value != null) { - zipcodeError.value = null; - } - } - - zipcodeController.addListener(handleZipcodeChange); - selectedPostalNotifier.addListener(handlePostalSelectionChange); - - await SuperportDialog.show( + Future _openWarehouseCreateDialog() async { + final result = await showWarehouseDetailDialog( context: context, - dialog: SuperportDialog( - title: isEdit ? '창고 수정' : '창고 등록', - description: '창고 기본 정보를 ${isEdit ? '수정' : '입력'}하세요.', - constraints: const BoxConstraints(maxWidth: 540), - primaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton( - onPressed: isSaving - ? null - : () async { - final code = codeController.text.trim(); - final name = nameController.text.trim(); - final zipcode = zipcodeController.text.trim(); - final address = addressController.text.trim(); - final note = noteController.text.trim(); - final selectedPostal = selectedPostalNotifier.value; - - codeError.value = code.isEmpty ? '창고코드를 입력하세요.' : null; - nameError.value = name.isEmpty ? '창고명을 입력하세요.' : null; - zipcodeError.value = - zipcode.isNotEmpty && selectedPostal == null - ? '우편번호 검색으로 주소를 선택하세요.' - : null; - - if (codeError.value != null || - nameError.value != null || - zipcodeError.value != null) { - return; - } - - saving.value = true; - final input = WarehouseInput( - warehouseCode: code, - warehouseName: name, - zipcode: zipcode.isEmpty ? null : zipcode, - addressDetail: address.isEmpty ? null : address, - isActive: isActiveNotifier.value, - note: note.isEmpty ? null : note, - ); - final navigator = Navigator.of( - context, - rootNavigator: true, - ); - final response = isEdit - ? await _controller.update(warehouseId!, input) - : await _controller.create(input); - saving.value = false; - if (response != null && mounted) { - if (!navigator.mounted) { - return; - } - _showSnack(isEdit ? '창고를 수정했습니다.' : '창고를 등록했습니다.'); - navigator.pop(true); - } - }, - child: Text(isEdit ? '저장' : '등록'), - ); - }, - ), - secondaryAction: ValueListenableBuilder( - valueListenable: saving, - builder: (context, isSaving, _) { - return ShadButton.ghost( - onPressed: isSaving - ? null - : () => Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ); - }, - ), - child: Builder( - builder: (dialogContext) { - final theme = ShadTheme.of(dialogContext); - final materialTheme = Theme.of(dialogContext); - - Future openPostalSearch() async { - final keyword = zipcodeController.text.trim(); - final result = await showPostalSearchDialog( - dialogContext, - initialKeyword: keyword.isEmpty ? null : keyword, - ); - if (result == null) { - return; - } - isApplyingPostalSelection = true; - zipcodeController - ..text = result.zipcode - ..selection = TextSelection.collapsed( - offset: result.zipcode.length, - ); - isApplyingPostalSelection = false; - selectedPostalNotifier.value = result; - if (result.fullAddress.isNotEmpty) { - addressController - ..text = result.fullAddress - ..selection = TextSelection.collapsed( - offset: addressController.text.length, - ); - } - } - - return SingleChildScrollView( - padding: const EdgeInsets.only(right: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - ValueListenableBuilder( - valueListenable: codeError, - builder: (_, errorText, __) { - return _FormField( - label: '창고코드', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: codeController, - readOnly: isEdit, - onChanged: (_) { - if (codeController.text.trim().isNotEmpty) { - codeError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: nameError, - builder: (_, errorText, __) { - return _FormField( - label: '창고명', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ShadInput( - controller: nameController, - onChanged: (_) { - if (nameController.text.trim().isNotEmpty) { - nameError.value = null; - } - }, - ), - if (errorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - errorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: zipcodeError, - builder: (_, zipcodeErrorText, __) { - return _FormField( - label: '우편번호', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: ShadInput( - controller: zipcodeController, - placeholder: const Text('예: 06000'), - keyboardType: TextInputType.number, - ), - ), - const SizedBox(width: 8), - ValueListenableBuilder( - valueListenable: saving, - builder: (_, isSaving, __) { - return ShadButton.outline( - onPressed: isSaving - ? null - : openPostalSearch, - child: const Text('검색'), - ); - }, - ), - ], - ), - const SizedBox(height: 8), - ValueListenableBuilder( - valueListenable: selectedPostalNotifier, - builder: (_, selection, __) { - if (selection == null) { - return Text( - '검색 버튼을 눌러 주소를 선택하세요.', - style: theme.textTheme.small.copyWith( - color: theme.colorScheme.mutedForeground, - ), - ); - } - final fullAddress = selection.fullAddress; - return Text( - fullAddress.isEmpty - ? '선택한 우편번호에 주소 정보가 없습니다.' - : fullAddress, - style: theme.textTheme.small, - ); - }, - ), - if (zipcodeErrorText != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: Text( - zipcodeErrorText, - style: theme.textTheme.small.copyWith( - color: materialTheme.colorScheme.error, - ), - ), - ), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '상세주소', - child: ShadInput( - controller: addressController, - placeholder: const Text('상세주소 입력'), - ), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: isActiveNotifier, - builder: (_, value, __) { - return _FormField( - label: '사용여부', - child: Row( - children: [ - ShadSwitch( - value: value, - onChanged: saving.value - ? null - : (next) => isActiveNotifier.value = next, - ), - const SizedBox(width: 8), - Text(value ? '사용' : '미사용'), - ], - ), - ); - }, - ), - const SizedBox(height: 16), - _FormField( - label: '비고', - child: ShadTextarea(controller: noteController), - ), - if (isEdit) ...[ - const SizedBox(height: 20), - Text( - '생성일시: ${_formatDateTime(existing.createdAt)}', - style: theme.textTheme.small, - ), - const SizedBox(height: 4), - Text( - '수정일시: ${_formatDateTime(existing.updatedAt)}', - style: theme.textTheme.small, - ), - ], - ], - ), - ); - }, - ), - ), + dateFormat: _dateFormat, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, ); - zipcodeController.removeListener(handleZipcodeChange); - selectedPostalNotifier.removeListener(handlePostalSelectionChange); - - if (!mounted) { - codeController.dispose(); - nameController.dispose(); - zipcodeController.dispose(); - addressController.dispose(); - noteController.dispose(); - selectedPostalNotifier.dispose(); - isActiveNotifier.dispose(); - saving.dispose(); - codeError.dispose(); - nameError.dispose(); - zipcodeError.dispose(); - return; - } - - codeController.dispose(); - nameController.dispose(); - zipcodeController.dispose(); - addressController.dispose(); - noteController.dispose(); - selectedPostalNotifier.dispose(); - isActiveNotifier.dispose(); - saving.dispose(); - codeError.dispose(); - nameError.dispose(); - zipcodeError.dispose(); - } - - Future _confirmDelete(Warehouse warehouse) async { - final confirmed = await SuperportDialog.show( - context: context, - dialog: SuperportDialog( - title: '창고 삭제', - description: '"${warehouse.warehouseName}" 창고를 삭제하시겠습니까?', - actions: [ - ShadButton.ghost( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(false), - child: const Text('취소'), - ), - ShadButton( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), - child: const Text('삭제'), - ), - ], - ), - ); - - if (confirmed == true && warehouse.id != null) { - final success = await _controller.delete(warehouse.id!); - if (success && mounted) { - _showSnack('창고를 삭제했습니다.'); + if (result != null && mounted) { + _showSnack(result.message); + switch (result.action) { + case WarehouseDetailDialogAction.created: + unawaited(_controller.fetch(page: 1)); + break; + case WarehouseDetailDialogAction.updated: + case WarehouseDetailDialogAction.deleted: + case WarehouseDetailDialogAction.restored: + unawaited(_controller.fetch(page: _controller.result?.page ?? 1)); + break; } } } - Future _restoreWarehouse(Warehouse warehouse) async { - if (warehouse.id == null) return; - final restored = await _controller.restore(warehouse.id!); - if (restored != null && mounted) { - _showSnack('창고를 복구했습니다.'); + Future _openWarehouseDetailDialog(Warehouse warehouse) async { + final id = warehouse.id; + if (id == null) { + _showSnack('ID 정보가 없어 상세를 열 수 없습니다.'); + return; + } + + final result = await showWarehouseDetailDialog( + context: context, + dateFormat: _dateFormat, + warehouse: warehouse, + onCreate: _controller.create, + onUpdate: _controller.update, + onDelete: _controller.delete, + onRestore: _controller.restore, + ); + + if (result != null && mounted) { + _showSnack(result.message); + unawaited(_controller.fetch(page: _controller.result?.page ?? 1)); } } @@ -875,27 +481,18 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { } messenger.showSnackBar(SnackBar(content: Text(message))); } - - String _formatDateTime(DateTime? value) { - if (value == null) return '-'; - return _dateFormat.format(value.toLocal()); - } } class _WarehouseTable extends StatelessWidget { const _WarehouseTable({ required this.warehouses, required this.dateFormat, - required this.onEdit, - required this.onDelete, - required this.onRestore, + required this.onWarehouseTap, }); final List warehouses; - final DateFormat dateFormat; - final void Function(Warehouse warehouse)? onEdit; - final void Function(Warehouse warehouse)? onDelete; - final void Function(Warehouse warehouse)? onRestore; + final intl.DateFormat dateFormat; + final void Function(Warehouse warehouse)? onWarehouseTap; @override Widget build(BuildContext context) { @@ -909,92 +506,47 @@ class _WarehouseTable extends StatelessWidget { Text('삭제'), Text('비고'), Text('변경일시'), - Text('동작'), ]; - final rows = warehouses.map((warehouse) { - final cells = [ - Text(warehouse.id?.toString() ?? '-'), - Text(warehouse.warehouseCode), - Text(warehouse.warehouseName), - Text(warehouse.zipcode?.zipcode ?? '-'), - Text( - warehouse.addressDetail?.isEmpty ?? true - ? '-' - : warehouse.addressDetail!, - ), - Text(warehouse.isActive ? 'Y' : 'N'), - Text(warehouse.isDeleted ? 'Y' : '-'), - Text(warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!), - Text( - warehouse.updatedAt == null - ? '-' - : dateFormat.format(warehouse.updatedAt!.toLocal()), - ), - ]; - - cells.add( - ShadTableCell( - alignment: Alignment.centerRight, - child: Wrap( - spacing: 8, - children: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onEdit == null ? null : () => onEdit!(warehouse), - child: const Icon(LucideIcons.pencil, size: 16), - ), - warehouse.isDeleted - ? ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onRestore == null - ? null - : () => onRestore!(warehouse), - child: const Icon(LucideIcons.history, size: 16), - ) - : ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: onDelete == null - ? null - : () => onDelete!(warehouse), - child: const Icon(LucideIcons.trash2, size: 16), - ), - ], - ), - ), - ); - - return cells; - }).toList(); + final rows = warehouses + .map( + (warehouse) => [ + Text(warehouse.id?.toString() ?? '-'), + Text(warehouse.warehouseCode), + Text(warehouse.warehouseName), + Text(warehouse.zipcode?.zipcode ?? '-'), + Text( + warehouse.addressDetail?.isEmpty ?? true + ? '-' + : warehouse.addressDetail!, + ), + Text(warehouse.isActive ? 'Y' : 'N'), + Text(warehouse.isDeleted ? 'Y' : '-'), + Text(warehouse.note?.isEmpty ?? true ? '-' : warehouse.note!), + Text( + warehouse.updatedAt == null + ? '-' + : dateFormat.format(warehouse.updatedAt!.toLocal()), + ), + ], + ) + .toList(); return SuperportTable( columns: columns, rows: rows, rowHeight: 56, maxHeight: 520, - columnSpanExtent: (index) => index == 9 - ? const FixedTableSpanExtent(160) - : const FixedTableSpanExtent(140), - ); - } -} - -class _FormField extends StatelessWidget { - const _FormField({required this.label, required this.child}); - - final String label; - final Widget child; - - @override - Widget build(BuildContext context) { - final theme = ShadTheme.of(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: theme.textTheme.small), - const SizedBox(height: 6), - child, - ], + onRowTap: onWarehouseTap == null + ? null + : (index) => onWarehouseTap!(warehouses[index]), + columnSpanExtent: (index) => switch (index) { + 1 => const FixedTableSpanExtent(140), + 2 => const FixedTableSpanExtent(200), + 4 => const FixedTableSpanExtent(220), + 8 => const FixedTableSpanExtent(160), + _ => const FixedTableSpanExtent(120), + }, ); } } diff --git a/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart b/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart index 3e18a54..b0bd390 100644 --- a/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart +++ b/lib/features/util/postal_search/presentation/widgets/postal_search_dialog.dart @@ -257,14 +257,10 @@ class _PostalSearchDialogState extends State<_PostalSearchDialog> { ], ], onRowTap: (index) { - if (_results.isEmpty) { + if (index < 0 || index >= _results.length) { return; } - final adjustedIndex = (index - 1).clamp( - 0, - _results.length - 1, - ); - navigator.pop(_results[adjustedIndex]); + navigator.pop(_results[index]); }, emptyLabel: '검색 결과가 없습니다.', ), diff --git a/lib/widgets/components/superport_detail_dialog.dart b/lib/widgets/components/superport_detail_dialog.dart new file mode 100644 index 0000000..bf82e47 --- /dev/null +++ b/lib/widgets/components/superport_detail_dialog.dart @@ -0,0 +1,565 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'superport_dialog.dart'; + +/// 엔티티 상세 정보를 탭 또는 단일 영역으로 보여주는 다이얼로그 전용 섹션 정의이다. +class SuperportDetailDialogSection { + const SuperportDetailDialogSection({ + this.key, + required this.id, + required this.label, + required this.builder, + this.icon, + this.badge, + this.scrollable = true, + }); + + /// 탭 식별자. 초기 탭 선택에 사용된다. + final String id; + + /// 탭 라벨에 사용될 텍스트. + final String label; + + /// 섹션 컨텐츠를 생성하는 빌더. + final WidgetBuilder builder; + + /// 라벨 좌측에 배치할 아이콘. + final IconData? icon; + + /// 라벨 우측에 배치할 보조 배지 위젯. + final Widget? badge; + + /// `true`면 섹션이 자체 스크롤 뷰를 생성한다. + final bool scrollable; + + /// 탭 버튼에 부여할 키. + final Key? key; +} + +/// 상세 요약을 표 형식으로 표현할 때 사용하는 메타 필드 정의이다. +class SuperportDetailMetadata { + const SuperportDetailMetadata({ + required this.label, + required this.value, + this.icon, + }); + + /// 항목 이름. + final String label; + + /// 값 영역에 들어갈 위젯. 기본 텍스트는 [SuperportDetailMetadata.text] 팩토리로 생성한다. + final Widget value; + + /// 값 좌측에 배치할 아이콘. + final IconData? icon; + + /// 간단한 문자열 값을 텍스트 위젯으로 감싼 메타데이터 팩토리이다. + factory SuperportDetailMetadata.text({ + required String label, + required String value, + IconData? icon, + String? tooltip, + TextStyle? style, + TextAlign textAlign = TextAlign.start, + }) { + final text = Text(value, textAlign: textAlign, style: style); + + final widget = tooltip == null + ? text + : Tooltip(message: tooltip, child: text); + + return SuperportDetailMetadata(label: label, value: widget, icon: icon); + } +} + +/// 상세 다이얼로그 본문을 구성하는 레이아웃 위젯이다. +class SuperportDetailDialog extends StatelessWidget { + const SuperportDetailDialog({ + super.key, + required this.sections, + this.summary, + this.metadata = const [], + this.summaryBadges = const [], + this.emptyPlaceholder, + this.initialSectionId, + this.initialSectionIndex = 0, + this.summaryPadding = const EdgeInsets.only(bottom: 16), + this.metadataPadding = EdgeInsets.zero, + this.infoPanelPadding = const EdgeInsets.only(bottom: 24), + this.sectionPadding = const EdgeInsets.symmetric(vertical: 4), + this.metadataColumns = 2, + }); + + /// 표시할 섹션 목록. + final List sections; + + /// 상단 개요 블록. 이름/설명 등 자유롭게 구성한다. + final Widget? summary; + + /// 요약 우측 또는 하단에 배치할 배지 목록. + final List summaryBadges; + + /// 개요 하단에 정렬되는 메타 정보 모음. + final List metadata; + + /// 섹션 데이터가 없을 때 출력할 플레이스홀더. + final Widget? emptyPlaceholder; + + /// 최초로 선택할 섹션 ID. + final String? initialSectionId; + + /// ID가 없을 때 사용할 기본 인덱스. + final int initialSectionIndex; + + /// 요약 영역 패딩. + final EdgeInsetsGeometry summaryPadding; + + /// 메타데이터 영역 패딩. + final EdgeInsetsGeometry metadataPadding; + + /// summary+metadata 정보 블록 외부 패딩. + final EdgeInsetsGeometry infoPanelPadding; + + /// 섹션 컨텐츠 패딩. + final EdgeInsetsGeometry sectionPadding; + + /// metadata를 배치할 기본 열 수. 가로폭이 좁으면 자동으로 1열로 조정된다. + final int metadataColumns; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + return LayoutBuilder( + builder: (context, constraints) { + final children = []; + + final hasInfoPanel = + summary != null || summaryBadges.isNotEmpty || metadata.isNotEmpty; + + if (hasInfoPanel) { + children.add( + Padding( + padding: infoPanelPadding, + child: _InfoPanel( + summary: summary, + summaryBadges: summaryBadges, + metadata: metadata, + summaryPadding: summaryPadding, + metadataPadding: metadataPadding, + metadataColumns: metadataColumns, + ), + ), + ); + } + + final sectionContent = sections.isEmpty + ? Center( + child: + emptyPlaceholder ?? + Text( + '표시할 세부 정보가 없습니다.', + style: theme.textTheme.muted, + textAlign: TextAlign.center, + ), + ) + : sections.length == 1 + ? _SectionPane(section: sections.first, padding: sectionPadding) + : _TabbedSections( + sections: sections, + initialSectionId: initialSectionId, + initialSectionIndex: initialSectionIndex, + padding: sectionPadding, + ); + + children.add(sectionContent); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: children, + ); + }, + ); + } +} + +/// Superport 스타일 상세 다이얼로그를 표시하는 편의 함수이다. +Future showSuperportDetailDialog({ + required BuildContext context, + required String title, + List sections = const [], + Widget? summary, + List summaryBadges = const [], + List metadata = const [], + Widget? emptyPlaceholder, + String? description, + List actions = const [], + bool barrierDismissible = true, + bool mobileFullscreen = true, + BoxConstraints? constraints, + EdgeInsetsGeometry? contentPadding, + EdgeInsetsGeometry infoPanelPadding = const EdgeInsets.only(bottom: 24), + int metadataColumns = 2, + String? initialSectionId, + int initialSectionIndex = 0, + FutureOr Function()? onSubmit, + VoidCallback? onClose, + bool showCloseButton = true, + bool enableFocusTrap = true, + List? headerActions, +}) { + return showSuperportDialog( + context: context, + title: title, + description: description, + body: SuperportDetailDialog( + sections: sections, + summary: summary, + summaryBadges: summaryBadges, + metadata: metadata, + emptyPlaceholder: emptyPlaceholder, + initialSectionId: initialSectionId, + initialSectionIndex: initialSectionIndex, + infoPanelPadding: infoPanelPadding, + metadataColumns: metadataColumns, + ), + actions: actions, + barrierDismissible: barrierDismissible, + mobileFullscreen: mobileFullscreen, + constraints: + constraints ?? const BoxConstraints(maxWidth: 720, minHeight: 320), + contentPadding: + contentPadding ?? + const EdgeInsets.symmetric(horizontal: 24, vertical: 20), + scrollable: true, + onSubmit: onSubmit, + onClose: onClose, + showCloseButton: showCloseButton, + enableFocusTrap: enableFocusTrap, + headerActions: headerActions, + ); +} + +class _InfoPanel extends StatelessWidget { + const _InfoPanel({ + required this.summary, + required this.summaryBadges, + required this.metadata, + required this.summaryPadding, + required this.metadataPadding, + required this.metadataColumns, + }); + + final Widget? summary; + final List summaryBadges; + final List metadata; + final EdgeInsetsGeometry summaryPadding; + final EdgeInsetsGeometry metadataPadding; + final int metadataColumns; + + @override + Widget build(BuildContext context) { + final hasSummary = summary != null || summaryBadges.isNotEmpty; + final hasMetadata = metadata.isNotEmpty; + final columnChildren = []; + + if (hasSummary) { + columnChildren.add( + Padding( + padding: summaryPadding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (summary != null) summary!, + if (summary != null && summaryBadges.isNotEmpty) + const SizedBox(height: 12), + if (summaryBadges.isNotEmpty) + Wrap(spacing: 8, runSpacing: 8, children: summaryBadges), + ], + ), + ), + ); + } + + if (hasSummary && hasMetadata) { + columnChildren.add(const SizedBox(height: 12)); + } + + if (hasMetadata) { + columnChildren.add( + Padding( + padding: metadataPadding, + child: _MetadataGrid(metadata: metadata, columns: metadataColumns), + ), + ); + } + + return ShadCard( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: columnChildren, + ), + ); + } +} + +class _MetadataGrid extends StatelessWidget { + const _MetadataGrid({required this.metadata, required this.columns}); + + final List metadata; + final int columns; + + @override + Widget build(BuildContext context) { + final theme = ShadTheme.of(context); + + return LayoutBuilder( + builder: (context, constraints) { + final mediaWidth = MediaQuery.of(context).size.width; + final maxWidth = constraints.maxWidth.isFinite + ? constraints.maxWidth + : mediaWidth; + final normalizedColumns = columns <= 0 ? 1 : columns; + final maxColumns = metadata.isEmpty ? 1 : metadata.length; + final targetColumns = (normalizedColumns.clamp(1, maxColumns)).toInt(); + final resolvedColumns = maxWidth < 520 ? 1 : targetColumns; + final spacing = resolvedColumns == 1 ? 0.0 : 16.0; + final tileWidth = resolvedColumns == 1 + ? maxWidth + : (maxWidth - spacing * (resolvedColumns - 1)) / resolvedColumns; + + return Wrap( + spacing: 16, + runSpacing: 12, + children: metadata + .map( + (item) => SizedBox( + width: resolvedColumns == 1 ? maxWidth : tileWidth, + child: _MetadataTile(item: item, theme: theme), + ), + ) + .toList(), + ); + }, + ); + } +} + +class _MetadataTile extends StatelessWidget { + const _MetadataTile({required this.item, required this.theme}); + + final SuperportDetailMetadata item; + final ShadThemeData theme; + + @override + Widget build(BuildContext context) { + final labelStyle = theme.textTheme.small.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.mutedForeground, + ); + + final valueStyle = theme.textTheme.small; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (item.icon != null) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Icon( + item.icon, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + Flexible(child: Text(item.label, style: labelStyle)), + ], + ), + const SizedBox(height: 4), + DefaultTextStyle.merge(style: valueStyle, child: item.value), + ], + ); + } +} + +class _SectionPane extends StatefulWidget { + const _SectionPane({required this.section, required this.padding}); + + final SuperportDetailDialogSection section; + final EdgeInsetsGeometry padding; + + @override + State<_SectionPane> createState() => _SectionPaneState(); +} + +class _SectionPaneState extends State<_SectionPane> { + ScrollController? _controller; + + @override + void initState() { + super.initState(); + if (widget.section.scrollable) { + _controller = ScrollController(); + } + } + + @override + void didUpdateWidget(covariant _SectionPane oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.section.scrollable && _controller == null) { + _controller = ScrollController(); + } else if (!widget.section.scrollable && _controller != null) { + _controller!.dispose(); + _controller = null; + } + } + + @override + void dispose() { + _controller?.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final content = Padding( + padding: widget.padding, + child: widget.section.builder(context), + ); + + if (!widget.section.scrollable) { + return content; + } + + final controller = _controller ??= ScrollController(); + + return Scrollbar( + controller: controller, + thumbVisibility: true, + child: SingleChildScrollView( + controller: controller, + primary: false, + padding: const EdgeInsets.only(right: 4), + physics: const ClampingScrollPhysics(), + child: content, + ), + ); + } +} + +class _TabbedSections extends StatefulWidget { + const _TabbedSections({ + required this.sections, + required this.initialSectionId, + required this.initialSectionIndex, + required this.padding, + }); + + final List sections; + final String? initialSectionId; + final int initialSectionIndex; + final EdgeInsetsGeometry padding; + + @override + State<_TabbedSections> createState() => _TabbedSectionsState(); +} + +class _TabbedSectionsState extends State<_TabbedSections> { + late ShadTabsController _controller; + + @override + void initState() { + super.initState(); + _controller = ShadTabsController(value: _resolveInitialSectionId()); + } + + @override + void didUpdateWidget(covariant _TabbedSections oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.sections.isEmpty) { + return; + } + + final selected = _controller.selected; + final exists = widget.sections.any((section) => section.id == selected); + if (!exists) { + _controller.select(_resolveInitialSectionId()); + return; + } + + final nextInitial = widget.initialSectionId; + if (nextInitial != null && nextInitial != oldWidget.initialSectionId) { + final hasTarget = widget.sections.any( + (section) => section.id == nextInitial, + ); + if (hasTarget) { + _controller.select(nextInitial); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ShadTabs( + controller: _controller, + expandContent: false, + gap: 12, + tabs: widget.sections + .map( + (section) => ShadTab( + key: section.key, + value: section.id, + content: _SectionPane(section: section, padding: widget.padding), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (section.icon != null) + Padding( + padding: const EdgeInsets.only(right: 6), + child: Icon(section.icon, size: 16), + ), + Text(section.label), + if (section.badge != null) ...[ + const SizedBox(width: 6), + section.badge!, + ], + ], + ), + ), + ) + .toList(), + ); + } + + String _resolveInitialSectionId() { + if (widget.sections.isEmpty) { + return ''; + } + + if (widget.initialSectionId != null) { + final match = widget.sections.firstWhere( + (section) => section.id == widget.initialSectionId, + orElse: () => widget.sections.first, + ); + return match.id; + } + + final index = widget.initialSectionIndex.clamp( + 0, + widget.sections.length - 1, + ); + return widget.sections[index].id; + } +} diff --git a/lib/widgets/components/superport_table.dart b/lib/widgets/components/superport_table.dart index 83d19c9..02de555 100644 --- a/lib/widgets/components/superport_table.dart +++ b/lib/widgets/components/superport_table.dart @@ -182,13 +182,27 @@ class SuperportTable extends StatelessWidget { : math.min(estimatedHeight, maxHeight!), ); + final hasHeaderRow = _headerCells != null || _columns != null; + final headerCount = hasHeaderRow ? 1 : 0; + + void Function(int index)? handleRowTap; + if (onRowTap != null) { + handleRowTap = (index) { + final dataIndex = index - headerCount; + if (dataIndex < 0 || dataIndex >= tableRows.length) { + return; + } + onRowTap!(dataIndex); + }; + } + final tableView = SizedBox( height: effectiveHeight, child: ShadTable.list( header: headerCells, columnSpanExtent: columnSpanExtent, rowSpanExtent: (_) => FixedTableSpanExtent(rowHeight), - onRowTap: onRowTap, + onRowTap: handleRowTap, primary: false, children: tableRows, ), diff --git a/test/features/approvals/approval_page_permission_test.dart b/test/features/approvals/approval_page_permission_test.dart index b05abeb..77f9b77 100644 --- a/test/features/approvals/approval_page_permission_test.dart +++ b/test/features/approvals/approval_page_permission_test.dart @@ -16,6 +16,7 @@ import 'package:superport_v2/features/approvals/domain/repositories/approval_tem import 'package:superport_v2/features/approvals/presentation/pages/approval_page.dart'; import 'package:superport_v2/features/inventory/lookups/domain/entities/lookup_item.dart'; import 'package:superport_v2/features/inventory/lookups/domain/repositories/inventory_lookup_repository.dart'; +import 'package:superport_v2/widgets/components/superport_detail_dialog.dart'; import '../../helpers/test_app.dart'; import '../../helpers/fixture_loader.dart'; @@ -43,6 +44,19 @@ void main() { .toSet(); } + Future selectDetailTab(WidgetTester tester, String id) async { + final tabsFinder = find.descendant( + of: find.byType(SuperportDetailDialog), + matching: find.byType(ShadTabs), + ); + final tabsState = tester.state(tabsFinder); + final controller = + (tabsState as dynamic).controller as ShadTabsController; + controller.select(id); + await tester.pump(); + await tester.pumpAndSettle(); + } + setUpAll(() async { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true'); await Environment.initialize(); @@ -95,10 +109,7 @@ void main() { await tester.tap(rowFinder); await tester.pumpAndSettle(); - final tabContext = tester.element(find.byType(TabBar)); - final tabController = DefaultTabController.of(tabContext); - tabController.animateTo(1); - await tester.pumpAndSettle(); + await selectDetailTab(tester, 'steps'); final approveButton = tester.widget( find.byKey(const ValueKey('step_action_100_approve')), @@ -128,10 +139,7 @@ void main() { await tester.tap(rowFinder); await tester.pumpAndSettle(); - final tabContext = tester.element(find.byType(TabBar)); - final tabController = DefaultTabController.of(tabContext); - tabController.animateTo(1); - await tester.pumpAndSettle(); + await selectDetailTab(tester, 'steps'); final approveButton = tester.widget( find.byKey(const ValueKey('step_action_100_approve')), @@ -161,10 +169,7 @@ void main() { await tester.tap(rowFinder); await tester.pumpAndSettle(); - final tabContext = tester.element(find.byType(TabBar)); - final tabController = DefaultTabController.of(tabContext); - tabController.animateTo(1); - await tester.pumpAndSettle(); + await selectDetailTab(tester, 'steps'); expect(find.textContaining('선행 단계가 완료되지 않았습니다.'), findsWidgets); diff --git a/test/features/approvals/history/presentation/dialogs/approval_history_detail_dialog_test.dart b/test/features/approvals/history/presentation/dialogs/approval_history_detail_dialog_test.dart new file mode 100644 index 0000000..d71f910 --- /dev/null +++ b/test/features/approvals/history/presentation/dialogs/approval_history_detail_dialog_test.dart @@ -0,0 +1,555 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/recall_approval_use_case.dart'; +import 'package:superport_v2/features/approvals/domain/usecases/resubmit_approval_use_case.dart'; +import 'package:superport_v2/features/approvals/history/domain/entities/approval_history_record.dart'; +import 'package:superport_v2/features/approvals/history/domain/repositories/approval_history_repository.dart'; +import 'package:superport_v2/features/approvals/history/presentation/controllers/approval_history_controller.dart'; +import 'package:superport_v2/features/approvals/history/presentation/dialogs/approval_history_detail_dialog.dart'; +import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + + late _FakeApprovalRepository approvalRepository; + late _FakeApprovalHistoryRepository historyRepository; + late ApprovalHistoryController controller; + late ApprovalHistoryRecord record; + late AuthenticatedUser user; + late Approval sampleApproval; + late ApprovalStatus statusInProgress; + late ApprovalStatus statusCompleted; + late ApprovalStatus statusRejected; + + setUp(() { + approvalRepository = _FakeApprovalRepository(); + historyRepository = _FakeApprovalHistoryRepository(); + + statusInProgress = ApprovalStatus( + id: 1, + name: '진행중', + color: '#0EA5E9', + isTerminal: false, + ); + statusCompleted = ApprovalStatus( + id: 2, + name: '완료', + color: '#22C55E', + isTerminal: true, + ); + statusRejected = ApprovalStatus( + id: 3, + name: '반려', + color: '#F97316', + isTerminal: true, + ); + + final requester = ApprovalRequester( + id: 901, + employeeNo: 'E901', + name: '상신자', + ); + final approver1 = ApprovalApprover( + id: 801, + employeeNo: 'E801', + name: '선결자', + ); + final approver2 = ApprovalApprover( + id: 802, + employeeNo: 'E802', + name: '최종자', + ); + + final steps = [ + ApprovalStep( + id: 1001, + stepOrder: 1, + approver: approver1, + status: statusInProgress, + assignedAt: DateTime(2024, 1, 10, 9), + note: '1단계', + ), + ApprovalStep( + id: 1002, + stepOrder: 2, + approver: approver2, + status: statusCompleted, + assignedAt: DateTime(2024, 1, 10, 10), + decidedAt: DateTime(2024, 1, 10, 12), + note: '2단계', + ), + ]; + + final histories = [ + ApprovalHistory( + id: 5001, + action: ApprovalAction(id: 11, name: '상신', code: 'submit'), + fromStatus: null, + toStatus: statusInProgress, + approver: approver1, + actionAt: DateTime(2024, 1, 10, 9, 5), + note: '상신 완료', + ), + ApprovalHistory( + id: 5002, + action: ApprovalAction(id: 12, name: '승인', code: 'approve'), + fromStatus: statusInProgress, + toStatus: statusCompleted, + approver: approver2, + actionAt: DateTime(2024, 1, 10, 12, 30), + note: '승인 완료', + ), + ]; + + sampleApproval = Approval( + id: 300, + approvalNo: 'APP-2024-0300', + transactionNo: 'TRX-0300', + transactionUpdatedAt: DateTime(2024, 1, 10, 12, 45), + status: statusInProgress, + requester: requester, + requestedAt: DateTime(2024, 1, 10, 8, 30), + steps: steps, + histories: histories, + updatedAt: DateTime(2024, 1, 10, 12, 50), + ); + + approvalRepository + ..detail = sampleApproval + ..recallResult = sampleApproval + ..resubmitResult = sampleApproval.copyWith( + status: statusInProgress, + updatedAt: DateTime(2024, 1, 10, 13), + ) + ..historyResult = PaginatedResult( + items: histories, + page: 1, + pageSize: 20, + total: histories.length, + ); + + historyRepository.listResult = PaginatedResult( + items: [ + ApprovalHistoryRecord( + id: 700, + approvalId: sampleApproval.id!, + approvalNo: sampleApproval.approvalNo, + stepOrder: 1, + action: ApprovalAction(id: 21, name: '상신 완료', code: 'submit'), + fromStatus: null, + toStatus: statusInProgress, + approver: approver1, + actionAt: DateTime(2024, 1, 10, 9, 5), + note: '상신 후 대기 중', + ), + ], + page: 1, + pageSize: 20, + total: 1, + ); + + controller = ApprovalHistoryController( + repository: historyRepository, + approvalRepository: approvalRepository, + recallUseCase: RecallApprovalUseCase(repository: approvalRepository), + resubmitUseCase: ResubmitApprovalUseCase(repository: approvalRepository), + ); + + record = historyRepository.listResult!.items.first; + + user = const AuthenticatedUser( + id: 42, + name: '결재자', + employeeNo: 'E042', + ); + }); + + tearDown(() { + controller.dispose(); + }); + + Future openDialog(WidgetTester tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showApprovalHistoryDetailDialog( + context: context, + controller: controller, + record: record, + dateFormat: dateFormat, + currentUser: user, + ), + ); + + await tester.pump(); + await tester.pumpAndSettle(); + } + + testWidgets( + 'showApprovalHistoryDetailDialog 결재 요약과 타임라인을 표시한다', + (tester) async { + await openDialog(tester); + + expect(find.text('결재 이력 상세'), findsOneWidget); + expect(find.textContaining('결재번호 ${record.approvalNo}'), findsWidgets); + expect(find.text('상태 타임라인'), findsOneWidget); + expect(find.text('감사 로그'), findsOneWidget); + + expect( + find.textContaining( + '상신자 ${sampleApproval.requester.name} (${sampleApproval.requester.employeeNo})', + ), + findsOneWidget, + ); + expect( + find.textContaining('총 ${sampleApproval.steps.length}단계'), + findsOneWidget, + ); + expect(find.text('승인'), findsWidgets); + + expect(approvalRepository.listHistoryCalls, isNotEmpty); + }, + ); + + testWidgets( + '회수 버튼을 누르면 recallApproval이 호출되어 감사 로그가 새로고침된다', + (tester) async { + await openDialog(tester); + + final recallButton = find.widgetWithText(ShadButton, '회수'); + expect(recallButton, findsOneWidget); + await tester.ensureVisible(recallButton); + await tester.tap(recallButton, warnIfMissed: false); + await tester.pumpAndSettle(); + + final dialogFinder = find.ancestor( + of: find.text('결재 회수'), + matching: find.byType(SuperportDialog), + ); + expect(dialogFinder, findsOneWidget); + + final memoField = find.descendant( + of: dialogFinder, + matching: find.byType(ShadTextarea), + ); + expect(memoField, findsOneWidget); + await tester.enterText(memoField, '긴급 회수'); + + final confirmButton = find.descendant( + of: dialogFinder, + matching: find.widgetWithText(ShadButton, '회수'), + ); + await tester.tap(confirmButton, warnIfMissed: false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(approvalRepository.recallInputs, hasLength(1)); + expect(approvalRepository.recallInputs.first.note, '긴급 회수'); + expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2)); + }, + ); + + testWidgets( + '재상신 버튼을 누르면 resubmitApproval이 호출되어 최신 단계 정보가 전달된다', + (tester) async { + final rejectedApproval = sampleApproval.copyWith( + status: statusRejected, + steps: sampleApproval.steps + .map( + (step) => step.stepOrder == 1 + ? step.copyWith( + status: statusRejected, + decidedAt: DateTime(2024, 1, 10, 11), + ) + : step, + ) + .toList(growable: false), + ); + final resubmittedStatus = ApprovalStatus( + id: 4, + name: '재상신', + color: '#6366F1', + isTerminal: false, + ); + final resubmittedApproval = rejectedApproval.copyWith( + status: resubmittedStatus, + updatedAt: DateTime(2024, 1, 10, 13, 10), + ); + + approvalRepository + ..detail = rejectedApproval + ..resubmitResult = resubmittedApproval; + record = record.copyWith( + action: ApprovalAction(id: 33, name: '반려', code: 'reject'), + toStatus: statusRejected, + stepOrder: 2, + ); + + await openDialog(tester); + + final resubmitButton = find.widgetWithText(ShadButton, '재상신'); + expect(resubmitButton, findsOneWidget); + await tester.ensureVisible(resubmitButton); + await tester.tap(resubmitButton, warnIfMissed: false); + await tester.pumpAndSettle(); + + final dialogFinder = find.ancestor( + of: find.text('결재 재상신'), + matching: find.byType(SuperportDialog), + ); + expect(dialogFinder, findsOneWidget); + + final memoField = find.descendant( + of: dialogFinder, + matching: find.byType(ShadTextarea), + ); + expect(memoField, findsOneWidget); + await tester.enterText(memoField, '재상신 메모'); + + final confirmButton = find.descendant( + of: dialogFinder, + matching: find.widgetWithText(ShadButton, '재상신'), + ); + await tester.tap(confirmButton, warnIfMissed: false); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pumpAndSettle(); + + expect(approvalRepository.resubmitInputs, hasLength(1)); + final input = approvalRepository.resubmitInputs.first; + expect(input.note, '재상신 메모'); + expect(input.submission.steps.length, rejectedApproval.steps.length); + expect( + input.submission.steps.first.stepOrder, + rejectedApproval.steps.first.stepOrder, + ); + expect(approvalRepository.listHistoryCalls.length, greaterThanOrEqualTo(2)); + }, + ); +} + +class _FakeApprovalRepository implements ApprovalRepository { + Approval? detail; + Approval? recallResult; + Approval? resubmitResult; + PaginatedResult? historyResult; + + final List recallInputs = []; + final List resubmitInputs = []; + final List fetchDetailIds = []; + final List<_ListHistoryCall> listHistoryCalls = []; + + @override + Future assignSteps(ApprovalStepAssignmentInput input) { + throw UnimplementedError(); + } + + @override + Future fetchDetail( + int id, { + bool includeSteps = true, + bool includeHistories = true, + }) async { + fetchDetailIds.add(id); + final result = detail; + if (result == null) { + throw StateError('detail이 설정되지 않았습니다.'); + } + return result; + } + + @override + Future canProceed(int id) { + throw UnimplementedError(); + } + + @override + Future> listActions({bool activeOnly = true}) { + throw UnimplementedError(); + } + + @override + Future performStepAction(ApprovalStepActionInput input) { + throw UnimplementedError(); + } + + @override + Future> list({ + int page = 1, + int pageSize = 20, + int? transactionId, + int? approvalStatusId, + int? requestedById, + List? statusCodes, + bool includePending = false, + bool includeHistories = false, + bool includeSteps = false, + }) { + throw UnimplementedError(); + } + + @override + Future submit(ApprovalSubmissionInput input) { + throw UnimplementedError(); + } + + @override + Future resubmit(ApprovalResubmissionInput input) async { + resubmitInputs.add(input); + final result = resubmitResult ?? detail; + if (result == null) { + throw StateError('resubmitResult가 설정되지 않았습니다.'); + } + return result; + } + + @override + Future approve(ApprovalDecisionInput input) { + throw UnimplementedError(); + } + + @override + Future reject(ApprovalDecisionInput input) { + throw UnimplementedError(); + } + + @override + Future recall(ApprovalRecallInput input) async { + recallInputs.add(input); + final result = recallResult ?? detail; + if (result == null) { + throw StateError('recallResult가 설정되지 않았습니다.'); + } + return result; + } + + @override + Future> listHistory({ + required int approvalId, + int page = 1, + int pageSize = 20, + DateTime? from, + DateTime? to, + int? actorId, + int? approvalActionId, + }) async { + listHistoryCalls.add( + _ListHistoryCall( + approvalId: approvalId, + page: page, + pageSize: pageSize, + actorId: actorId, + approvalActionId: approvalActionId, + ), + ); + return historyResult ?? + PaginatedResult( + items: const [], + page: page, + pageSize: pageSize, + total: 0, + ); + } + + @override + Future create(ApprovalCreateInput input) { + throw UnimplementedError(); + } + + @override + Future update(ApprovalUpdateInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } +} + +class _FakeApprovalHistoryRepository implements ApprovalHistoryRepository { + PaginatedResult? listResult; + final List<_HistoryListCall> listCalls = []; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + int? approvalActionId, + DateTime? from, + DateTime? to, + }) async { + listCalls.add( + _HistoryListCall( + page: page, + pageSize: pageSize, + query: query, + approvalActionId: approvalActionId, + from: from, + to: to, + ), + ); + return listResult ?? + PaginatedResult( + items: const [], + page: page, + pageSize: pageSize, + total: 0, + ); + } +} + +class _ListHistoryCall { + _ListHistoryCall({ + required this.approvalId, + required this.page, + required this.pageSize, + this.actorId, + this.approvalActionId, + }); + + final int approvalId; + final int page; + final int pageSize; + final int? actorId; + final int? approvalActionId; +} + +class _HistoryListCall { + _HistoryListCall({ + required this.page, + required this.pageSize, + this.query, + this.approvalActionId, + this.from, + this.to, + }); + + final int page; + final int pageSize; + final String? query; + final int? approvalActionId; + final DateTime? from; + final DateTime? to; +} diff --git a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart index b0918f1..ad9bf64 100644 --- a/test/features/approvals/history/presentation/pages/approval_history_page_test.dart +++ b/test/features/approvals/history/presentation/pages/approval_history_page_test.dart @@ -395,9 +395,10 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - final recallButton = find.widgetWithText(ShadButton, '회수').first; - await tester.ensureVisible(recallButton); - await tester.tap(recallButton); + await tester.tap(find.widgetWithText(ShadButton, '회수').first); + await tester.pump(); + await tester.pumpAndSettle(); + await tester.pumpAndSettle(); expect(find.text('결재 회수'), findsOneWidget); @@ -406,8 +407,120 @@ void main() { await tester.tap(confirmButton); await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 500)); expect(fetchCount, equals(2)); - expect(find.text('결재 상세를 새로고침하지 못했습니다. 다시 시도해 주세요.'), findsOneWidget); + expect(find.textContaining('결재 상세를 새로고침하지 못했습니다'), findsOneWidget); + + await tester.tap(find.byTooltip('닫기')); + await tester.pumpAndSettle(); + }); + + testWidgets('회수 성공 시 토스트를 노출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + + when( + () => historyRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + approvalActionId: any(named: 'approvalActionId'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [record], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + final recallable = recallableFlow(); + when( + () => approvalRepository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => recallable.approval); + + when(() => recallUseCase.call(any())).thenAnswer((_) async => recallable); + + await tester.pumpWidget(_buildApp(const ApprovalHistoryPage())); + await tester.pumpAndSettle(); + + final table = tester.widget(find.byType(SuperportTable)); + table.onRowTap?.call(0); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ShadButton, '회수').first); + await tester.pumpAndSettle(); + + expect(find.text('결재 회수'), findsOneWidget); + await tester.tap(find.widgetWithText(ShadButton, '회수').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('결재(APP-2024-0001) 회수를 완료했습니다.'), findsOneWidget); + verify(() => recallUseCase.call(any())).called(1); + + await tester.tap(find.byTooltip('닫기')); + await tester.pumpAndSettle(); + }); + + testWidgets('재상신 성공 시 토스트를 노출한다', (tester) async { + dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); + + when( + () => historyRepository.list( + page: any(named: 'page'), + pageSize: any(named: 'pageSize'), + query: any(named: 'query'), + approvalActionId: any(named: 'approvalActionId'), + from: any(named: 'from'), + to: any(named: 'to'), + ), + ).thenAnswer( + (_) async => PaginatedResult( + items: [record], + page: 1, + pageSize: 20, + total: 1, + ), + ); + + final flow = resubmittableFlow(); + when( + () => approvalRepository.fetchDetail( + any(), + includeSteps: any(named: 'includeSteps'), + includeHistories: any(named: 'includeHistories'), + ), + ).thenAnswer((_) async => flow.approval); + + when(() => resubmitUseCase.call(any())).thenAnswer((_) async => flow); + + await tester.pumpWidget(_buildApp(const ApprovalHistoryPage())); + await tester.pumpAndSettle(); + + final table = tester.widget(find.byType(SuperportTable)); + table.onRowTap?.call(0); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(ShadButton, '재상신').first); + await tester.pumpAndSettle(); + + expect(find.text('결재 재상신'), findsOneWidget); + await tester.tap(find.widgetWithText(ShadButton, '재상신').last); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(find.text('결재(APP-2024-0001) 재상신을 완료했습니다.'), findsOneWidget); + verify(() => resubmitUseCase.call(any())).called(1); + + await tester.tap(find.byTooltip('닫기')); + await tester.pumpAndSettle(); }); } diff --git a/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart b/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart new file mode 100644 index 0000000..6a1bb26 --- /dev/null +++ b/test/features/approvals/presentation/dialogs/approval_detail_dialog_test.dart @@ -0,0 +1,388 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/common/models/paginated_result.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_proceed_status.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_repository.dart'; +import 'package:superport_v2/features/approvals/domain/repositories/approval_template_repository.dart'; +import 'package:superport_v2/features/approvals/presentation/controllers/approval_controller.dart'; +import 'package:superport_v2/features/approvals/presentation/dialogs/approval_detail_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; + +import '../../../../helpers/test_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + + late _FakeApprovalRepository approvalRepository; + late _FakeApprovalTemplateRepository templateRepository; + late ApprovalController controller; + late Approval sampleApproval; + late ApprovalTemplate sampleTemplate; + + setUp(() async { + approvalRepository = _FakeApprovalRepository(); + templateRepository = _FakeApprovalTemplateRepository(); + + controller = ApprovalController( + approvalRepository: approvalRepository, + templateRepository: templateRepository, + ); + + final statusInProgress = ApprovalStatus(id: 1, name: '진행중'); + final requester = ApprovalRequester( + id: 99, + employeeNo: 'E099', + name: '요청자', + ); + final approver = ApprovalApprover(id: 77, employeeNo: 'E077', name: '승인자'); + + final step = ApprovalStep( + id: 501, + stepOrder: 1, + approver: approver, + status: statusInProgress, + assignedAt: DateTime(2024, 1, 1, 9), + ); + + sampleApproval = Approval( + id: 100, + approvalNo: 'APP-2024-0100', + transactionNo: 'TRX-100', + status: statusInProgress, + requester: requester, + requestedAt: DateTime(2024, 1, 1, 9), + steps: [step], + histories: const [], + ); + + approvalRepository.detail = sampleApproval; + approvalRepository.updatedAfterAssign = sampleApproval; + approvalRepository.updatedAfterAction = sampleApproval; + + sampleTemplate = ApprovalTemplate( + id: 200, + code: 'TEMP-200', + name: '샘플 템플릿', + isActive: true, + steps: [ + ApprovalTemplateStep( + id: null, + stepOrder: 1, + approver: ApprovalTemplateApprover( + id: 42, + employeeNo: 'E042', + name: '템플 승인자', + ), + ), + ], + ); + + templateRepository + ..listResult = [sampleTemplate] + ..detailResult = sampleTemplate; + + approvalRepository.actions = [ + ApprovalAction(id: 1, name: '승인', code: 'approve'), + ApprovalAction(id: 2, name: '반려', code: 'reject'), + ApprovalAction(id: 3, name: '코멘트', code: 'comment'), + ]; + + expect(controller.templates, isEmpty); + + await controller.loadTemplates(force: true); + await controller.loadActionOptions(force: true); + await controller.selectApproval(sampleApproval.id!); + expect(controller.templates, isNotEmpty); + expect(controller.selected, isNotNull); + expect(controller.canProceedSelected, isTrue); + expect(controller.hasActionOptions, isTrue); + }); + + tearDown(() { + controller.dispose(); + }); + + Future openDialog(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showApprovalDetailDialog( + context: context, + controller: controller, + dateFormat: dateFormat, + canPerformStepActions: true, + canApplyTemplate: true, + ), + ); + await tester.pumpAndSettle(); + } + + testWidgets('showApprovalDetailDialog 결재 요약과 템플릿 선택을 표시한다', (tester) async { + await openDialog(tester); + + expect( + find.textContaining('결재번호 ${sampleApproval.approvalNo}'), + findsOneWidget, + ); + expect(find.textContaining(sampleApproval.transactionNo), findsWidgets); + + final stepsTab = find.text('단계'); + await tester.tap(stepsTab, warnIfMissed: false); + await tester.pumpAndSettle(); + expect(find.text('Step 1'), findsOneWidget); + expect(find.text('템플릿 적용'), findsOneWidget); + }); + + testWidgets('템플릿 적용 버튼을 누르면 assignSteps가 호출되고 성공 토스트를 노출한다', (tester) async { + await openDialog(tester); + + await tester.tap(find.text('단계'), warnIfMissed: false); + await tester.pumpAndSettle(); + + final applyButton = find.byKey( + const ValueKey('approval_apply_template_button'), + ); + expect(applyButton, findsOneWidget); + await tester.ensureVisible(applyButton); + await tester.tap(applyButton, warnIfMissed: false); + await tester.pumpAndSettle(); + await tester.pump(); + + expect(approvalRepository.assignmentInputs, hasLength(1)); + expect( + approvalRepository.assignmentInputs.first.steps.first.approverId, + sampleTemplate.steps.first.approver.id, + ); + }); + + testWidgets('승인 버튼을 누르면 performStepAction이 호출되고 성공 메시지를 노출한다', ( + tester, + ) async { + await openDialog(tester); + + await tester.tap(find.text('단계'), warnIfMissed: false); + await tester.pumpAndSettle(); + + final stepText = find.text('Step 1').first; + final stepCard = find + .ancestor(of: stepText, matching: find.byType(ShadCard)) + .first; + expect(stepCard, findsOneWidget); + + final approveButtonFinder = find.byKey( + const ValueKey('step_action_501_approve'), + ); + expect(approveButtonFinder, findsOneWidget); + final approveButton = tester.widget(approveButtonFinder); + expect(approveButton.onPressed, isNotNull); + await tester.ensureVisible(approveButtonFinder); + await tester.tap(approveButtonFinder, warnIfMissed: false); + await tester.pumpAndSettle(); + + final confirmDialog = find + .ancestor( + of: find.text('결재 승인'), + matching: find.byType(SuperportDialog), + ) + .first; + + final noteField = find.descendant( + of: confirmDialog, + matching: find.byType(EditableText), + ); + await tester.enterText(noteField, '승인 메모'); + + final confirmButton = find + .descendant( + of: confirmDialog, + matching: find.widgetWithText(ShadButton, '승인'), + ) + .first; + await tester.tap(confirmButton, warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(approvalRepository.actionInputs, hasLength(1)); + expect(approvalRepository.actionInputs.first.actionId, 1); + }); +} + +class _FakeApprovalRepository implements ApprovalRepository { + Approval? detail; + Approval? updatedAfterAssign; + Approval? updatedAfterAction; + bool canProceedResult = true; + List actions = const []; + + final List assignmentInputs = []; + final List actionInputs = []; + + @override + Future assignSteps(ApprovalStepAssignmentInput input) async { + assignmentInputs.add(input); + return updatedAfterAssign ?? detail!; + } + + @override + Future fetchDetail( + int id, { + bool includeSteps = true, + bool includeHistories = true, + }) async { + return detail!; + } + + @override + Future canProceed(int id) async { + return ApprovalProceedStatus(approvalId: id, canProceed: canProceedResult); + } + + @override + Future> listActions({bool activeOnly = true}) async { + return actions; + } + + @override + Future performStepAction(ApprovalStepActionInput input) async { + actionInputs.add(input); + return updatedAfterAction ?? detail!; + } + + @override + Future> list({ + int page = 1, + int pageSize = 20, + int? transactionId, + int? approvalStatusId, + int? requestedById, + List? statusCodes, + bool includePending = false, + bool includeHistories = false, + bool includeSteps = false, + }) { + throw UnimplementedError(); + } + + @override + Future submit(ApprovalSubmissionInput input) { + throw UnimplementedError(); + } + + @override + Future resubmit(ApprovalResubmissionInput input) { + throw UnimplementedError(); + } + + @override + Future approve(ApprovalDecisionInput input) { + throw UnimplementedError(); + } + + @override + Future reject(ApprovalDecisionInput input) { + throw UnimplementedError(); + } + + @override + Future recall(ApprovalRecallInput input) { + throw UnimplementedError(); + } + + @override + Future> listHistory({ + required int approvalId, + int page = 1, + int pageSize = 20, + DateTime? from, + DateTime? to, + int? actorId, + int? approvalActionId, + }) { + throw UnimplementedError(); + } + + @override + Future create(ApprovalCreateInput input) { + throw UnimplementedError(); + } + + @override + Future update(ApprovalUpdateInput input) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } +} + +class _FakeApprovalTemplateRepository implements ApprovalTemplateRepository { + List listResult = const []; + ApprovalTemplate? detailResult; + + @override + Future> list({ + int page = 1, + int pageSize = 20, + String? query, + bool? isActive, + }) async { + return PaginatedResult( + items: listResult, + page: page, + pageSize: pageSize, + total: listResult.length, + ); + } + + @override + Future fetchDetail( + int id, { + bool includeSteps = true, + }) async { + return detailResult!; + } + + @override + Future create( + ApprovalTemplateInput input, { + List steps = const [], + }) { + throw UnimplementedError(); + } + + @override + Future update( + int id, + ApprovalTemplateInput input, { + List? steps, + }) { + throw UnimplementedError(); + } + + @override + Future delete(int id) { + throw UnimplementedError(); + } + + @override + Future restore(int id) { + throw UnimplementedError(); + } +} diff --git a/test/features/approvals/step/presentation/dialogs/approval_step_detail_dialog_test.dart b/test/features/approvals/step/presentation/dialogs/approval_step_detail_dialog_test.dart new file mode 100644 index 0000000..8c2bff2 --- /dev/null +++ b/test/features/approvals/step/presentation/dialogs/approval_step_detail_dialog_test.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval.dart'; +import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_input.dart'; +import 'package:superport_v2/features/approvals/step/domain/entities/approval_step_record.dart'; +import 'package:superport_v2/features/approvals/step/presentation/dialogs/approval_step_detail_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_detail_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + + late ApprovalStepRecord sampleRecord; + late ApprovalApprover approver; + + setUp(() { + final statusPending = ApprovalStatus( + id: 1, + name: '대기', + color: '#0EA5E9', + isTerminal: false, + ); + approver = ApprovalApprover(id: 800, employeeNo: 'E800', name: '김승인'); + + final step = ApprovalStep( + id: 900, + stepOrder: 1, + approver: approver, + status: statusPending, + assignedAt: DateTime(2024, 1, 10, 9), + ); + + sampleRecord = ApprovalStepRecord( + approvalId: 400, + approvalNo: 'APP-2024-0400', + transactionNo: 'TRX-400', + templateName: '일반 결재선', + step: step, + ); + }); + + testWidgets('결재 단계 상세 다이얼로그는 요약과 메타 정보를 노출한다', (tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + final resultFuture = showApprovalStepDetailDialog( + context: context, + record: sampleRecord, + dateFormat: dateFormat, + onUpdate: (_, __) => Future.value(sampleRecord), + onDelete: (_) => Future.value(true), + onRestore: (_) => Future.value(sampleRecord), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('결재 단계 상세'), findsAtLeastNWidgets(1)); + expect(find.text('결재 단계 1'), findsAtLeastNWidgets(1)); + expect(find.textContaining(approver.name), findsAtLeastNWidgets(1)); + expect(find.text('단계 순서'), findsWidgets); + expect(find.text('상태'), findsWidgets); + expect(find.text('승인자 사번'), findsWidgets); + + await tester.tap(find.byTooltip('닫기'), warnIfMissed: false); + await tester.pumpAndSettle(); + expect(await resultFuture, isNull); + }); + + testWidgets('수정 탭에서 저장을 누르면 onUpdate 콜백이 입력값과 함께 호출된다', (tester) async { + ApprovalStepInput? capturedInput; + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + final resultFuture = showApprovalStepDetailDialog( + context: context, + record: sampleRecord, + dateFormat: dateFormat, + onUpdate: (_, input) async { + capturedInput = input; + return sampleRecord.copyWith( + step: sampleRecord.step.copyWith( + stepOrder: input.stepOrder, + approver: ApprovalApprover( + id: input.approverId, + employeeNo: 'E${input.approverId}', + name: '변경 승인자', + ), + note: input.note, + ), + ); + }, + onDelete: (_) => Future.value(false), + onRestore: (_) => Future.value(sampleRecord), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final tabsFinder = find.descendant( + of: find.byType(SuperportDetailDialog), + matching: find.byType(ShadTabs), + ); + final tabsState = tester.state(tabsFinder); + final controller = + (tabsState as dynamic).controller as ShadTabsController; + controller.select('edit'); + await tester.pump(const Duration(milliseconds: 100)); + + await tester.enterText( + find.byKey(const ValueKey('approval_step_detail_step_order')), + '3', + ); + await tester.enterText( + find.byKey(const ValueKey('approval_step_detail_approver_id')), + '901', + ); + await tester.enterText( + find.byKey(const ValueKey('approval_step_detail_note')), + '조정된 결재 단계', + ); + + final submitButtonFinder = find.byKey( + const ValueKey('approval_step_detail_submit'), + ); + final submitButton = tester.widget(submitButtonFinder); + expect(submitButton.onPressed, isNotNull); + submitButton.onPressed!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final result = await resultFuture; + expect(result, isNotNull); + expect(result!.action, ApprovalStepDetailAction.updated); + expect(capturedInput, isNotNull); + expect(capturedInput!.stepOrder, 3); + expect(capturedInput!.approverId, 901); + expect(capturedInput!.note, '조정된 결재 단계'); + }); + + testWidgets('삭제 탭에서 삭제 버튼을 누르면 onDelete 콜백 성공 시 결과가 반환된다', (tester) async { + var deleteCalled = false; + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + final resultFuture = showApprovalStepDetailDialog( + context: context, + record: sampleRecord, + dateFormat: dateFormat, + onUpdate: (_, __) => Future.value(sampleRecord), + onDelete: (_) async { + deleteCalled = true; + return true; + }, + onRestore: (_) => Future.value(sampleRecord), + ); + + await tester.pump(); + await tester.pumpAndSettle(); + + final tabsFinder = find.descendant( + of: find.byType(SuperportDetailDialog), + matching: find.byType(ShadTabs), + ); + final tabsState = tester.state(tabsFinder); + final controller = + (tabsState as dynamic).controller as ShadTabsController; + controller.select('delete'); + await tester.pump(const Duration(milliseconds: 100)); + + final deleteButtonFinder = find.byWidgetPredicate( + (widget) => + widget is ShadButton && + widget.variant == ShadButtonVariant.destructive && + widget.child is Text && + (widget.child as Text).data == '삭제', + ); + expect(deleteButtonFinder, findsOneWidget); + final deleteButton = tester.widget(deleteButtonFinder); + expect(deleteButton.onPressed, isNotNull); + deleteButton.onPressed!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final result = await resultFuture; + expect(deleteCalled, isTrue); + expect(result, isNotNull); + expect(result!.action, ApprovalStepDetailAction.deleted); + }); +} diff --git a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart index c774516..03ae15d 100644 --- a/test/features/approvals/step/presentation/pages/approval_step_page_test.dart +++ b/test/features/approvals/step/presentation/pages/approval_step_page_test.dart @@ -108,20 +108,18 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - expect(find.text('APP-2024-0001'), findsOneWidget); - expect(find.text('최승인'), findsOneWidget); + expect(find.text('APP-2024-0001'), findsWidgets); + expect(find.text('최승인'), findsWidgets); - final detailButtonFinder = find.byKey(const ValueKey('step_detail_501_1')); - final detailButton = tester.widget(detailButtonFinder); - detailButton.onPressed?.call(); + await tester.tap(find.text('APP-2024-0001')); await tester.pump(); await tester.pumpAndSettle(); - expect(find.text('결재 단계 상세'), findsOneWidget); - expect(find.text('검토 필요'), findsOneWidget); + expect(find.text('결재 단계 상세'), findsWidgets); + expect(find.text('검토 필요'), findsWidgets); verify(() => repository.fetchDetail(501)).called(1); - await tester.tap(find.text('닫기')); + await tester.tap(find.byTooltip('닫기')); await tester.pumpAndSettle(); }); @@ -198,7 +196,7 @@ void main() { expect(find.text('결재번호 APP-2024-0012 단계가 추가되었습니다.'), findsOneWidget); }); - testWidgets('단계 수정 다이얼로그에서 저장을 호출한다', (tester) async { + testWidgets('상세 다이얼로그에서 단계를 수정한다', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); repository = _MockApprovalStepRepository(); GetIt.I.registerLazySingleton(() => repository); @@ -237,6 +235,7 @@ void main() { ), ); + when(() => repository.fetchDetail(any())).thenAnswer((_) async => record); when( () => repository.update(any(), any()), ).thenAnswer((_) async => updatedRecord); @@ -245,36 +244,43 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - final editButtonFinder = find.byKey( - ValueKey('step_edit_${record.step.id}_${record.step.stepOrder}'), - ); - final editButton = tester.widget(editButtonFinder); - editButton.onPressed?.call(); + await tester.tap(find.text('APP-2024-0001')); + await tester.pump(); + await tester.pumpAndSettle(); + + final editTabsState = + tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs)) + as dynamic; + editTabsState.controller.select('edit'); await tester.pump(); await tester.pumpAndSettle(); await tester.enterText( - find.byKey(const ValueKey('step_form_step_order')), + find.byKey(const ValueKey('approval_step_detail_step_order')), '2', ); await tester.enterText( - find.byKey(const ValueKey('step_form_approver_id')), + find.byKey(const ValueKey('approval_step_detail_approver_id')), '30', ); - await tester.enterText(find.byKey(const ValueKey('step_form_note')), '수정됨'); + await tester.enterText( + find.byKey(const ValueKey('approval_step_detail_note')), + '수정됨', + ); - await tester.tap(find.byKey(const ValueKey('step_form_submit'))); + final submitButtonFinder = find.byKey( + const ValueKey('approval_step_detail_submit'), + ); + final submitButton = tester.widget(submitButtonFinder); + submitButton.onPressed?.call(); await tester.pump(); await tester.pumpAndSettle(); verify(() => repository.update(record.step.id!, any())).called(1); - expect( - find.text('결재번호 ${record.approvalNo} 단계 정보를 수정했습니다.'), - findsOneWidget, - ); + expect(find.text('결재 단계 정보를 수정했습니다.'), findsOneWidget); }); - testWidgets('삭제 버튼 확인 후 저장소 삭제를 호출한다', (tester) async { + testWidgets('상세 다이얼로그에서 단계를 삭제한다', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); repository = _MockApprovalStepRepository(); GetIt.I.registerLazySingleton(() => repository); @@ -297,26 +303,37 @@ void main() { ), ); + when(() => repository.fetchDetail(any())).thenAnswer((_) async => record); when(() => repository.delete(any())).thenAnswer((_) async {}); await tester.pumpWidget(_buildApp(const ApprovalStepPage())); await tester.pump(); await tester.pumpAndSettle(); - final deleteFinder = find.byKey(const ValueKey('step_delete_501_1')); - final deleteButton = tester.widget(deleteFinder); + await tester.tap(find.text('APP-2024-0001')); + await tester.pump(); + await tester.pumpAndSettle(); + + final deleteTabsState = + tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs)) + as dynamic; + deleteTabsState.controller.select('delete'); + await tester.pump(); + await tester.pumpAndSettle(); + + final deleteButtonFinder = find.byKey( + const ValueKey('approval_step_detail_delete'), + ); + final deleteButton = tester.widget(deleteButtonFinder); deleteButton.onPressed?.call(); await tester.pump(); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ShadButton, '삭제').last); - await tester.pump(); - await tester.pumpAndSettle(); - verify(() => repository.delete(501)).called(1); + expect(find.text('결재 단계를 삭제했습니다.'), findsOneWidget); }); - testWidgets('복구 버튼 확인 후 저장소 복구를 호출한다', (tester) async { + testWidgets('상세 다이얼로그에서 삭제된 단계를 복구한다', (tester) async { dotenv.testLoad(fileInput: 'FEATURE_APPROVALS_ENABLED=true\n'); repository = _MockApprovalStepRepository(); GetIt.I.registerLazySingleton(() => repository); @@ -343,22 +360,35 @@ void main() { ), ); + when( + () => repository.fetchDetail(any()), + ).thenAnswer((_) async => deletedRecord); when(() => repository.restore(any())).thenAnswer((_) async => record); await tester.pumpWidget(_buildApp(const ApprovalStepPage())); await tester.pump(); await tester.pumpAndSettle(); - final restoreFinder = find.byKey(const ValueKey('step_restore_501_1')); - final restoreButton = tester.widget(restoreFinder); + await tester.tap(find.text('APP-2024-0001')); + await tester.pump(); + await tester.pumpAndSettle(); + + final tabsState = + tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs)) + as dynamic; + tabsState.controller.select('restore'); + await tester.pump(); + await tester.pumpAndSettle(); + + final restoreButtonFinder = find.byKey( + const ValueKey('approval_step_detail_restore'), + ); + final restoreButton = tester.widget(restoreButtonFinder); restoreButton.onPressed?.call(); await tester.pump(); await tester.pumpAndSettle(); - await tester.tap(find.widgetWithText(ShadButton, '복구').last); - await tester.pump(); - await tester.pumpAndSettle(); - verify(() => repository.restore(501)).called(1); + expect(find.text('결재 단계를 복구했습니다.'), findsOneWidget); }); } diff --git a/test/features/approvals/template/presentation/dialogs/approval_template_detail_dialog_test.dart b/test/features/approvals/template/presentation/dialogs/approval_template_detail_dialog_test.dart new file mode 100644 index 0000000..1a4eeeb --- /dev/null +++ b/test/features/approvals/template/presentation/dialogs/approval_template_detail_dialog_test.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; +import 'package:superport_v2/core/services/token_storage.dart'; +import 'package:superport_v2/features/approvals/domain/entities/approval_template.dart'; +import 'package:superport_v2/features/approvals/template/presentation/dialogs/approval_template_detail_dialog.dart'; +import 'package:superport_v2/features/auth/application/auth_service.dart'; +import 'package:superport_v2/features/auth/domain/entities/auth_session.dart'; +import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart'; +import 'package:superport_v2/features/auth/domain/entities/login_request.dart'; +import 'package:superport_v2/features/auth/domain/repositories/auth_repository.dart'; +import 'package:superport_v2/widgets/components/superport_detail_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + + late ApprovalTemplate sampleTemplate; + late ApprovalTemplateApprover approver1; + late ApprovalTemplateApprover approver2; + + setUp(() { + final getIt = GetIt.I; + if (!getIt.isRegistered()) { + getIt.registerSingleton( + _StubAuthService( + AuthSession( + accessToken: 'access', + refreshToken: 'refresh', + expiresAt: DateTime.now().add(const Duration(hours: 1)), + user: const AuthenticatedUser( + id: 1, + name: '테스터', + employeeNo: 'E001', + ), + permissions: const [], + ), + ), + ); + } + + approver1 = ApprovalTemplateApprover( + id: 501, + employeeNo: 'E501', + name: '1차 승인자', + ); + approver2 = ApprovalTemplateApprover( + id: 502, + employeeNo: 'E502', + name: '2차 승인자', + ); + + sampleTemplate = ApprovalTemplate( + id: 10, + code: 'TEMP-10', + name: '자산 구매 결재', + description: '자산 구매 시 사용하는 기본 결재선', + note: '월별로 재검토 필요', + isActive: true, + createdAt: DateTime(2024, 1, 1, 9), + updatedAt: DateTime(2024, 1, 10, 10), + steps: [ + ApprovalTemplateStep( + id: 1001, + stepOrder: 1, + approver: approver1, + note: '팀 리더 확인', + ), + ApprovalTemplateStep( + id: 1002, + stepOrder: 2, + approver: approver2, + note: '경영진 승인', + ), + ], + ); + }); + + tearDown(() { + final getIt = GetIt.I; + if (getIt.isRegistered()) { + getIt.unregister(); + } + }); + + testWidgets('템플릿 상세 다이얼로그는 요약과 메타데이터를 info panel에 표시한다', (tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + final resultFuture = showApprovalTemplateDetailDialog( + context: context, + dateFormat: dateFormat, + template: sampleTemplate, + onCreate: (_, __) => Future.value(null), + onUpdate: (_, __, ___) => Future.value(sampleTemplate), + onDelete: (_) => Future.value(false), + onRestore: (_) => Future.value(null), + ); + + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.text('결재 템플릿 상세'), findsAtLeastNWidgets(1)); + expect(find.text(sampleTemplate.name), findsAtLeastNWidgets(1)); + expect(find.text('단계'), findsOneWidget); + expect(find.text('코드'), findsWidgets); + expect(find.text('TEMP-10'), findsWidgets); + expect(find.text('상태'), findsWidgets); + expect(find.text('생성일시'), findsWidgets); + + expect(find.byType(SuperportDetailDialog), findsOneWidget); + await tester.tap(find.byTooltip('닫기'), warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 100)); + expect(await resultFuture, isNull); + }); + + testWidgets('생성 모드에서 필수 정보를 입력하면 onCreate 콜백이 호출된다', (tester) async { + final createdTemplates = []; + final createdSteps = >[]; + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + final resultFuture = showApprovalTemplateDetailDialog( + context: context, + dateFormat: dateFormat, + template: null, + onCreate: (input, steps) async { + createdTemplates.add(input); + createdSteps.add(steps); + return sampleTemplate.copyWith(name: input.name); + }, + onUpdate: (_, __, ___) => Future.value(null), + onDelete: (_) => Future.value(false), + onRestore: (_) => Future.value(null), + ); + + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.enterText( + find.byKey(const ValueKey('template_form_name')), + '신규 결재 템플릿', + ); + await tester.enterText( + find.byKey(const ValueKey('template_step_0_order')), + '1', + ); + + final approverFieldFinder = find.byKey( + const ValueKey('template_step_0_approver'), + ); + final approverFieldState = tester.state(approverFieldFinder) as dynamic; + approverFieldState.widget.idController.text = approver1.id.toString(); + await tester.pump(); + + final submitButton = tester.widget( + find.widgetWithText(ShadButton, '등록'), + ); + expect(submitButton.onPressed, isNotNull); + submitButton.onPressed!(); + await tester.pump(); + await tester.pumpAndSettle(); + expect(createdTemplates, isNotEmpty); + + final result = await resultFuture; + expect(result, isNotNull); + expect(result!.action, ApprovalTemplateDetailAction.created); + expect(createdTemplates, hasLength(1)); + expect(createdTemplates.first.name, '신규 결재 템플릿'); + expect(createdSteps.single.first.approverId, approver1.id); + }); + + testWidgets('삭제 탭에서 삭제 버튼을 누르면 onDelete 콜백이 호출되어 결과가 반환된다', (tester) async { + var deleteCalled = false; + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + final resultFuture = showApprovalTemplateDetailDialog( + context: context, + dateFormat: dateFormat, + template: sampleTemplate, + onCreate: (_, __) => Future.value(null), + onUpdate: (_, __, ___) => Future.value(sampleTemplate), + onDelete: (_) async { + deleteCalled = true; + return true; + }, + onRestore: (_) => Future.value(null), + ); + + await tester.pump(); + await tester.pumpAndSettle(); + + final tabsFinder = find.descendant( + of: find.byType(SuperportDetailDialog), + matching: find.byType(ShadTabs), + ); + final tabsState = tester.state(tabsFinder); + final controller = + (tabsState as dynamic).controller as ShadTabsController; + controller.select('delete'); + await tester.pump(const Duration(milliseconds: 100)); + + final deleteButtonFinder = find.byWidgetPredicate( + (widget) => + widget is ShadButton && + widget.variant == ShadButtonVariant.destructive && + widget.child is Text && + (widget.child as Text).data == '삭제', + ); + expect(deleteButtonFinder, findsOneWidget); + final deleteButton = tester.widget(deleteButtonFinder); + expect(deleteButton.onPressed, isNotNull); + deleteButton.onPressed!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final result = await resultFuture; + expect(deleteCalled, isTrue); + expect(result, isNotNull); + expect(result!.action, ApprovalTemplateDetailAction.deleted); + }); +} + +class _StubAuthService extends AuthService { + _StubAuthService(this._session) + : super( + repository: _FakeAuthRepository(), + tokenStorage: _FakeTokenStorage(), + ); + + final AuthSession _session; + + @override + AuthSession? get session => _session; +} + +class _FakeAuthRepository implements AuthRepository { + @override + Future login(LoginRequest request) { + throw UnimplementedError(); + } + + @override + Future refresh(String refreshToken) { + throw UnimplementedError(); + } +} + +class _FakeTokenStorage implements TokenStorage { + String? access; + String? refresh; + + @override + Future clear() async { + access = null; + refresh = null; + } + + @override + Future readAccessToken() async => access; + + @override + Future readRefreshToken() async => refresh; + + @override + Future writeAccessToken(String? token) async { + access = token; + } + + @override + Future writeRefreshToken(String? token) async { + refresh = token; + } +} diff --git a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart index ba7f63e..2efa150 100644 --- a/test/features/approvals/template/presentation/pages/approval_template_page_test.dart +++ b/test/features/approvals/template/presentation/pages/approval_template_page_test.dart @@ -14,6 +14,7 @@ import 'package:superport_v2/features/approvals/domain/usecases/save_approval_te import 'package:superport_v2/features/approvals/template/presentation/pages/approval_template_page.dart'; import 'package:superport_v2/features/approvals/shared/domain/entities/approval_approver_candidate.dart'; import 'package:superport_v2/features/approvals/shared/domain/repositories/approval_approver_repository.dart'; +import 'package:superport_v2/features/approvals/shared/widgets/approver_autocomplete_field.dart'; import 'package:superport_v2/features/auth/application/auth_service.dart'; import 'package:superport_v2/features/auth/domain/entities/auth_session.dart'; import 'package:superport_v2/features/auth/domain/entities/authenticated_user.dart'; @@ -169,8 +170,8 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - expect(find.text('AP_INBOUND'), findsOneWidget); - expect(find.text('입고 템플릿'), findsOneWidget); + expect(find.text('AP_INBOUND'), findsWidgets); + expect(find.text('입고 템플릿'), findsWidgets); expect(find.textContaining('1. 최승인'), findsOneWidget); verify( @@ -198,7 +199,7 @@ void main() { when( () => repository.create(any(), steps: any(named: 'steps')), - ).thenAnswer((_) async => buildTemplate()); + ).thenAnswer((_) async => buildTemplate().copyWith(name: '신규 템플릿')); await tester.pumpWidget(_buildApp(const ApprovalTemplatePage())); await tester.pump(); @@ -207,43 +208,26 @@ void main() { await tester.tap(find.text('템플릿 생성')); await tester.pumpAndSettle(); - final dialogFieldsFinder = find.descendant( - of: find.byType(Dialog), - matching: find.byType(EditableText), - skipOffstage: false, - ); - final dialogFieldElements = dialogFieldsFinder.evaluate().toList(); - expect(dialogFieldElements.length, greaterThanOrEqualTo(4)); - await tester.enterText( - find.byWidget(dialogFieldElements[0].widget), - 'AP_NEW', - ); - await tester.enterText( - find.byWidget(dialogFieldElements[1].widget), + find.byKey(const ValueKey('template_form_name')), '신규 템플릿', ); - - final stepFieldsFinder = find.descendant( - of: find.byKey(const ValueKey('step_field_0')), - matching: find.byType(EditableText), - skipOffstage: false, + final approverField = tester.widget( + find.byType(ApprovalApproverAutocompleteField).first, ); - final stepFieldElements = stepFieldsFinder.evaluate().toList(); - expect(stepFieldElements.length, greaterThanOrEqualTo(2)); - - await tester.enterText(find.byWidget(stepFieldElements[1].widget), '33'); - await tester.testTextInput.receiveAction(TextInputAction.done); + approverField.idController.text = '33'; await tester.pump(); - await tester.tap(find.text('생성 완료')); + final createButtonFinder = find.widgetWithText(ShadButton, '등록').last; + final createButton = tester.widget(createButtonFinder); + createButton.onPressed?.call(); await tester.pump(); await tester.pumpAndSettle(); verify( () => repository.create(any(), steps: any(named: 'steps')), ).called(1); - expect(find.text('템플릿 "신규 템플릿"을 생성했습니다.'), findsOneWidget); + expect(find.textContaining('템플릿 "신규 템플릿"'), findsOneWidget); }); testWidgets('보기 버튼을 눌러 템플릿 단계를 미리본다', (tester) async { @@ -273,25 +257,25 @@ void main() { await tester.pump(); await tester.pumpAndSettle(); - final previewFinder = find.text('보기', skipOffstage: false); - - await tester.dragUntilVisible( - previewFinder, - find.text(template.name), - const Offset(-200, 0), - ); - await tester.pumpAndSettle(); - - await tester.tap(previewFinder); + await tester.tap(find.text(template.name)); await tester.pump(); await tester.pumpAndSettle(); expect(find.text(template.name), findsWidgets); + final detailTabsState = + tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs)) + as dynamic; + detailTabsState.controller.select('steps'); + await tester.pump(); + await tester.pumpAndSettle(); expect(find.textContaining('사번 E001'), findsOneWidget); verify( () => repository.fetchDetail(template.id, includeSteps: true), ).called(1); + + await tester.tap(find.byTooltip('닫기')); + await tester.pumpAndSettle(); }); testWidgets('수정 플로우에서 fetchDetail 후 update를 호출한다', (tester) async { @@ -334,31 +318,25 @@ void main() { repository.list(page: 1, pageSize: 20, query: null, isActive: null), ).called(1); - await tester.dragUntilVisible( - find.text('수정'), - find.text('입고 템플릿'), - const Offset(-200, 0), - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('수정').first); + await tester.tap(find.text('입고 템플릿')); await tester.pump(); await tester.pumpAndSettle(); - final editDialogFields = find.descendant( - of: find.byType(Dialog), - matching: find.byType(EditableText), - skipOffstage: false, - ); - final editFieldElements = editDialogFields.evaluate().toList(); - expect(editFieldElements.length, greaterThanOrEqualTo(1)); + final tabsState = + tester.state(find.byWidgetPredicate((widget) => widget is ShadTabs)) + as dynamic; + tabsState.controller.select('edit'); + await tester.pump(); + await tester.pumpAndSettle(); await tester.enterText( - find.byWidget(editFieldElements[0].widget), + find.byKey(const ValueKey('template_form_name')), '수정된 템플릿', ); - await tester.tap(find.text('수정 완료')); + final saveButtonFinder = find.widgetWithText(ShadButton, '저장').last; + final saveButton = tester.widget(saveButtonFinder); + saveButton.onPressed?.call(); await tester.pump(); await tester.pumpAndSettle(); @@ -367,7 +345,7 @@ void main() { () => repository.update(10, any(), steps: any(named: 'steps')), ).called(1); - expect(find.text('템플릿 "입고 템플릿"을(를) 수정했습니다.'), findsOneWidget); + expect(find.text('템플릿 "수정된 템플릿"을(를) 수정했습니다.'), findsOneWidget); }); }); } diff --git a/test/features/inventory/inbound/presentation/widgets/inbound_detail_view_test.dart b/test/features/inventory/inbound/presentation/widgets/inbound_detail_view_test.dart new file mode 100644 index 0000000..153876c --- /dev/null +++ b/test/features/inventory/inbound/presentation/widgets/inbound_detail_view_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; + +import 'package:superport_v2/features/inventory/inbound/presentation/models/inbound_record.dart'; +import 'package:superport_v2/features/inventory/inbound/presentation/widgets/inbound_detail_view.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final currencyFormatter = intl.NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); + + InboundRecord buildRecord() { + return InboundRecord( + id: 1, + number: 'IN-1', + transactionNumber: 'IN-1', + transactionType: '입고', + processedAt: DateTime(2024, 1, 1, 9), + warehouse: '서울 1창고', + status: '완료', + writer: '홍길동', + remark: '비고', + items: [ + InboundLineItem( + id: 10, + product: '테스트 제품', + manufacturer: '테스트 제조사', + unit: 'EA', + quantity: 3, + price: 1000, + remark: '정상', + ), + ], + ); + } + + testWidgets('InboundDetailView는 라인 품목과 경고 배지를 렌더링한다', (tester) async { + final record = buildRecord(); + + await tester.pumpWidget( + buildTestApp( + InboundDetailView( + record: record, + currencyFormatter: currencyFormatter, + transitionsEnabled: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('재고 상태 전이가 비활성화된 상태입니다.'), findsOneWidget); + expect(find.text('라인 품목'), findsOneWidget); + expect(find.text('테스트 제품'), findsOneWidget); + expect(find.text('테스트 제조사'), findsOneWidget); + expect(find.text('EA'), findsOneWidget); + expect(find.text('3'), findsWidgets); + expect(find.text('₩1,000'), findsOneWidget); + expect(find.text('정상'), findsOneWidget); + }); +} diff --git a/test/features/inventory/outbound/presentation/widgets/outbound_detail_view_test.dart b/test/features/inventory/outbound/presentation/widgets/outbound_detail_view_test.dart new file mode 100644 index 0000000..2533447 --- /dev/null +++ b/test/features/inventory/outbound/presentation/widgets/outbound_detail_view_test.dart @@ -0,0 +1,68 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; + +import 'package:superport_v2/features/inventory/outbound/presentation/models/outbound_record.dart'; +import 'package:superport_v2/features/inventory/outbound/presentation/widgets/outbound_detail_view.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final currencyFormatter = intl.NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); + + OutboundRecord buildRecord() { + return OutboundRecord( + id: 1, + number: 'OUT-1', + transactionNumber: 'OUT-1', + transactionType: '출고', + processedAt: DateTime(2024, 2, 10, 11), + warehouse: '서울 1창고', + status: '출고 완료', + writer: '관리자', + remark: '긴급 출고', + items: [ + OutboundLineItem( + id: 20, + product: '출고 제품', + manufacturer: '제조사', + unit: 'EA', + quantity: 5, + price: 2000, + remark: '양호', + ), + ], + customers: [ + OutboundCustomer(id: 1, customerId: 7, code: 'CUS-01', name: '테스트 고객'), + ], + ); + } + + testWidgets('OutboundDetailView는 고객 배지와 라인 테이블을 표시한다', (tester) async { + final record = buildRecord(); + + await tester.pumpWidget( + buildTestApp( + OutboundDetailView( + record: record, + currencyFormatter: currencyFormatter, + transitionsEnabled: true, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('출고 고객사'), findsOneWidget); + expect(find.text('테스트 고객 · CUS-01'), findsOneWidget); + expect(find.text('라인 품목'), findsOneWidget); + expect(find.text('출고 제품'), findsOneWidget); + expect(find.text('제조사'), findsWidgets); + expect(find.text('EA'), findsWidgets); + expect(find.text('5'), findsWidgets); + expect(find.text('₩2,000'), findsOneWidget); + expect(find.text('양호'), findsOneWidget); + }); +} diff --git a/test/features/inventory/rental/presentation/widgets/rental_detail_view_test.dart b/test/features/inventory/rental/presentation/widgets/rental_detail_view_test.dart new file mode 100644 index 0000000..db82447 --- /dev/null +++ b/test/features/inventory/rental/presentation/widgets/rental_detail_view_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; + +import 'package:superport_v2/features/inventory/rental/presentation/models/rental_record.dart'; +import 'package:superport_v2/features/inventory/rental/presentation/widgets/rental_detail_view.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final currencyFormatter = intl.NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); + + RentalRecord buildRecord() { + return RentalRecord( + id: 1, + number: 'RENT-1', + transactionNumber: 'RENT-1', + transactionType: '대여', + rentalType: '대여', + processedAt: DateTime(2024, 3, 5, 15), + warehouse: '부산 창고', + status: '반납 대기', + writer: '김운영', + remark: '대여 비고', + returnDueDate: DateTime(2024, 3, 20), + items: [ + RentalLineItem( + id: 30, + product: '대여 품목', + manufacturer: '렌탈 제조사', + unit: 'EA', + quantity: 2, + price: 5000, + remark: '', + ), + ], + customers: [ + RentalCustomer(id: 2, customerId: 9, code: 'RC-09', name: '렌탈 고객'), + ], + ); + } + + testWidgets('RentalDetailView는 고객 배지와 라인 테이블을 렌더링한다', (tester) async { + final record = buildRecord(); + + await tester.pumpWidget( + buildTestApp( + RentalDetailView( + record: record, + currencyFormatter: currencyFormatter, + transitionsEnabled: false, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('재고 상태 전이가 비활성화된 상태입니다.'), findsOneWidget); + expect(find.text('연결 고객사'), findsOneWidget); + expect(find.text('렌탈 고객 · RC-09'), findsOneWidget); + expect(find.text('라인 품목'), findsOneWidget); + expect(find.text('대여 품목'), findsOneWidget); + expect(find.text('렌탈 제조사'), findsOneWidget); + expect(find.text('EA'), findsOneWidget); + expect(find.text('2'), findsWidgets); + expect(find.text('₩5,000'), findsOneWidget); + }); +} diff --git a/test/features/inventory/transactions/presentation/widgets/transaction_detail_dialog_test.dart b/test/features/inventory/transactions/presentation/widgets/transaction_detail_dialog_test.dart new file mode 100644 index 0000000..291e79a --- /dev/null +++ b/test/features/inventory/transactions/presentation/widgets/transaction_detail_dialog_test.dart @@ -0,0 +1,47 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/features/inventory/transactions/presentation/widgets/transaction_detail_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + testWidgets( + 'showInventoryTransactionDetailDialog는 summary/metadata/섹션을 렌더링한다', + (tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showInventoryTransactionDetailDialog( + context: context, + title: '트랜잭션 상세', + description: '라인 품목과 상태를 확인하세요.', + summary: const Text('TRX-123'), + summaryBadges: const [ShadBadge(child: Text('완료'))], + metadata: [ + SuperportDetailMetadata.text(label: '상태', value: '완료'), + SuperportDetailMetadata.text(label: '창고', value: '서울 1창고'), + ], + sections: [ + SuperportDetailDialogSection( + id: 'lines', + label: '라인 품목', + builder: (_) => const Text('라인 섹션'), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('트랜잭션 상세'), findsOneWidget); + expect(find.text('TRX-123'), findsOneWidget); + expect(find.text('상태'), findsOneWidget); + expect(find.text('라인 섹션'), findsOneWidget); + }, + ); +} diff --git a/test/features/masters/customer/presentation/dialogs/customer_detail_dialog_test.dart b/test/features/masters/customer/presentation/dialogs/customer_detail_dialog_test.dart new file mode 100644 index 0000000..f59b1ca --- /dev/null +++ b/test/features/masters/customer/presentation/dialogs/customer_detail_dialog_test.dart @@ -0,0 +1,91 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart'; +import 'package:superport_v2/features/masters/customer/presentation/dialogs/customer_detail_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + + Future noopCreate(CustomerInput _) async => null; + Future noopUpdate(int _, CustomerInput __) async => null; + Future noopDelete(int _) async => false; + Future noopRestore(int _) async => null; + + testWidgets('showCustomerDetailDialog 등록 모드 폼을 렌더링한다', (tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showCustomerDetailDialog( + context: context, + dateFormat: dateFormat, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('고객사 등록'), findsOneWidget); + expect(find.text('고객사코드'), findsOneWidget); + expect(find.text('유형'), findsOneWidget); + expect(find.text('등록'), findsWidgets); + }); + + testWidgets('showCustomerDetailDialog 상세 모드는 고객 요약을 표시한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final customer = Customer( + id: 10, + customerCode: 'C-001', + customerName: '슈퍼고객', + contactName: '홍길동', + isPartner: true, + isGeneral: false, + email: 'customer@example.com', + mobileNo: '010-1111-2222', + zipcode: CustomerZipcode( + zipcode: '06000', + sido: '서울', + sigungu: '강남구', + roadName: '테헤란로', + ), + addressDetail: '101호', + isActive: true, + isDeleted: false, + note: 'VIP', + createdAt: DateTime(2024, 3, 1, 9), + updatedAt: DateTime(2024, 3, 2, 10), + ); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showCustomerDetailDialog( + context: context, + dateFormat: dateFormat, + customer: customer, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('고객사 상세'), findsOneWidget); + expect(find.textContaining('슈퍼고객').first, findsOneWidget); + expect(find.text('고객사 코드'), findsWidgets); + expect(find.text('유형'), findsWidgets); + }); +} diff --git a/test/features/masters/customer/presentation/pages/customer_page_test.dart b/test/features/masters/customer/presentation/pages/customer_page_test.dart index a5f8328..85c2244 100644 --- a/test/features/masters/customer/presentation/pages/customer_page_test.dart +++ b/test/features/masters/customer/presentation/pages/customer_page_test.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart'; import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart'; @@ -131,7 +132,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('고객사코드를 입력하세요.'), findsOneWidget); @@ -174,7 +175,7 @@ void main() { await tester.enterText(fields.at(1), '검색 필요 고객'); await tester.enterText(fields.at(4), '06000'); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('검색 버튼을 눌러 주소를 선택하세요.'), findsOneWidget); @@ -251,11 +252,10 @@ void main() { await tester.enterText(editableTexts.at(4), '02-0000-0000'); // 유형 체크박스: 기본값 partner=false, general=true. partner on 추가 - await tester.tap(find.text('파트너')); + await tester.tap(find.text('파트너'), warnIfMissed: false); await tester.pump(); - await tester.tap(find.text('등록')); - await tester.pumpAndSettle(); + await tester.tapShadButton('등록'); expect(capturedInput, isNotNull); expect(capturedInput?.customerCode, 'C-100'); diff --git a/test/features/masters/group/presentation/dialogs/group_detail_dialog_test.dart b/test/features/masters/group/presentation/dialogs/group_detail_dialog_test.dart new file mode 100644 index 0000000..e040dfe --- /dev/null +++ b/test/features/masters/group/presentation/dialogs/group_detail_dialog_test.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/group/presentation/dialogs/group_detail_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + + Future noopCreate(GroupInput _) async => null; + Future noopUpdate(int _, GroupInput __) async => null; + Future noopDelete(int _) async => false; + Future noopRestore(int _) async => null; + + testWidgets('showGroupDetailDialog 등록 모드 폼을 노출한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(960, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showGroupDetailDialog( + context: context, + dateFormat: dateFormat, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('그룹 등록'), findsOneWidget); + expect(find.text('그룹명'), findsOneWidget); + expect(find.text('설명'), findsOneWidget); + expect(find.widgetWithText(ShadButton, '등록'), findsWidgets); + }); + + testWidgets('showGroupDetailDialog 상세 모드는 요약과 메타데이터를 제공한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final group = Group( + id: 12, + groupName: '운영팀', + description: '운영 담당', + isDefault: false, + isActive: true, + isDeleted: false, + note: '연중무휴', + createdAt: DateTime(2024, 2, 1, 9), + updatedAt: DateTime(2024, 3, 1, 10), + ); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showGroupDetailDialog( + context: context, + dateFormat: dateFormat, + group: group, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('그룹 상세'), findsWidgets); + expect(find.text('운영팀'), findsWidgets); + expect(find.text('운영 담당'), findsWidgets); + expect(find.text('기본 여부'), findsWidgets); + expect(find.text('사용 여부'), findsWidgets); + expect(find.text('삭제'), findsOneWidget); + }); + + testWidgets('삭제 섹션에서 경고와 버튼 상태를 노출한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final group = Group( + id: 20, + groupName: '삭제 대상', + isDefault: false, + isActive: true, + isDeleted: false, + createdAt: DateTime(2024, 1, 10, 8), + updatedAt: DateTime(2024, 1, 12, 9), + ); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showGroupDetailDialog( + context: context, + dateFormat: dateFormat, + group: group, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: (_) async => true, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + final deleteTab = find.text('삭제').first; + await tester.tap(deleteTab, warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(find.textContaining('삭제하면'), findsOneWidget); + + final dialog = find.byType(Dialog); + final deleteButton = find + .descendant( + of: dialog, + matching: find.widgetWithText(ShadButton, '삭제', skipOffstage: false), + ) + .first; + final buttonWidget = tester.widget(deleteButton); + expect(buttonWidget.onPressed, isNotNull); + }); +} diff --git a/test/features/masters/group/presentation/pages/group_page_test.dart b/test/features/masters/group/presentation/pages/group_page_test.dart index 2ce9fcc..c65b913 100644 --- a/test/features/masters/group/presentation/pages/group_page_test.dart +++ b/test/features/masters/group/presentation/pages/group_page_test.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; import 'package:superport_v2/features/masters/group/domain/repositories/group_repository.dart'; @@ -120,7 +121,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('그룹명을 입력하세요.'), findsOneWidget); @@ -179,8 +180,7 @@ void main() { await tester.enterText(editableTexts.at(0), '운영팀'); await tester.enterText(editableTexts.at(1), '운영 담당'); - await tester.tap(find.text('등록')); - await tester.pumpAndSettle(); + await tester.tapShadButton('등록'); expect(capturedInput, isNotNull); expect(capturedInput?.groupName, '운영팀'); diff --git a/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart b/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart index e8ebc4d..420d2fd 100644 --- a/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart +++ b/test/features/masters/group_permission/presentation/pages/group_permission_page_test.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; @@ -179,7 +180,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('그룹을 선택하세요.'), findsOneWidget); @@ -260,7 +261,7 @@ void main() { final dialog = find.byType(Dialog); final selects = find.descendant( of: dialog, - matching: find.byType(ShadSelect), + matching: find.byWidgetPredicate((widget) => widget is ShadSelect), ); // 그룹 선택 @@ -287,8 +288,7 @@ void main() { await tester.tap(switches.at(3)); await tester.pump(); - await tester.tap(find.text('등록')); - await tester.pumpAndSettle(); + await tester.tapShadButton('등록'); expect(capturedInput, isNotNull); expect(capturedInput?.groupId, 1); diff --git a/test/features/masters/menu/presentation/pages/menu_page_test.dart b/test/features/masters/menu/presentation/pages/menu_page_test.dart index d621367..558fbfc 100644 --- a/test/features/masters/menu/presentation/pages/menu_page_test.dart +++ b/test/features/masters/menu/presentation/pages/menu_page_test.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/masters/menu/domain/entities/menu.dart'; import 'package:superport_v2/features/masters/menu/domain/repositories/menu_repository.dart'; @@ -107,7 +108,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('메뉴코드를 입력하세요.'), findsOneWidget); @@ -168,8 +169,7 @@ void main() { await tester.enterText(editableTexts.at(0), 'MENU010'); await tester.enterText(editableTexts.at(1), '신규 메뉴'); - await tester.tap(find.text('등록')); - await tester.pumpAndSettle(); + await tester.tapShadButton('등록'); expect(capturedInput, isNotNull); expect(capturedInput?.menuCode, 'MENU010'); diff --git a/test/features/masters/product/presentation/dialogs/product_detail_dialog_test.dart b/test/features/masters/product/presentation/dialogs/product_detail_dialog_test.dart new file mode 100644 index 0000000..a7b3376 --- /dev/null +++ b/test/features/masters/product/presentation/dialogs/product_detail_dialog_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:superport_v2/features/masters/product/domain/entities/product.dart'; +import 'package:superport_v2/features/masters/product/presentation/dialogs/product_detail_dialog.dart'; +import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; +import 'package:superport_v2/features/masters/uom/domain/entities/uom.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + final vendorOptions = [ + Vendor(id: 1, vendorCode: 'V-001', vendorName: '슈퍼벤더'), + ]; + final uomOptions = [Uom(id: 1, uomName: 'EA')]; + + Future noopCreate(ProductInput _) async => null; + Future noopUpdate(int _, ProductInput __) async => null; + Future noopDelete(int _) async => false; + Future noopRestore(int _) async => null; + + testWidgets('showProductDetailDialog 등록 모드는 입력 폼을 표시한다', (tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showProductDetailDialog( + context: context, + dateFormat: dateFormat, + vendorOptions: vendorOptions, + uomOptions: uomOptions, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('제품 등록'), findsOneWidget); + expect(find.text('제품코드'), findsOneWidget); + expect(find.text('제조사'), findsOneWidget); + expect(find.text('단위'), findsOneWidget); + expect(find.text('등록'), findsWidgets); + }); + + testWidgets('showProductDetailDialog 상세 모드는 기본/연결/히스토리 정보를 제공한다', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final product = Product( + id: 42, + productCode: 'P-100', + productName: '테스트 제품', + vendor: ProductVendor(id: 1, vendorCode: 'V-001', vendorName: '슈퍼벤더'), + uom: ProductUom(id: 1, uomName: 'EA'), + isActive: true, + isDeleted: false, + note: '비고 메모', + createdAt: DateTime(2024, 1, 1, 9), + updatedAt: DateTime(2024, 2, 2, 10), + ); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showProductDetailDialog( + context: context, + dateFormat: dateFormat, + product: product, + vendorOptions: vendorOptions, + uomOptions: uomOptions, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('제품 상세'), findsOneWidget); + expect(find.text('테스트 제품'), findsWidgets); + expect(find.text('제품코드'), findsOneWidget); + expect(find.text('연결 관계'), findsOneWidget); + + await tester.tap(find.text('연결 관계')); + await tester.pumpAndSettle(); + expect(find.textContaining('슈퍼벤더'), findsWidgets); + + await tester.tap(find.text('히스토리')); + await tester.pumpAndSettle(); + expect(find.textContaining('변경 이력 데이터는 준비 중입니다.'), findsOneWidget); + }); +} diff --git a/test/features/masters/product/presentation/pages/product_page_test.dart b/test/features/masters/product/presentation/pages/product_page_test.dart index 76026cb..e61ddf6 100644 --- a/test/features/masters/product/presentation/pages/product_page_test.dart +++ b/test/features/masters/product/presentation/pages/product_page_test.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/masters/product/domain/entities/product.dart'; import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart'; @@ -208,7 +209,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('제품코드를 입력하세요.'), findsOneWidget); @@ -303,8 +304,7 @@ void main() { await tester.tap(find.text('EA')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); - await tester.pumpAndSettle(); + await tester.tapShadButton('등록'); expect(capturedInput, isNotNull); expect(capturedInput?.productCode, 'NP-001'); diff --git a/test/features/masters/user/presentation/dialogs/user_detail_dialog_test.dart b/test/features/masters/user/presentation/dialogs/user_detail_dialog_test.dart new file mode 100644 index 0000000..a8d1d06 --- /dev/null +++ b/test/features/masters/user/presentation/dialogs/user_detail_dialog_test.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; +import 'package:superport_v2/features/masters/user/domain/entities/user.dart'; +import 'package:superport_v2/features/masters/user/presentation/dialogs/user_detail_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + final groups = [Group(id: 1, groupName: '관리자')]; + + Future noopCreate(UserInput _) async => null; + Future noopUpdate(int _, UserInput __) async => null; + Future noopDelete(int _) async => false; + Future noopRestore(int _) async => null; + Future noopReset(int _) async => null; + + testWidgets('showUserDetailDialog 등록 모드 폼 렌더링', (tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showUserDetailDialog( + context: context, + dateFormat: dateFormat, + groupOptions: groups, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + onResetPassword: noopReset, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('사용자 등록'), findsOneWidget); + expect(find.text('사번'), findsOneWidget); + expect(find.text('그룹'), findsOneWidget); + expect(find.text('등록'), findsWidgets); + }); + + testWidgets('상세 모드에서 비밀번호 재설정을 실행한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final user = UserAccount( + id: 7, + employeeNo: 'A007', + employeeName: '홍길동', + email: 'hong@superport.com', + mobileNo: '010-1234-5678', + group: UserGroup(id: 1, groupName: '관리자'), + createdAt: DateTime(2024, 1, 1, 9), + updatedAt: DateTime(2024, 1, 2, 10), + passwordUpdatedAt: DateTime(2024, 1, 2, 9), + ); + + var resetCalled = false; + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + final dialogFuture = showUserDetailDialog( + context: context, + dateFormat: dateFormat, + user: user, + groupOptions: groups, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + onResetPassword: (id) async { + resetCalled = id == 7; + return user; + }, + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.text('보안')); + await tester.pumpAndSettle(); + + final resetButton = find.widgetWithText(ShadButton, '비밀번호 재설정').first; + await tester.ensureVisible(resetButton); + await tester.tap(resetButton, warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(find.text('비밀번호 재설정'), findsWidgets); + final confirmButton = find.widgetWithText(ShadButton, '재설정').last; + await tester.ensureVisible(confirmButton); + await tester.tap(confirmButton, warnIfMissed: false); + await tester.pumpAndSettle(); + + final result = await dialogFuture; + + expect(resetCalled, isTrue); + expect(result?.action, UserDetailDialogAction.passwordReset); + expect(find.byType(SuperportDialog), findsNothing); + }); + + testWidgets('상세 모드에서 삭제 섹션을 노출한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final user = UserAccount( + id: 9, + employeeNo: 'A009', + employeeName: '삭제 대상', + group: UserGroup(id: 1, groupName: '관리자'), + isActive: true, + isDeleted: false, + createdAt: DateTime(2024, 1, 3, 9), + updatedAt: DateTime(2024, 1, 4, 12), + ); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + final dialogFuture = showUserDetailDialog( + context: context, + dateFormat: dateFormat, + user: user, + groupOptions: groups, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + onResetPassword: noopReset, + ); + + await tester.pumpAndSettle(); + + await tester.tap(find.text('삭제').first); + await tester.pumpAndSettle(); + + expect(find.textContaining('삭제하면'), findsOneWidget); + expect(find.widgetWithText(ShadButton, '삭제'), findsWidgets); + + await tester.tap(find.byTooltip('닫기'), warnIfMissed: false); + await tester.pumpAndSettle(); + + await dialogFuture; + }); +} diff --git a/test/features/masters/user/presentation/pages/user_page_test.dart b/test/features/masters/user/presentation/pages/user_page_test.dart index 1876625..436ad6e 100644 --- a/test/features/masters/user/presentation/pages/user_page_test.dart +++ b/test/features/masters/user/presentation/pages/user_page_test.dart @@ -1,3 +1,5 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -5,6 +7,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/core/permissions/permission_manager.dart'; import 'package:superport_v2/features/masters/group/domain/entities/group.dart'; @@ -14,6 +17,7 @@ import 'package:superport_v2/features/masters/group_permission/domain/repositori import 'package:superport_v2/features/masters/user/domain/entities/user.dart'; import 'package:superport_v2/features/masters/user/domain/repositories/user_repository.dart'; import 'package:superport_v2/features/masters/user/presentation/pages/user_page.dart'; +import 'package:superport_v2/widgets/components/superport_dialog.dart'; class _MockUserRepository extends Mock implements UserRepository {} @@ -191,7 +195,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('사번을 입력하세요.'), findsOneWidget); @@ -259,7 +263,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - final dialog = find.byType(Dialog); + final dialog = find.byType(SuperportDialog); await tester.enterText( find.byKey(const ValueKey('user_form_employee')), 'A010', @@ -298,8 +302,7 @@ void main() { await tester.tap(adminOption.first, warnIfMissed: false); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); - await tester.pumpAndSettle(); + await tester.tapShadButton('등록'); expect(capturedInput, isNotNull); expect(capturedInput?.employeeNo, 'A010'); @@ -307,7 +310,7 @@ void main() { expect(capturedInput?.forcePasswordChange, isTrue); expect(capturedInput?.email, 'new@superport.com'); expect(capturedInput?.mobileNo, '010-1111-2222'); - expect(find.byType(Dialog), findsNothing); + expect(find.byType(SuperportDialog), findsNothing); expect(find.text('A010'), findsOneWidget); verify(() => userRepository.create(any())).called(1); }); @@ -344,7 +347,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - final dialog = find.byType(Dialog); + final dialog = find.byType(SuperportDialog); await tester.enterText( find.byKey(const ValueKey('user_form_employee')), 'A011', @@ -381,14 +384,14 @@ void main() { await tester.tap(find.text('관리자', skipOffstage: false).first); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.textContaining('최소 8자 이상 입력해야 합니다.'), findsOneWidget); verifyNever(() => userRepository.create(any())); }); - testWidgets('비밀번호 재설정 버튼을 통해 확인 후 API 호출', (tester) async { + testWidgets('상세 팝업에서 비밀번호 재설정 액션 수행', (tester) async { final view = tester.view; view.physicalSize = const Size(1280, 800); view.devicePixelRatio = 1.0; @@ -432,20 +435,37 @@ void main() { await tester.pumpWidget(_buildApp(const UserPage())); await tester.pumpAndSettle(); - final resetFinder = find - .widgetWithIcon(ShadButton, LucideIcons.refreshCcw) - .first; - final resetButton = tester.widget(resetFinder); - resetButton.onPressed?.call(); + final rowFinder = find.text('A001'); + expect(rowFinder, findsOneWidget); + + final rowRect = tester.getRect(rowFinder); + await tester.tapAt( + rowRect.center, + kind: PointerDeviceKind.mouse, + ); await tester.pumpAndSettle(); - expect(find.byType(Dialog), findsOneWidget); - expect(find.text('재설정'), findsOneWidget); - await tester.tap(find.text('재설정')); + expect(find.byType(SuperportDialog), findsOneWidget); + + await tester.tap(find.text('보안')); + await tester.pumpAndSettle(); + + final resetButton = + find.widgetWithText(ShadButton, '비밀번호 재설정').first; + await tester.ensureVisible(resetButton); + await tester.tap(resetButton, warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(find.text('비밀번호 재설정'), findsWidgets); + + final confirmButton = + find.widgetWithText(ShadButton, '재설정').last; + await tester.ensureVisible(confirmButton); + await tester.tap(confirmButton, warnIfMissed: false); await tester.pumpAndSettle(); verify(() => userRepository.resetPassword(1)).called(1); - expect(find.text('비밀번호 재설정'), findsNothing); + expect(find.byType(SuperportDialog), findsNothing); }); }); } diff --git a/test/features/masters/vendor/presentation/dialogs/vendor_detail_dialog_test.dart b/test/features/masters/vendor/presentation/dialogs/vendor_detail_dialog_test.dart new file mode 100644 index 0000000..b20908d --- /dev/null +++ b/test/features/masters/vendor/presentation/dialogs/vendor_detail_dialog_test.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; +import 'package:superport_v2/features/masters/vendor/presentation/dialogs/vendor_detail_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + + Future noopCreate(VendorInput _) async => null; + Future noopUpdate(int _, VendorInput __) async => null; + Future noopDelete(int _) async => false; + Future noopRestore(int _) async => null; + + testWidgets('showVendorDetailDialog 등록 모드 폼을 노출한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(1080, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showVendorDetailDialog( + context: context, + dateFormat: dateFormat, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('벤더 등록'), findsOneWidget); + expect(find.text('벤더코드'), findsOneWidget); + expect(find.text('벤더명'), findsOneWidget); + expect(find.widgetWithText(ShadButton, '등록'), findsWidgets); + }); + + testWidgets('showVendorDetailDialog 상세 모드는 요약 정보를 제공한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final vendor = Vendor( + id: 7, + vendorCode: 'V-007', + vendorName: '슈퍼벤더', + isActive: true, + isDeleted: false, + note: '테스트 벤더', + createdAt: DateTime(2024, 3, 1, 9, 30), + updatedAt: DateTime(2024, 3, 5, 12, 0), + ); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showVendorDetailDialog( + context: context, + dateFormat: dateFormat, + vendor: vendor, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: (id) async { + expect(id, equals(7)); + return true; + }, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('벤더 상세'), findsWidgets); + expect(find.text('슈퍼벤더'), findsWidgets); + expect(find.text('삭제 상태'), findsOneWidget); + expect(find.text('삭제'), findsOneWidget); + }); + + testWidgets('삭제 섹션에서 경고 메시지와 버튼을 노출한다', (tester) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final vendor = Vendor( + id: 9, + vendorCode: 'V-009', + vendorName: '삭제 대상', + isActive: true, + isDeleted: false, + createdAt: DateTime(2024, 4, 1, 10), + updatedAt: DateTime(2024, 4, 10, 18), + ); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showVendorDetailDialog( + context: context, + dateFormat: dateFormat, + vendor: vendor, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: (_) async => true, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + final deleteTab = find.text('삭제').first; + await tester.tap(deleteTab, warnIfMissed: false); + await tester.pumpAndSettle(); + + expect(find.textContaining('삭제하면'), findsOneWidget); + + final dialog = find.byType(Dialog); + final deleteButton = find + .descendant( + of: dialog, + matching: find.widgetWithText(ShadButton, '삭제', skipOffstage: false), + ) + .first; + final buttonWidget = tester.widget(deleteButton); + expect(buttonWidget.onPressed, isNotNull); + }); +} diff --git a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart index a453fe2..0bc68a8 100644 --- a/test/features/masters/vendor/presentation/pages/vendor_page_test.dart +++ b/test/features/masters/vendor/presentation/pages/vendor_page_test.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/masters/vendor/domain/entities/vendor.dart'; import 'package:superport_v2/features/masters/vendor/domain/repositories/vendor_repository.dart'; @@ -142,7 +143,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('벤더코드를 입력하세요.'), findsOneWidget); @@ -207,14 +208,14 @@ void main() { await tester.enterText(editableTexts.at(0), 'NV-001'); await tester.enterText(editableTexts.at(1), '신규벤더'); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록'); await tester.pumpAndSettle(); + expect(find.byType(Dialog), findsNothing); + verify(() => repository.create(any())).called(1); expect(capturedInput, isNotNull); expect(capturedInput?.vendorCode, 'NV-001'); - expect(find.byType(Dialog), findsNothing); expect(find.text('NV-001'), findsOneWidget); - verify(() => repository.create(any())).called(1); }); testWidgets('좁은 폭에서도 오버플로 없이 렌더링', (tester) async { diff --git a/test/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog_test.dart b/test/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog_test.dart new file mode 100644 index 0000000..7c13653 --- /dev/null +++ b/test/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog_test.dart @@ -0,0 +1,89 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart' as intl; + +import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; +import 'package:superport_v2/features/masters/warehouse/presentation/dialogs/warehouse_detail_dialog.dart'; + +import '../../../../../helpers/test_app.dart'; + +void main() { + final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm'); + + Future noopCreate(WarehouseInput _) async => null; + Future noopUpdate(int _, WarehouseInput __) async => null; + Future noopDelete(int _) async => false; + Future noopRestore(int _) async => null; + + testWidgets('showWarehouseDetailDialog 등록 모드 UI', (tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showWarehouseDetailDialog( + context: context, + dateFormat: dateFormat, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('창고 등록'), findsOneWidget); + expect(find.text('창고코드'), findsOneWidget); + expect(find.text('우편번호'), findsOneWidget); + expect(find.text('등록'), findsWidgets); + }); + + testWidgets('showWarehouseDetailDialog 상세 모드 metadata/위험 섹션 제공', ( + tester, + ) async { + await tester.binding.setSurfaceSize(const Size(1280, 800)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final warehouse = Warehouse( + id: 7, + warehouseCode: 'WH-001', + warehouseName: '서울 1창고', + zipcode: WarehouseZipcode(zipcode: '06000', sido: '서울', sigungu: '강남구'), + addressDetail: '강남대로 123', + note: '비고', + isActive: true, + isDeleted: false, + createdAt: DateTime(2024, 1, 1, 9), + updatedAt: DateTime(2024, 1, 2, 10), + ); + + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showWarehouseDetailDialog( + context: context, + dateFormat: dateFormat, + warehouse: warehouse, + onCreate: noopCreate, + onUpdate: noopUpdate, + onDelete: noopDelete, + onRestore: noopRestore, + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('창고 상세'), findsOneWidget); + expect(find.textContaining('서울 1창고'), findsWidgets); + expect(find.text('기본주소'), findsOneWidget); + expect(find.text('서울 강남구'), findsWidgets); + expect(find.text('수정'), findsOneWidget); + + await tester.tap(find.text('삭제')); + await tester.pumpAndSettle(); + expect(find.textContaining('삭제하면 창고가'), findsOneWidget); + }); +} diff --git a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart index a624569..6155330 100644 --- a/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart +++ b/test/features/masters/warehouse/presentation/pages/warehouse_page_test.dart @@ -5,6 +5,7 @@ import 'package:get_it/get_it.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../../../../../helpers/tester_extensions.dart'; import 'package:superport_v2/core/common/models/paginated_result.dart'; import 'package:superport_v2/features/masters/warehouse/domain/entities/warehouse.dart'; import 'package:superport_v2/features/masters/warehouse/domain/repositories/warehouse_repository.dart'; @@ -152,7 +153,7 @@ void main() { await tester.tap(find.text('신규 등록')); await tester.pumpAndSettle(); - await tester.tap(find.text('등록')); + await tester.tapShadButton('등록', settle: false); await tester.pump(); expect(find.text('창고코드를 입력하세요.'), findsOneWidget); @@ -237,8 +238,7 @@ void main() { await tester.enterText(updatedFields.at(3), '주소'); - await tester.tap(find.text('등록')); - await tester.pumpAndSettle(); + await tester.tapShadButton('등록'); expect(capturedInput, isNotNull); expect(capturedInput?.warehouseCode, 'WH-100'); diff --git a/test/helpers/tester_extensions.dart b/test/helpers/tester_extensions.dart new file mode 100644 index 0000000..3f211c6 --- /dev/null +++ b/test/helpers/tester_extensions.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +extension WidgetTesterDialogActions on WidgetTester { + /// 다이얼로그 내 `ShadButton`을 텍스트 라벨로 찾아 탭한다. + Future tapShadButton(String label, {bool settle = true}) async { + final buttonFinder = find.widgetWithText(ShadButton, label); + expect( + buttonFinder, + findsWidgets, + reason: '텍스트 "$label" 를 가진 ShadButton을 찾지 못했습니다.', + ); + final elements = buttonFinder.evaluate().toList(growable: false); + final targetIndex = elements.lastIndexWhere((element) { + final widget = element.widget; + return widget is ShadButton && widget.onPressed != null; + }); + expect( + targetIndex, + isNot(-1), + reason: '눌러야 할 활성화된 "$label" 버튼을 찾을 수 없습니다.', + ); + final targetButton = buttonFinder.at(targetIndex); + final targetText = find.descendant( + of: targetButton, + matching: find.byType(Text), + ); + await ensureVisible(targetText.first); + await tap(targetText.first, warnIfMissed: false); + if (settle) { + await pumpAndSettle(); + } + } +} diff --git a/test/widgets/superport_detail_dialog_test.dart b/test/widgets/superport_detail_dialog_test.dart new file mode 100644 index 0000000..345a0ee --- /dev/null +++ b/test/widgets/superport_detail_dialog_test.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import 'package:superport_v2/widgets/components/superport_detail_dialog.dart'; + +import '../helpers/test_app.dart'; + +void main() { + testWidgets('SuperportDetailDialog은 요약과 메타데이터를 렌더링한다', (tester) async { + final widget = SuperportDetailDialog( + summary: const Text('벤더 요약'), + summaryBadges: const [ShadBadge(child: Text('활성'))], + metadata: [ + SuperportDetailMetadata.text(label: 'ID', value: '#42'), + SuperportDetailMetadata.text(label: '상태', value: '사용 중'), + ], + sections: [ + SuperportDetailDialogSection( + id: 'overview', + label: '개요', + builder: (_) => const Text('개요 섹션'), + ), + ], + ); + + await tester.pumpWidget(buildTestApp(widget)); + await tester.pumpAndSettle(); + + expect(find.text('벤더 요약'), findsOneWidget); + expect(find.text('활성'), findsOneWidget); + expect(find.text('ID'), findsOneWidget); + expect(find.text('#42'), findsOneWidget); + expect(find.text('상태'), findsOneWidget); + expect(find.text('사용 중'), findsOneWidget); + expect(find.text('개요 섹션'), findsOneWidget); + expect(find.byType(ShadTabs), findsNothing); + }); + + testWidgets('SuperportDetailDialog은 여러 섹션 탭을 전환한다', (tester) async { + final widget = SuperportDetailDialog( + sections: [ + SuperportDetailDialogSection( + id: 'overview', + label: '개요', + builder: (_) => const Text('개요 내용'), + ), + SuperportDetailDialogSection( + id: 'history', + label: '이력', + builder: (_) => const Text('이력 내용'), + ), + ], + ); + + await tester.pumpWidget(buildTestApp(widget)); + await tester.pumpAndSettle(); + + final tabsFinder = find.byWidgetPredicate((widget) => widget is ShadTabs); + expect(tabsFinder, findsOneWidget); + expect(find.text('개요 내용'), findsOneWidget); + expect(find.text('이력 내용'), findsNothing); + + await tester.tap(find.text('이력')); + await tester.pumpAndSettle(); + + expect(find.text('개요 내용'), findsNothing); + expect(find.text('이력 내용'), findsOneWidget); + }); + + testWidgets('showSuperportDetailDialog는 기본 닫기 버튼을 렌더링하지 않는다', (tester) async { + await tester.pumpWidget(buildTestApp(const SizedBox.shrink())); + await tester.pumpAndSettle(); + + final context = tester.element(find.byType(SizedBox)); + + unawaited( + showSuperportDetailDialog( + context: context, + title: '테스트 다이얼로그', + sections: [ + SuperportDetailDialogSection( + id: 'overview', + label: '개요', + builder: (_) => const Text('본문'), + ), + ], + ), + ); + + await tester.pumpAndSettle(); + + expect(find.text('테스트 다이얼로그'), findsOneWidget); + expect(find.text('본문'), findsOneWidget); + expect(find.text('닫기'), findsNothing); + }); +}