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:
@@ -147,7 +147,7 @@ class DashboardApprovalDto {
|
||||
final String approvalNo;
|
||||
final String title;
|
||||
final String stepSummary;
|
||||
final String? requestedAt;
|
||||
final DateTime? requestedAt;
|
||||
|
||||
factory DashboardApprovalDto.fromJson(Map<String, dynamic> json) {
|
||||
num? rawId = _readNum(json, 'approval_id');
|
||||
@@ -175,7 +175,7 @@ class DashboardApprovalDto {
|
||||
approvalNo: _readString(json, 'approval_no') ?? '',
|
||||
title: _readString(json, 'title') ?? '',
|
||||
stepSummary: _readString(json, 'step_summary') ?? '',
|
||||
requestedAt: _readString(json, 'requested_at'),
|
||||
requestedAt: _parseDate(json['requested_at']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,6 @@ class DashboardPendingApproval {
|
||||
/// 현재 단계/승인자 요약
|
||||
final String stepSummary;
|
||||
|
||||
/// 상신 일시(문자열)
|
||||
final String? requestedAt;
|
||||
/// 상신 일시
|
||||
final DateTime? requestedAt;
|
||||
}
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user