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

@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
/// 결재 승인자를 테이블/다이얼로그에서 일관되게 표시하기 위한 셀 위젯.
///
/// - 아바타에 이름 이니셜을 렌더링하고, 이름/사번/부가 설명을 함께 노출한다.
class ApprovalApproverCell extends StatelessWidget {
const ApprovalApproverCell({
super.key,
required this.name,
required this.employeeNo,
this.subtitle,
this.backgroundColor,
this.textColor,
this.avatarSize = 32,
});
/// 승인자 이름.
final String name;
/// 승인자 사번.
final String employeeNo;
/// 이름 아래에 노출할 부가 설명.
final String? subtitle;
/// 아바타 배경색. 지정하지 않으면 테마 보조색을 사용한다.
final Color? backgroundColor;
/// 텍스트 색상. 지정하지 않으면 테마 기본 텍스트 색을 사용한다.
final Color? textColor;
/// 아바타 지름.
final double avatarSize;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final baseColor = backgroundColor ?? theme.colorScheme.secondary;
final labelColor = textColor ?? theme.colorScheme.foreground;
final avatarTextColor = theme.colorScheme.secondaryForeground;
final initials = _buildInitials(name);
return Row(
mainAxisSize: MainAxisSize.max,
children: [
CircleAvatar(
radius: avatarSize / 2,
backgroundColor: baseColor.withValues(alpha: 0.14),
foregroundColor: avatarTextColor,
child: Text(
initials,
style: theme.textTheme.small.copyWith(fontWeight: FontWeight.w700),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
color: labelColor,
),
),
const SizedBox(height: 2),
Text(
employeeNo,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.muted,
),
if (subtitle != null && subtitle!.trim().isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitle!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.muted.copyWith(
fontSize: (theme.textTheme.muted.fontSize ?? 12).clamp(
11,
13,
),
),
),
],
],
),
),
],
);
}
String _buildInitials(String value) {
final segments = value.trim().split(RegExp(r'[\s\u00A0]+'));
if (segments.length == 1) {
final entry = segments.first;
if (entry.length <= 2) {
return entry.toUpperCase();
}
return entry.substring(0, 2).toUpperCase();
}
return (segments[0].isNotEmpty ? segments[0][0] : '') +
(segments[1].isNotEmpty ? segments[1][0] : '');
}
}
/// 결재 상태를 배경색/테두리와 함께 표시하는 배지 위젯.
class ApprovalStatusBadge extends StatelessWidget {
const ApprovalStatusBadge({super.key, required this.label, this.colorHex});
/// 상태 라벨.
final String label;
/// 백엔드에서 내려오는 HEX 문자열(예: `#12ABFF`).
final String? colorHex;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final baseColor = _resolveColor(colorHex, theme.colorScheme.primary);
final background = baseColor.withValues(alpha: 0.12);
final borderColor = baseColor.withValues(alpha: 0.36);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: borderColor),
),
child: Text(
label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
color: baseColor,
),
),
);
}
Color _resolveColor(String? value, Color fallback) {
if (value == null || value.isEmpty) {
return fallback;
}
var hex = value.trim();
if (hex.startsWith('#')) {
hex = hex.substring(1);
}
if (hex.length == 6) {
hex = 'FF$hex';
}
final parsed = int.tryParse(hex, radix: 16);
if (parsed == null) {
return fallback;
}
return Color(parsed);
}
}
/// 결재 메모를 아이콘과 함께 툴팁으로 노출하는 위젯.
class ApprovalNoteTooltip extends StatelessWidget {
const ApprovalNoteTooltip({
super.key,
required this.note,
this.placeholder = '-',
this.maxWidth = 220,
});
/// 메모 본문. 비어 있으면 [placeholder]를 표시한다.
final String? note;
/// 메모가 없을 때 대체로 표시할 텍스트.
final String placeholder;
/// 한 줄로 보여줄 때의 최대 너비.
final double maxWidth;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final trimmed = note?.trim();
if (trimmed == null || trimmed.isEmpty) {
return Text(placeholder, style: theme.textTheme.muted);
}
return Tooltip(
message: trimmed,
waitDuration: const Duration(milliseconds: 300),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
lucide.LucideIcons.stickyNote,
size: 16,
color: theme.colorScheme.mutedForeground,
),
const SizedBox(width: 6),
Flexible(
child: Text(
trimmed,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.small,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1 @@
export 'approval_ui_helpers.dart';