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:
220
lib/features/approvals/shared/widgets/approval_ui_helpers.dart
Normal file
220
lib/features/approvals/shared/widgets/approval_ui_helpers.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1
lib/features/approvals/shared/widgets/widgets.dart
Normal file
1
lib/features/approvals/shared/widgets/widgets.dart
Normal file
@@ -0,0 +1 @@
|
||||
export 'approval_ui_helpers.dart';
|
||||
Reference in New Issue
Block a user