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