feat(approvals): Approval Flow v2 프런트엔드 전면 개편

- 환경/라우터 모듈에 approval_flow_v2 토글을 추가하고 FeatureFlags 초기화를 연결 (.env*, lib/core/**)
- ApiClient 빌더·ApiRoutes 확장과 ApprovalRepositoryRemote 리팩터링으로 include·액션 시그니처를 정합화
- ApprovalFlow·ApprovalDraft 엔티티/레포/유즈케이스를 도입해 서버 초안과 단계 액션(승인·회수·재상신)을 지원
- Approval 컨트롤러·히스토리·템플릿 페이지와 공유 위젯을 재작성해 감사 로그·회수 UX·템플릿 CRUD를 반영
- Inbound/Outbound/Rental 컨트롤러·페이지에 결재 섹션을 삽입하고 대시보드 pending 카드 요약을 갱신
- SuperportDialog·FormField 등 공통 위젯을 보강하고 승인 위젯 가이드를 추가해 UI 가이드를 정리
- 결재/재고 테스트 픽스처와 단위·위젯·통합 테스트를 확장하고 flutter_test_config로 스테이징 호스트를 허용
- Approval Flow 레포트/플랜 문서를 업데이트하고 ApprovalFlow_System_Integration_and_ChangePlan.md를 추가
- 실행: flutter analyze, flutter test
This commit is contained in:
JiWoong Sul
2025-10-31 01:05:39 +09:00
parent 259b056072
commit d76f765814
133 changed files with 13878 additions and 947 deletions

View File

@@ -393,50 +393,36 @@ class _PendingApprovalCard extends StatelessWidget {
return ShadCard(
title: Text('내 결재 대기', style: theme.textTheme.h3),
description: Text(
'현재 승인 대기 중인 결재 요청입니다.',
'최종 승인 대기 전표는 기본 목록에 노출되지 않습니다.',
style: theme.textTheme.muted,
),
child: const SuperportEmptyState(
title: '대기 중인 결재가 없습니다',
description: '새로운 결재 요청이 등록되면 이곳에서 바로 확인할 수 있습니다.',
description: '최종 승인 대기 전표가 생성되면 이곳에 표시됩니다.',
),
);
}
final now = DateTime.now();
final dateFormat = intl.DateFormat('yyyy-MM-dd HH:mm');
return ShadCard(
title: Text('내 결재 대기', style: theme.textTheme.h3),
description: Text('현재 승인 대기 중인 결재 요청입니다.', style: theme.textTheme.muted),
description: Text(
'최종 승인 대기 전표를 한곳에서 확인하고 처리할 수 있습니다.',
style: theme.textTheme.muted,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final approval in approvals) ...[
ListTile(
leading: const Icon(lucide.LucideIcons.fileCheck, size: 20),
title: Text(approval.approvalNo, style: theme.textTheme.small),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(approval.title, style: theme.textTheme.p),
const SizedBox(height: 4),
Text(approval.stepSummary, style: theme.textTheme.muted),
if (approval.requestedAt != null &&
approval.requestedAt!.trim().isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'상신: ${approval.requestedAt}',
style: theme.textTheme.small,
),
),
],
),
trailing: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleViewDetail(context, approval),
child: const Text('상세'),
),
for (var index = 0; index < approvals.length; index++) ...[
_PendingApprovalListTile(
approval: approvals[index],
now: now,
dateFormat: dateFormat,
onViewDetail: () => _handleViewDetail(context, approvals[index]),
),
const Divider(),
if (index < approvals.length - 1) const Divider(),
],
],
),
@@ -525,6 +511,191 @@ class _PendingApprovalCard extends StatelessWidget {
}
}
class _PendingApprovalListTile extends StatelessWidget {
const _PendingApprovalListTile({
required this.approval,
required this.now,
required this.dateFormat,
required this.onViewDetail,
});
final DashboardPendingApproval approval;
final DateTime now;
final intl.DateFormat dateFormat;
final VoidCallback onViewDetail;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final summary = _PendingApprovalSummary.parse(approval.stepSummary);
final requestedAt = approval.requestedAt;
final timestampLabel = requestedAt == null
? '상신일시 확인 불가'
: dateFormat.format(requestedAt.toLocal());
final elapsed = requestedAt == null
? null
: _formatElapsedKorean(now.difference(requestedAt));
final chips = <Widget>[ShadBadge(child: Text(approval.approvalNo))];
if (summary.stage != null && summary.stage!.isNotEmpty) {
chips.add(ShadBadge.outline(child: Text(summary.stage!)));
}
if (summary.actor != null && summary.actor!.isNotEmpty) {
chips.add(ShadBadge.outline(child: Text('승인자 ${summary.actor!}')));
}
if (summary.status != null && summary.status!.isNotEmpty) {
chips.add(ShadBadge.outline(child: Text('상태 ${summary.status!}')));
}
final description = summary.description;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 4),
child: Icon(
lucide.LucideIcons.fileCheck,
size: 18,
color: theme.colorScheme.mutedForeground,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
approval.title,
style: theme.textTheme.p.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.foreground,
),
),
if (chips.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 6, children: chips),
],
if (description != null) ...[
const SizedBox(height: 8),
Text(description, style: theme.textTheme.small),
],
const SizedBox(height: 8),
Text(
elapsed == null
? '상신: $timestampLabel'
: '상신: $timestampLabel · 경과 $elapsed',
style: theme.textTheme.small.copyWith(
color: theme.colorScheme.mutedForeground,
),
),
],
),
),
const SizedBox(width: 12),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: onViewDetail,
child: const Text('상세'),
),
],
),
);
}
}
class _PendingApprovalSummary {
const _PendingApprovalSummary({this.stage, this.actor, this.status});
final String? stage;
final String? actor;
final String? status;
static _PendingApprovalSummary parse(String? raw) {
if (raw == null) {
return const _PendingApprovalSummary();
}
var text = raw.trim();
if (text.isEmpty) {
return const _PendingApprovalSummary();
}
String? status;
final statusMatch = RegExp(r'\(([^)]+)\)$').firstMatch(text);
if (statusMatch != null) {
status = statusMatch.group(1)?.trim();
text = text.substring(0, statusMatch.start).trim();
}
final parts = text.split(RegExp(r'\s*[·/→>]+\s*'));
String? stage;
String? actor;
if (parts.isNotEmpty) {
final value = parts.first.trim();
if (value.isNotEmpty) {
stage = value;
}
}
if (parts.length >= 2) {
final joined = parts.sublist(1).join(' · ').trim();
if (joined.isNotEmpty) {
actor = joined;
}
}
return _PendingApprovalSummary(stage: stage, actor: actor, status: status);
}
String? get description {
final segments = <String>[];
if (stage != null && stage!.isNotEmpty) {
segments.add('현재 단계 $stage');
}
if (actor != null && actor!.isNotEmpty) {
segments.add('승인자 $actor');
}
if (status != null && status!.isNotEmpty) {
segments.add('상태 $status');
}
if (segments.isEmpty) {
return null;
}
return segments.join(' · ');
}
}
String _formatElapsedKorean(Duration duration) {
var value = duration;
if (value.isNegative) {
value = Duration(seconds: -value.inSeconds);
}
if (value.inMinutes < 1) {
return '1분 미만';
}
if (value.inHours < 1) {
return '${value.inMinutes}';
}
if (value.inHours < 24) {
final hours = value.inHours;
final minutes = value.inMinutes % 60;
if (minutes == 0) {
return '$hours시간';
}
return '$hours시간 $minutes분';
}
if (value.inDays < 7) {
final days = value.inDays;
final hours = value.inHours % 24;
if (hours == 0) {
return '$days일';
}
return '$days일 $hours시간';
}
final weeks = value.inDays ~/ 7;
final days = value.inDays % 7;
if (days == 0) {
return '$weeks주';
}
return '$weeks주 $days일';
}
class _DashboardApprovalDetailContent extends StatelessWidget {
const _DashboardApprovalDetailContent({required this.approval});