web: migrate health notifications to js_interop; add browser hook
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled

- Replace dart:js with package:js in health_check_service_web.dart\n- Implement showHealthCheckNotification in web/index.html\n- Pin js dependency to ^0.6.7 for flutter_secure_storage_web compatibility

auth: harden AuthInterceptor + tests

- Allow overrideAuthRepository injection for testing\n- Normalize imports to package: paths\n- Add unit test covering token attach, 401→refresh→retry, and failure path\n- Add integration test skeleton gated by env vars

ui/data: map User.companyName to list column

- Add companyName to domain User\n- Map UserDto.company?.name\n- Render companyName in user_list

cleanup: remove legacy equipment table + unused code; minor warnings

- Remove _buildFlexibleTable and unused helpers\n- Remove unused zipcode details and cache retry constant\n- Fix null-aware and non-null assertions\n- Address child-last warnings in administrator dialog

docs: update AGENTS.md session context
This commit is contained in:
JiWoong Sul
2025-09-08 17:39:00 +09:00
parent 519e1883a3
commit 655d473413
55 changed files with 2729 additions and 4968 deletions

View File

@@ -471,6 +471,22 @@ class _AdministratorFormDialogState extends State<_AdministratorFormDialog> {
Widget build(BuildContext context) {
return ShadDialog(
title: Text(widget.title),
actions: [
ShadButton.outline(
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
child: const Text('취소'),
),
ShadButton(
onPressed: _isSubmitting ? null : _handleSubmit,
child: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_isEditMode ? '수정' : '생성'),
),
],
child: SizedBox(
width: 500,
child: Form(
@@ -575,22 +591,7 @@ class _AdministratorFormDialogState extends State<_AdministratorFormDialog> {
),
),
),
actions: [
ShadButton.outline(
child: const Text('취소'),
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(),
),
ShadButton(
onPressed: _isSubmitting ? null : _handleSubmit,
child: _isSubmitting
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(_isEditMode ? '수정' : '생성'),
),
],
);
}
}
}

View File

@@ -1137,6 +1137,14 @@ class SidebarMenu extends StatelessWidget {
),
],
),
_buildMenuItem(
icon: Icons.calendar_month_outlined,
title: '임대 관리',
route: Routes.rent,
isActive: currentRoute == Routes.rent,
badge: null,
),
if (!collapsed) ...[
@@ -1376,6 +1384,8 @@ class SidebarMenu extends StatelessWidget {
return Icons.factory;
case Icons.category_outlined:
return Icons.category;
case Icons.calendar_month_outlined:
return Icons.calendar_month;
default:
return outlinedIcon;
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
/// 폼 화면의 일관된 레이아웃을 제공하는 템플릿 위젯
class FormLayoutTemplate extends StatelessWidget {
@@ -27,27 +28,30 @@ class FormLayoutTemplate extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Color(0xFFF5F7FA),
backgroundColor: ShadcnTheme.background, // Phase 10: 통일된 배경색
appBar: AppBar(
title: Text(
title,
style: TextStyle(
fontSize: 18,
style: ShadcnTheme.headingH3.copyWith( // Phase 10: 표준 헤딩 스타일
fontWeight: FontWeight.w600,
color: Color(0xFF1A1F36),
color: ShadcnTheme.foreground,
),
),
backgroundColor: Colors.white,
backgroundColor: ShadcnTheme.card, // Phase 10: 카드 배경색
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back_ios, color: Color(0xFF6B7280), size: 20),
icon: Icon(
Icons.arrow_back_ios,
color: ShadcnTheme.mutedForeground, // Phase 10: 뮤트된 전경색
size: 20,
),
onPressed: onCancel ?? () => Navigator.of(context).pop(),
),
actions: customActions != null ? [customActions!] : null,
bottom: PreferredSize(
preferredSize: Size.fromHeight(1),
child: Container(
color: Color(0xFFE5E7EB),
color: ShadcnTheme.border, // Phase 10: 통일된 테두리 색상
height: 1,
),
),
@@ -60,13 +64,13 @@ class FormLayoutTemplate extends StatelessWidget {
Widget _buildBottomBar(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
color: ShadcnTheme.card, // Phase 10: 카드 배경색
border: Border(
top: BorderSide(color: Color(0xFFE5E7EB), width: 1),
top: BorderSide(color: ShadcnTheme.border, width: 1), // Phase 10: 통일된 테두리
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
color: ShadcnTheme.foreground.withValues(alpha: 0.05), // Phase 10: 그림자 색상
offset: Offset(0, -2),
blurRadius: 4,
),
@@ -125,24 +129,22 @@ class FormSection extends StatelessWidget {
if (title != null) ...[
Text(
title!,
style: TextStyle(
fontSize: 16,
style: ShadcnTheme.bodyLarge.copyWith( // Phase 10: 표준 바디 라지
fontWeight: FontWeight.w600,
color: Color(0xFF1A1F36),
color: ShadcnTheme.foreground, // Phase 10: 전경색
),
),
if (subtitle != null) ...[
SizedBox(height: 4),
Text(
subtitle!,
style: TextStyle(
fontSize: 14,
color: Color(0xFF6B7280),
style: ShadcnTheme.bodyMedium.copyWith( // Phase 10: 표준 바디 미디엄
color: ShadcnTheme.mutedForeground, // Phase 10: 뮤트된 전경색
),
),
],
SizedBox(height: 20),
Divider(color: Color(0xFFE5E7EB), height: 1),
Divider(color: ShadcnTheme.border, height: 1), // Phase 10: 테두리 색상
SizedBox(height: 20),
],
if (children.isNotEmpty)

View File

@@ -45,19 +45,19 @@ class ShadcnTheme {
static const Color infoLight = Color(0xFFCFFAFE); // cyan-100
static const Color infoForeground = Color(0xFFFFFFFF);
// ============= 비즈니스 상태 색상 =============
// 회사 구분 색상
static const Color companyHeadquarters = Color(0xFF2563EB); // 본사 - Primary Blue (권위)
static const Color companyBranch = Color(0xFF7C3AED); // 지점 - Purple (연결성)
static const Color companyPartner = Color(0xFF059669); // 파트너사 - Green (협력)
static const Color companyCustomer = Color(0xFFEA580C); // 고객사 - Orange (활력)
// ============= 비즈니스 상태 색상 (색체심리학 기반) =============
// 회사 구분 색상 - Phase 10 업데이트
static const Color companyHeadquarters = Color(0xFF2563EB); // 본사 - 진한 파랑 (권위, 안정성)
static const Color companyBranch = Color(0xFF3B82F6); // 지점 - 밝은 파랑 (연결성, 확장)
static const Color companyPartner = Color(0xFF10B981); // 파트너사 - 에메랄드 (협력, 신뢰)
static const Color companyCustomer = Color(0xFF059669); // 고객사 - 진한 그린 (성장, 번영)
// 장비 상태 색상
static const Color equipmentIn = Color(0xFF059669); // 입고 - Green (진입/추가)
static const Color equipmentOut = Color(0xFF0891B2); // 출고 - Cyan (이동/프로세스)
static const Color equipmentRent = Color(0xFF7C3AED); // 대여 - Purple (임시 상태)
static const Color equipmentDisposal = Color(0xFF6B7280); // 폐기 - Gray (비활성)
static const Color equipmentRepair = Color(0xFFD97706); // 수리중 - Amber (주의 필요)
// 트랜잭션 상태 색상 - Phase 10 업데이트
static const Color equipmentIn = Color(0xFF10B981); // 입고 - 에메랄드 (추가/성장)
static const Color equipmentOut = Color(0xFF3B82F6); // 출고 - 블루 (이동/처리)
static const Color equipmentRent = Color(0xFF8B5CF6); // 대여 - Purple (임시 상태)
static const Color equipmentDisposal = Color(0xFF6B7280); // 폐기 - Gray (종료/비활성)
static const Color equipmentRepair = Color(0xFFF59E0B); // 수리중 - Amber (주의/진행)
static const Color equipmentUnknown = Color(0xFF9CA3AF); // 알수없음 - Light Gray
// ============= UI 요소 색상 =============
@@ -93,8 +93,15 @@ class ShadcnTheme {
// 추가 색상 (기존 호환)
static const Color blue = primary;
static const Color purple = companyBranch;
static const Color green = companyPartner;
static const Color purple = equipmentRent;
static const Color green = equipmentIn;
// Phase 10 - 알림/경고 색상 체계 (긴급도 기반)
static const Color alertNormal = Color(0xFF10B981); // 60일 이상 - 안전 (그린)
static const Color alertWarning60 = Color(0xFFF59E0B); // 60일 이내 - 주의 (앰버)
static const Color alertWarning30 = Color(0xFFF97316); // 30일 이내 - 경고 (오렌지)
static const Color alertCritical7 = Color(0xFFEF4444); // 7일 이내 - 위험 (레드)
static const Color alertExpired = Color(0xFFDC2626); // 만료됨 - 심각 (진한 레드)
static const Color radius = Color(0xFF000000); // 사용하지 않음
@@ -526,51 +533,60 @@ class ShadcnTheme {
}
// ============= 유틸리티 메서드 =============
/// 회사 타입에 따른 색상 반환
/// 회사 타입에 따른 색상 반환 (Phase 10 업데이트)
static Color getCompanyColor(String type) {
switch (type.toLowerCase()) {
case '본사':
case 'headquarters':
return companyHeadquarters;
return companyHeadquarters; // #2563eb - 진한 파랑
case '지점':
case 'branch':
return companyBranch;
return companyBranch; // #3b82f6 - 밝은 파랑
case '파트너사':
case 'partner':
return companyPartner;
return companyPartner; // #10b981 - 에메랄드
case '고객사':
case 'customer':
return companyCustomer;
return companyCustomer; // #059669 - 진한 그린
default:
return secondary;
}
}
/// 장비 상태에 따른 색상 반환
/// 트랜잭션 상태에 따른 색상 반환 (Phase 10 업데이트)
static Color getEquipmentStatusColor(String status) {
switch (status.toLowerCase()) {
case '입고':
case 'in':
return equipmentIn;
return equipmentIn; // #10b981 - 에메랄드
case '출고':
case 'out':
return equipmentOut;
return equipmentOut; // #3b82f6 - 블루
case '대여':
case 'rent':
return equipmentRent;
return equipmentRent; // #8b5cf6 - 퍼플
case '폐기':
case 'disposal':
return equipmentDisposal;
return equipmentDisposal; // #6b7280 - 그레이
case '수리중':
case 'repair':
return equipmentRepair;
return equipmentRepair; // #f59e0b - 앰버
case '알수없음':
case 'unknown':
return equipmentUnknown;
return equipmentUnknown; // #9ca3af - 라이트 그레이
default:
return secondary;
}
}
/// 알림/경고 긴급도에 따른 색상 반환 (Phase 10 신규 추가)
static Color getAlertColor(int daysUntilExpiry) {
if (daysUntilExpiry < 0) return alertExpired; // 만료됨
if (daysUntilExpiry <= 7) return alertCritical7; // 7일 이내
if (daysUntilExpiry <= 30) return alertWarning30; // 30일 이내
if (daysUntilExpiry <= 60) return alertWarning60; // 60일 이내
return alertNormal; // 60일 이상
}
/// 상태별 배경색 반환 (연한 버전)
static Color getStatusBackgroundColor(String status) {

View File

@@ -162,13 +162,13 @@ class _CompanyListState extends State<CompanyList> {
}
/// 본사/지점 구분 배지 생성
/// 본사/지점 구분 배지 생성 - Phase 10: 색체심리학 기반 색상 적용
Widget _buildCompanyTypeLabel(bool isBranch) {
return ShadcnBadge(
text: isBranch ? '지점' : '본사',
variant: isBranch
? ShadcnBadgeVariant.companyBranch // Purple (#7C3AED) - 차별화
: ShadcnBadgeVariant.companyHeadquarters, // Blue (#2563EB)
? ShadcnBadgeVariant.companyBranch // Phase 10: 지점 - 밝은 파랑
: ShadcnBadgeVariant.companyHeadquarters, // Phase 10: 본사 - 진한 파랑
size: ShadcnBadgeSize.small,
);
}
@@ -261,7 +261,7 @@ class _CompanyListState extends State<CompanyList> {
if (item.isPartner) {
flags.add(ShadcnBadge(
text: '파트너',
variant: ShadcnBadgeVariant.companyPartner,
variant: ShadcnBadgeVariant.companyPartner, // Phase 10: 협력 - 에메랄드
size: ShadcnBadgeSize.small,
));
}
@@ -269,7 +269,7 @@ class _CompanyListState extends State<CompanyList> {
if (item.isCustomer) {
flags.add(ShadcnBadge(
text: '고객',
variant: ShadcnBadgeVariant.companyCustomer,
variant: ShadcnBadgeVariant.companyCustomer, // Phase 10: 고객 - 진한 그린
size: ShadcnBadgeSize.small,
));
}
@@ -313,259 +313,105 @@ class _CompanyListState extends State<CompanyList> {
}
}
/// 헤더 셀 빌더
Widget _buildHeaderCell(
String text, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final child = Container(
alignment: Alignment.centerLeft,
child: Text(
text,
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
);
// (Deprecated) 기존 커스텀 테이블 빌더 유틸들은 ShadTable 전환으로 제거되었습니다.
if (useExpanded) {
return Expanded(flex: flex, child: child);
} else {
return SizedBox(width: minWidth, child: child);
}
}
/// 데이터 셀 빌더
Widget _buildDataCell(
Widget child, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final container = Container(
alignment: Alignment.centerLeft,
child: child,
);
if (useExpanded) {
return Expanded(flex: flex, child: container);
} else {
return SizedBox(width: minWidth, child: container);
}
}
/// 헤더 셀 리스트
List<Widget> _buildHeaderCells() {
return [
_buildHeaderCell('번호', flex: 0, useExpanded: false, minWidth: 50),
_buildHeaderCell('회사명', flex: 3, useExpanded: true, minWidth: 120),
_buildHeaderCell('구분', flex: 0, useExpanded: false, minWidth: 60),
_buildHeaderCell('주소', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('담당자', flex: 2, useExpanded: true, minWidth: 80),
_buildHeaderCell('연락처', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('파트너/고객', flex: 1, useExpanded: true, minWidth: 80),
_buildHeaderCell('상태', flex: 0, useExpanded: false, minWidth: 60),
_buildHeaderCell('등록/수정일', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('비고', flex: 1, useExpanded: true, minWidth: 80),
_buildHeaderCell('관리', flex: 0, useExpanded: false, minWidth: 100),
];
}
/// 테이블 행 빌더
Widget _buildTableRow(CompanyItem item, int index, CompanyListController controller) {
final rowNumber = ((controller.currentPage - 1) * controller.pageSize) + index + 1;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: index.isEven
? ShadcnTheme.muted.withValues(alpha: 0.1)
: null,
border: const Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
_buildDataCell(
Text(
rowNumber.toString(),
style: ShadcnTheme.bodySmall,
),
flex: 0,
useExpanded: false,
minWidth: 50,
),
_buildDataCell(
_buildDisplayNameText(item),
flex: 3,
useExpanded: true,
minWidth: 120,
),
_buildDataCell(
_buildCompanyTypeLabel(item.isBranch),
flex: 0,
useExpanded: false,
minWidth: 60,
),
_buildDataCell(
Text(
item.address.isNotEmpty ? item.address : '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
flex: 2,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
_buildContactInfo(item),
flex: 2,
useExpanded: true,
minWidth: 80,
),
_buildDataCell(
_buildContactDetails(item),
flex: 2,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
_buildPartnerCustomerFlags(item),
flex: 1,
useExpanded: true,
minWidth: 80,
),
_buildDataCell(
_buildStatusBadge(item.isActive),
flex: 0,
useExpanded: false,
minWidth: 60,
),
_buildDataCell(
_buildDateInfo(item),
flex: 2,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
Text(
item.remark ?? '-',
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
flex: 1,
useExpanded: true,
minWidth: 80,
),
_buildDataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (item.id != null) ...[
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () {
if (item.isBranch) {
Navigator.pushNamed(
context,
'/company/branch/edit',
arguments: {
'companyId': item.parentCompanyId,
'branchId': item.id,
'parentCompanyName': item.parentCompanyName,
},
).then((result) {
if (result == true) controller.refresh();
});
} else {
Navigator.pushNamed(
context,
'/company/edit',
arguments: {
'companyId': item.id,
'isBranch': false,
},
).then((result) {
if (result == true) controller.refresh();
});
}
},
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: 4),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () {
if (item.isBranch) {
_deleteBranch(item.parentCompanyId!, item.id!);
} else {
_deleteCompany(item.id!);
}
},
child: const Icon(Icons.delete, size: 16),
),
],
],
),
flex: 0,
useExpanded: false,
minWidth: 100,
),
],
),
);
}
/// 헤더 고정 패턴 회사 테이블 빌더
/// ShadTable 기반 회사 테이블 빌더 (기존 컬럼 구성 유지)
Widget _buildCompanyShadTable(List<CompanyItem> items, CompanyListController controller) {
if (items.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.business_outlined, size: 64, color: ShadcnTheme.mutedForeground),
const SizedBox(height: 16),
Text('등록된 회사가 없습니다', style: ShadcnTheme.bodyMedium.copyWith(color: ShadcnTheme.mutedForeground)),
],
),
);
}
return Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Column(
children: [
// 고정 헤더
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(bottom: BorderSide(color: Colors.black)),
),
child: Row(children: _buildHeaderCells()),
),
// 스크롤 바디
Expanded(
child: items.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('번호')),
ShadTableCell.header(child: Text('회사명')),
ShadTableCell.header(child: Text('구분')),
ShadTableCell.header(child: Text('주소')),
ShadTableCell.header(child: Text('담당자')),
ShadTableCell.header(child: Text('연락처')),
ShadTableCell.header(child: Text('파트너/고객')),
ShadTableCell.header(child: Text('상태')),
ShadTableCell.header(child: Text('등록/수정일')),
ShadTableCell.header(child: Text('비고')),
ShadTableCell.header(child: Text('관리')),
],
children: [
for (int index = 0; index < items.length; index++)
[
// 번호
ShadTableCell(child: Text(((((controller.currentPage - 1) * controller.pageSize) + index + 1)).toString(), style: ShadcnTheme.bodySmall)),
// 회사명 (본사>지점 표기 유지)
ShadTableCell(child: _buildDisplayNameText(items[index])),
// 구분 (본사/지점)
ShadTableCell(child: _buildCompanyTypeLabel(items[index].isBranch)),
// 주소
ShadTableCell(child: Text(items[index].address.isNotEmpty ? items[index].address : '-', overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodySmall)),
// 담당자 요약
ShadTableCell(child: _buildContactInfo(items[index])),
// 연락처 상세
ShadTableCell(child: _buildContactDetails(items[index])),
// 파트너/고객 플래그
ShadTableCell(child: _buildPartnerCustomerFlags(items[index])),
// 상태
ShadTableCell(child: _buildStatusBadge(items[index].isActive)),
// 등록/수정일
ShadTableCell(child: _buildDateInfo(items[index])),
// 비고
ShadTableCell(child: Text(items[index].remark ?? '-', overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodySmall)),
// 관리(편집/삭제)
ShadTableCell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (items[index].id != null) ...[
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () async {
// 기존 편집 흐름 유지
final args = {'companyId': items[index].id};
final result = await Navigator.pushNamed(context, '/company/edit', arguments: args);
if (result == true) {
controller.refresh();
}
},
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(height: 16),
Text(
'등록된 회사가 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
const SizedBox(width: 4),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () {
if (items[index].isBranch) {
_deleteBranch(items[index].parentCompanyId!, items[index].id!);
} else {
_deleteCompany(items[index].id!);
}
},
child: const Icon(Icons.delete, size: 16),
),
],
),
)
: ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) => _buildTableRow(items[index], index, controller),
],
),
),
],
),
],
],
),
),
);
}
@@ -761,4 +607,4 @@ class _CompanyListState extends State<CompanyList> {
),
);
}
}
}

View File

@@ -233,13 +233,13 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
label: Text(_controller.isFieldReadOnly('serialNumber')
? '장비 번호 * 🔒' : '장비 번호 *'),
validator: (value) {
if (value?.trim().isEmpty ?? true) {
if ((value ?? '').trim().isEmpty) {
return '장비 번호는 필수입니다';
}
return null;
},
onChanged: _controller.isFieldReadOnly('serialNumber') ? null : (value) {
_controller.serialNumber = value?.trim() ?? '';
_controller.serialNumber = value.trim();
setState(() {});
print('DEBUG [장비번호 입력] value: "$value", controller.serialNumber: "${_controller.serialNumber}"');
},
@@ -252,7 +252,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
placeholder: const Text('바코드를 입력하세요'),
label: const Text('바코드'),
onChanged: (value) {
_controller.barcode = value?.trim() ?? '';
_controller.barcode = value.trim();
print('DEBUG [바코드 입력] value: "$value", controller.barcode: "${_controller.barcode}"');
},
),
@@ -504,7 +504,7 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
label: const Text('워런티 번호 *'),
placeholder: const Text('워런티 번호를 입력하세요'),
validator: (value) {
if (value.trim().isEmpty ?? true) {
if (value.trim().isEmpty) {
return '워런티 번호는 필수입니다';
}
return null;
@@ -683,4 +683,4 @@ class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
];
}
}
}

View File

@@ -57,6 +57,8 @@ class _EquipmentListState extends State<EquipmentList> {
});
}
// 드롭다운 데이터를 미리 로드하는 메서드
Future<void> _preloadDropdownData() async {
try {
@@ -94,6 +96,157 @@ class _EquipmentListState extends State<EquipmentList> {
});
}
/// ShadTable 기반 장비 목록 테이블
///
/// - 표준 컴포넌트 사용으로 일관성 확보
/// - 핵심 컬럼만 우선 도입 (상태/장비번호/시리얼/제조사/모델/회사/창고/일자/관리)
/// - 반응형: 가용 너비에 따라 일부 컬럼은 숨김 처리 가능
Widget _buildShadTable(List<UnifiedEquipment> items, {required double availableWidth}) {
final allSelected = items.isNotEmpty &&
items.every((e) => _selectedItems.contains(e.equipment.id));
return ShadTable.list(
header: [
// 선택
ShadTableCell.header(
child: ShadCheckbox(
value: allSelected,
onChanged: (checked) {
setState(() {
if (checked == true) {
_selectedItems
..clear()
..addAll(items.map((e) => e.equipment.id).whereType<int>());
} else {
_selectedItems.clear();
}
});
},
),
),
ShadTableCell.header(child: const Text('상태')),
ShadTableCell.header(child: const Text('장비번호')),
ShadTableCell.header(child: const Text('시리얼')),
ShadTableCell.header(child: const Text('제조사')),
ShadTableCell.header(child: const Text('모델')),
if (availableWidth > 900) ShadTableCell.header(child: const Text('회사')),
if (availableWidth > 1100) ShadTableCell.header(child: const Text('창고')),
if (availableWidth > 800) ShadTableCell.header(child: const Text('일자')),
ShadTableCell.header(child: const Text('관리')),
],
children: items.map((item) {
final id = item.equipment.id;
final selected = id != null && _selectedItems.contains(id);
return [
// 선택 체크박스
ShadTableCell(
child: ShadCheckbox(
value: selected,
onChanged: (checked) {
setState(() {
if (id == null) return;
if (checked == true) {
_selectedItems.add(id);
} else {
_selectedItems.remove(id);
}
});
},
),
),
// 상태
ShadTableCell(child: _buildStatusBadge(item.status)),
// 장비번호
ShadTableCell(
child: _buildTextWithTooltip(
item.equipment.equipmentNumber,
item.equipment.equipmentNumber,
),
),
// 시리얼
ShadTableCell(
child: _buildTextWithTooltip(
item.equipment.serialNumber ?? '-',
item.equipment.serialNumber ?? '-',
),
),
// 제조사
ShadTableCell(
child: _buildTextWithTooltip(
item.vendorName ?? item.equipment.manufacturer,
item.vendorName ?? item.equipment.manufacturer,
),
),
// 모델
ShadTableCell(
child: _buildTextWithTooltip(
item.modelName ?? item.equipment.modelName,
item.modelName ?? item.equipment.modelName,
),
),
// 회사 (반응형)
if (availableWidth > 900)
ShadTableCell(
child: _buildTextWithTooltip(
item.companyName ?? item.currentCompany ?? '-',
item.companyName ?? item.currentCompany ?? '-',
),
),
// 창고 (반응형)
if (availableWidth > 1100)
ShadTableCell(
child: _buildTextWithTooltip(
item.warehouseLocation ?? '-',
item.warehouseLocation ?? '-',
),
),
// 일자 (반응형)
if (availableWidth > 800)
ShadTableCell(
child: _buildTextWithTooltip(
_formatDate(item.date),
_formatDate(item.date),
),
),
// 관리 액션
ShadTableCell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: '이력 보기',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDialog(item.equipment.id ?? 0),
child: const Icon(Icons.history, size: 16),
),
),
const SizedBox(width: 4),
Tooltip(
message: '수정',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleEdit(item),
child: const Icon(Icons.edit, size: 16),
),
),
const SizedBox(width: 4),
Tooltip(
message: '삭제',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleDelete(item),
child: const Icon(Icons.delete_outline, size: 16),
),
),
],
),
),
];
}).toList(),
);
}
/// 라우트에 따른 초기 필터 설정
void _setInitialFilter() {
switch (widget.currentRoute) {
@@ -173,32 +326,7 @@ class _EquipmentListState extends State<EquipmentList> {
}
/// 전체 선택/해제
void _onSelectAll(bool? value) {
setState(() {
final equipments = _getFilteredEquipments();
_selectedItems.clear(); // UI 체크박스 상태 초기화
if (value == true) {
for (final equipment in equipments) {
if (equipment.equipment.id != null) {
_selectedItems.add(equipment.equipment.id!);
_controller.selectEquipment(equipment);
}
}
} else {
_controller.clearSelection();
}
});
}
/// 전체 선택 상태 확인
bool _isAllSelected() {
final equipments = _getFilteredEquipments();
if (equipments.isEmpty) return false;
return equipments.every((e) =>
_controller.selectedEquipmentIds.contains('${e.equipment.id}:${e.status}'));
}
/// 필터링된 장비 목록 반환
@@ -986,339 +1114,11 @@ class _EquipmentListState extends State<EquipmentList> {
return totalWidth;
}
/// 헤더 셀 빌더
Widget _buildHeaderCell(
String text, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final child = Container(
alignment: Alignment.centerLeft,
child: Text(
text,
style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
);
if (useExpanded) {
return Expanded(flex: flex, child: child);
} else {
return SizedBox(width: minWidth, child: child);
}
}
/// 데이터 셀 빌더
Widget _buildDataCell(
Widget child, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final container = Container(
alignment: Alignment.centerLeft,
child: child,
);
if (useExpanded) {
return Expanded(flex: flex, child: container);
} else {
return SizedBox(width: minWidth, child: container);
}
}
/// 유연한 테이블 빌더 - Virtual Scrolling 적용
Widget _buildFlexibleTable(List<UnifiedEquipment> pagedEquipments, {required bool useExpanded, required double availableWidth}) {
final hasOutOrRent = pagedEquipments.any((e) =>
e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent
);
// 헤더를 별도로 빌드 - 반응형 컬럼 적용
Widget header = Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing1, // spacing2 -> spacing1로 더 축소
vertical: 6, // 8 -> 6으로 더 축소
),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
// 필수 컬럼들 (항상 표시) - 축소된 너비 적용
// 체크박스
_buildDataCell(
ShadCheckbox(
value: _isAllSelected(),
onChanged: (bool? value) => _onSelectAll(value),
),
flex: 1,
useExpanded: useExpanded,
minWidth: 30,
),
// 번호
_buildHeaderCell('번호', flex: 1, useExpanded: useExpanded, minWidth: 35),
// 회사명 (소유회사)
_buildHeaderCell('소유회사', flex: 2, useExpanded: useExpanded, minWidth: 70),
// 제조사
_buildHeaderCell('제조사', flex: 2, useExpanded: useExpanded, minWidth: 60),
// 모델명
_buildHeaderCell('모델명', flex: 3, useExpanded: useExpanded, minWidth: 80),
// 장비번호
_buildHeaderCell('장비번호', flex: 3, useExpanded: useExpanded, minWidth: 70),
// 상태
_buildHeaderCell('상태', flex: 2, useExpanded: useExpanded, minWidth: 50),
// 관리
_buildHeaderCell('관리', flex: 2, useExpanded: useExpanded, minWidth: 100),
// 중간 화면용 컬럼들 (800px 이상)
if (availableWidth > 800) ...[
// 수량
_buildHeaderCell('수량', flex: 1, useExpanded: useExpanded, minWidth: 35),
// 입출고일
_buildHeaderCell('입출고일', flex: 2, useExpanded: useExpanded, minWidth: 70),
],
// 상세 컬럼들 (1200px 이상에서만 표시)
if (_showDetailedColumns && availableWidth > 1200) ...[
_buildHeaderCell('바코드', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('구매가격', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('구매일', flex: 2, useExpanded: useExpanded, minWidth: 70),
_buildHeaderCell('보증기간', flex: 2, useExpanded: useExpanded, minWidth: 80),
],
],
),
);
// 빈 상태 처리
if (pagedEquipments.isEmpty) {
return Column(
children: [
header,
Expanded(
child: Center(
child: Text(
'데이터가 없습니다',
style: ShadcnTheme.bodyMedium,
),
),
),
],
);
}
// Virtual Scrolling을 위한 CustomScrollView 사용
return Column(
mainAxisSize: MainAxisSize.min,
children: [
header, // 헤더는 고정
Expanded(
child: ListView.builder(
controller: ScrollController(),
itemCount: pagedEquipments.length,
itemBuilder: (context, index) {
final UnifiedEquipment equipment = pagedEquipments[index];
return Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing1, // spacing2 -> spacing1로 더 축소
vertical: 2, // 3 -> 2로 더 축소
),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
// 필수 컬럼들 (항상 표시) - 축소된 너비 적용
// 체크박스
_buildDataCell(
ShadCheckbox(
value: _selectedItems.contains(equipment.equipment.id ?? 0),
onChanged: (bool? value) {
if (equipment.equipment.id != null) {
_onItemSelected(equipment.equipment.id!, value ?? false);
}
},
),
flex: 1,
useExpanded: useExpanded,
minWidth: 30,
),
// 번호
_buildDataCell(
Text(
'${((_controller.currentPage - 1) * _controller.pageSize) + index + 1}',
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 35,
),
// 소유회사
_buildDataCell(
_buildTextWithTooltip(
equipment.companyName ?? 'N/A',
equipment.companyName ?? 'N/A',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 제조사
_buildDataCell(
_buildTextWithTooltip(
equipment.vendorName ?? 'N/A',
equipment.vendorName ?? 'N/A',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 60,
),
// 모델명
_buildDataCell(
_buildTextWithTooltip(
equipment.modelName ?? '-',
equipment.modelName ?? '-',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 80,
),
// 장비번호
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.serialNumber ?? '',
equipment.equipment.serialNumber ?? '',
),
flex: 3,
useExpanded: useExpanded,
minWidth: 70,
),
// 상태
_buildDataCell(
_buildStatusBadge(equipment.status),
flex: 2,
useExpanded: useExpanded,
minWidth: 50,
),
// 관리 (아이콘 전용 버튼으로 최적화)
_buildDataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
Tooltip(
message: '이력 보기',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDialog(equipment.equipment.id ?? 0),
child: const Icon(Icons.history, size: 16),
),
),
const SizedBox(width: 1),
Tooltip(
message: '수정',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleEdit(equipment),
child: const Icon(Icons.edit, size: 16),
),
),
const SizedBox(width: 1),
Tooltip(
message: '삭제',
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _handleDelete(equipment),
child: const Icon(Icons.delete_outline, size: 16),
),
),
],
),
flex: 2,
useExpanded: useExpanded,
minWidth: 100,
),
// 중간 화면용 컬럼들 (800px 이상)
if (availableWidth > 800) ...[
// 수량 (백엔드에서 관리하지 않으므로 고정값)
_buildDataCell(
Text(
'1',
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: useExpanded,
minWidth: 35,
),
// 입출고일
_buildDataCell(
_buildTextWithTooltip(
_formatDate(equipment.date),
_formatDate(equipment.date),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
],
// 상세 컬럼들 (1200px 이상에서만 표시)
if (_showDetailedColumns && availableWidth > 1200) ...[
// 바코드
_buildDataCell(
_buildTextWithTooltip(
equipment.equipment.barcode ?? '-',
equipment.equipment.barcode ?? '-',
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 구매가격
_buildDataCell(
_buildTextWithTooltip(
_formatPrice(equipment.equipment.purchasePrice),
_formatPrice(equipment.equipment.purchasePrice),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 구매일
_buildDataCell(
_buildTextWithTooltip(
_formatDate(equipment.equipment.purchaseDate),
_formatDate(equipment.equipment.purchaseDate),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 70,
),
// 보증기간
_buildDataCell(
_buildTextWithTooltip(
_formatWarrantyPeriod(equipment.equipment.warrantyStartDate, equipment.equipment.warrantyEndDate),
_formatWarrantyPeriod(equipment.equipment.warrantyStartDate, equipment.equipment.warrantyEndDate),
),
flex: 2,
useExpanded: useExpanded,
minWidth: 80,
),
],
],
),
);
},
),
),
],
);
}
/// 데이터 테이블
Widget _buildDataTable(List<UnifiedEquipment> filteredEquipments) {
@@ -1367,19 +1167,18 @@ class _EquipmentListState extends State<EquipmentList> {
final minimumWidth = _getMinimumTableWidth(pagedEquipments, availableWidth);
final needsHorizontalScroll = minimumWidth > availableWidth;
// ShadTable 경로로 일괄 전환 (가로 스크롤은 ShadTable 외부에서 처리)
if (needsHorizontalScroll) {
// 최소 너비보다 작을 때만 스크롤 활성화
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _horizontalScrollController,
child: SizedBox(
width: minimumWidth,
child: _buildFlexibleTable(pagedEquipments, useExpanded: false, availableWidth: availableWidth),
child: _buildShadTable(pagedEquipments, availableWidth: availableWidth),
),
);
} else {
// 충분한 공간이 있을 때는 Expanded 사용
return _buildFlexibleTable(pagedEquipments, useExpanded: true, availableWidth: availableWidth);
return _buildShadTable(pagedEquipments, availableWidth: availableWidth);
}
},
),
@@ -1399,10 +1198,7 @@ class _EquipmentListState extends State<EquipmentList> {
}
/// 가격 포맷팅
String _formatPrice(double? price) {
if (price == null) return '-';
return '${(price / 10000).toStringAsFixed(0)}만원';
}
/// 날짜 포맷팅
String _formatDate(DateTime? date) {
@@ -1411,75 +1207,8 @@ class _EquipmentListState extends State<EquipmentList> {
}
/// 보증기간 포맷팅
String _formatWarrantyPeriod(DateTime? startDate, DateTime? endDate) {
if (startDate == null || endDate == null) return '-';
final now = DateTime.now();
final isExpired = now.isAfter(endDate);
final remainingDays = isExpired ? 0 : endDate.difference(now).inDays;
if (isExpired) {
return '만료됨';
} else if (remainingDays <= 30) {
return '$remainingDays일 남음';
} else {
return _formatDate(endDate);
}
}
/// 재고 상태 위젯 빌더 (백엔드 기반 단순화)
Widget _buildInventoryStatus(UnifiedEquipment equipment) {
// 백엔드 Equipment_History 기반으로 단순 상태만 표시
Widget stockInfo;
if (equipment.status == 'I') {
// 입고 상태: 재고 있음
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.check_circle, color: Colors.green, size: 16),
const SizedBox(width: 4),
Text(
'보유중',
style: ShadcnTheme.bodySmall.copyWith(color: Colors.green[700]),
),
],
);
} else if (equipment.status == 'O') {
// 출고 상태: 재고 없음
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.warning, color: Colors.orange, size: 16),
const SizedBox(width: 4),
Text(
'출고됨',
style: ShadcnTheme.bodySmall.copyWith(color: Colors.orange[700]),
),
],
);
} else if (equipment.status == 'T') {
// 대여 상태
stockInfo = Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule, color: Colors.blue, size: 16),
const SizedBox(width: 4),
Text(
'대여중',
style: ShadcnTheme.bodySmall.copyWith(color: Colors.blue[700]),
),
],
);
} else {
// 기타 상태
stockInfo = Text(
'-',
style: ShadcnTheme.bodySmall,
);
}
return stockInfo;
}
/// 상태 배지 빌더
Widget _buildStatusBadge(String status) {
@@ -1528,51 +1257,8 @@ class _EquipmentListState extends State<EquipmentList> {
);
}
/// 입출고일 위젯 빌더
Widget _buildCreatedDateWidget(UnifiedEquipment equipment) {
String dateStr = equipment.date.toString().substring(0, 10);
return Text(
dateStr,
style: ShadcnTheme.bodySmall,
);
}
/// 액션 버튼 빌더
Widget _buildActionButtons(int equipmentId) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
// 이력 버튼 - 텍스트 + 아이콘으로 강화
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDialog(equipmentId),
child: Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(Icons.history, size: 14),
SizedBox(width: 4),
Text('이력', style: TextStyle(fontSize: 12)),
],
),
),
const SizedBox(width: 4),
// 편집 버튼
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _handleEditById(equipmentId),
child: const Icon(Icons.edit_outlined, size: 14),
),
const SizedBox(width: 4),
// 삭제 버튼
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _handleDeleteById(equipmentId),
child: const Icon(Icons.delete_outline, size: 14),
),
],
);
}
// 장비 이력 다이얼로그 표시
void _showEquipmentHistoryDialog(int equipmentId) async {
@@ -1596,43 +1282,10 @@ class _EquipmentListState extends State<EquipmentList> {
// 편집 핸들러 (액션 버튼에서 호출) - 장비 ID로 처리
void _handleEditById(int equipmentId) {
// 해당 장비 찾기
final equipment = _controller.equipments.firstWhere(
(e) => e.equipment.id == equipmentId,
orElse: () => throw Exception('Equipment not found'),
);
_handleEdit(equipment);
}
// 삭제 핸들러 (액션 버튼에서 호출) - 장비 ID로 처리
void _handleDeleteById(int equipmentId) {
// 해당 장비 찾기
final equipment = _controller.equipments.firstWhere(
(e) => e.equipment.id == equipmentId,
orElse: () => throw Exception('Equipment not found'),
);
_handleDelete(equipment);
}
/// 체크박스 선택 관련 함수들
void _onItemSelected(int id, bool selected) {
// 해당 장비 찾기
final equipment = _controller.equipments.firstWhere(
(e) => e.equipment.id == id,
orElse: () => throw Exception('Equipment not found'),
);
setState(() {
if (selected) {
_selectedItems.add(id);
_controller.selectEquipment(equipment); // Controller에도 전달
} else {
_selectedItems.remove(id);
_controller.toggleSelection(equipment); // 선택 해제
}
});
}
// 사용하지 않는 카테고리 관련 함수들 제거됨 (리스트 API에서 제공하지 않음)

View File

@@ -75,180 +75,8 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
);
}
/// 헤더 셀 빌더
Widget _buildHeaderCell(
String text, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final child = Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Text(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
);
// (Deprecated) 기존 커스텀 테이블 빌더 유틸들은 ShadTable 전환으로 제거되었습니다.
if (useExpanded) {
return Expanded(flex: flex, child: child);
} else {
return SizedBox(width: minWidth, child: child);
}
}
/// 데이터 셀 빌더
Widget _buildDataCell(
Widget child, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final container = Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: child,
);
if (useExpanded) {
return Expanded(flex: flex, child: container);
} else {
return SizedBox(width: minWidth, child: container);
}
}
/// 헤더 셀 리스트 (요구사항에 맞게 재정의)
List<Widget> _buildHeaderCells() {
return [
_buildHeaderCell('장비명', flex: 3, useExpanded: true, minWidth: 150),
_buildHeaderCell('시리얼번호', flex: 2, useExpanded: true, minWidth: 120),
_buildHeaderCell('위치', flex: 2, useExpanded: true, minWidth: 120),
_buildHeaderCell('변동일', flex: 1, useExpanded: false, minWidth: 100),
_buildHeaderCell('작업', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('비고', flex: 2, useExpanded: true, minWidth: 120),
];
}
/// 테이블 행 빌더 (요구사항에 맞게 재정의)
Widget _buildTableRow(InventoryHistoryViewModel history, int index) {
return Container(
decoration: BoxDecoration(
color: index.isEven ? ShadcnTheme.muted.withValues(alpha: 0.1) : null,
border: const Border(
bottom: BorderSide(color: Colors.black12, width: 1),
),
),
child: Row(
children: [
// 장비명
_buildDataCell(
Tooltip(
message: history.equipmentName,
child: Text(
history.equipmentName,
style: ShadcnTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
flex: 3,
useExpanded: true,
minWidth: 150,
),
// 시리얼번호
_buildDataCell(
Tooltip(
message: history.serialNumber,
child: Text(
history.serialNumber,
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
flex: 2,
useExpanded: true,
minWidth: 120,
),
// 위치 (출고/대여: 고객사, 입고/폐기: 창고)
_buildDataCell(
Tooltip(
message: history.location,
child: Row(
children: [
Icon(
history.isCustomerLocation ? Icons.business : Icons.warehouse,
size: 14,
color: history.isCustomerLocation ? Colors.blue : Colors.green,
),
const SizedBox(width: 4),
Expanded(
child: Text(
history.location,
style: ShadcnTheme.bodySmall,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
flex: 2,
useExpanded: true,
minWidth: 120,
),
// 변동일
_buildDataCell(
Text(
history.formattedDate,
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: false,
minWidth: 100,
),
// 작업 (상세보기만)
_buildDataCell(
ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDetail(history),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.history, size: 14),
SizedBox(width: 4),
Text('상세보기', style: TextStyle(fontSize: 12)),
],
),
),
flex: 0,
useExpanded: false,
minWidth: 80,
),
// 비고
_buildDataCell(
Tooltip(
message: history.remark ?? '비고 없음',
child: Text(
history.remark ?? '-',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.mutedForeground,
),
overflow: TextOverflow.ellipsis,
),
),
flex: 2,
useExpanded: true,
minWidth: 120,
),
],
),
);
}
/// 장비 이력 상세보기 다이얼로그 표시
void _showEquipmentHistoryDetail(InventoryHistoryViewModel history) async {
@@ -304,17 +132,104 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
height: 40,
width: 120,
child: ShadSelect<String>(
selectedOptionBuilder: (context, value) => Text(
_getTransactionTypeDisplayText(value),
style: const TextStyle(fontSize: 14),
selectedOptionBuilder: (context, value) => Row(
mainAxisSize: MainAxisSize.min,
children: [
if (value != 'all') ...[
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: _getTransactionTypeColor(value),
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
],
Text(
_getTransactionTypeDisplayText(value),
style: const TextStyle(fontSize: 14),
),
],
),
placeholder: const Text('거래 유형'),
options: [
const ShadOption(value: 'all', child: Text('전체')),
const ShadOption(value: 'I', child: Text('입고')),
const ShadOption(value: 'O', child: Text('출고')),
const ShadOption(value: 'R', child: Text('대여')),
const ShadOption(value: 'D', child: Text('폐기')),
const ShadOption(
value: 'all',
child: Text('전체'),
),
ShadOption(
value: 'I',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: ShadcnTheme.equipmentIn,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text('입고'),
],
),
),
ShadOption(
value: 'O',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: ShadcnTheme.equipmentOut,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text('출고'),
],
),
),
ShadOption(
value: 'R',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: ShadcnTheme.equipmentRent,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text('대여'),
],
),
),
ShadOption(
value: 'D',
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: ShadcnTheme.equipmentDisposal,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
const Text('폐기'),
],
),
),
],
onChanged: (value) {
if (value != null) {
@@ -407,7 +322,7 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
],
totalCount: stats['total'],
statusMessage: controller.hasActiveFilters
? '${controller.filterStatusText}'
? controller.filterStatusText
: '장비 입출고 이력을 조회합니다',
);
},
@@ -432,7 +347,23 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
}
}
/// 데이터 테이블 빌더
/// 거래 유형별 Phase 10 색상 반환
Color _getTransactionTypeColor(String type) {
switch (type) {
case 'I':
return ShadcnTheme.equipmentIn; // 입고 - 그린
case 'O':
return ShadcnTheme.equipmentOut; // 출고 - 블루
case 'R':
return ShadcnTheme.equipmentRent; // 대여 - 퍼플
case 'D':
return ShadcnTheme.equipmentDisposal; // 폐기 - 그레이
default:
return ShadcnTheme.foregroundMuted; // 기본/전체
}
}
/// 데이터 테이블 빌더 (ShadTable)
Widget _buildDataTable(List<InventoryHistoryViewModel> historyList) {
if (historyList.isEmpty) {
return Center(
@@ -471,31 +402,66 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 고정 헤더
Container(
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: const Border(
bottom: BorderSide(color: Colors.black12),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('장비명')),
ShadTableCell.header(child: Text('시리얼번호')),
ShadTableCell.header(child: Text('위치')),
ShadTableCell.header(child: Text('변동일')),
ShadTableCell.header(child: Text('작업')),
ShadTableCell.header(child: Text('비고')),
],
children: historyList.map((history) {
return [
// 장비명
ShadTableCell(
child: Tooltip(
message: history.equipmentName,
child: Text(history.equipmentName, overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500)),
),
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
topRight: Radius.circular(8),
// 시리얼번호
ShadTableCell(
child: Tooltip(
message: history.serialNumber,
child: Text(history.serialNumber, overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodySmall),
),
),
),
child: Row(children: _buildHeaderCells()),
),
// 스크롤 바디
Expanded(
child: ListView.builder(
itemCount: historyList.length,
itemBuilder: (context, index) => _buildTableRow(historyList[index], index),
),
),
],
// 위치
ShadTableCell(
child: Row(
children: [
Icon(history.isCustomerLocation ? Icons.business : Icons.warehouse, size: 14, color: history.isCustomerLocation ? ShadcnTheme.companyCustomer : ShadcnTheme.equipmentIn),
const SizedBox(width: 6),
Expanded(child: Text(history.location, overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodySmall)),
],
),
),
// 변동일
ShadTableCell(child: Text(history.formattedDate, style: ShadcnTheme.bodySmall)),
// 작업
ShadTableCell(
child: ShadButton.outline(
size: ShadButtonSize.sm,
onPressed: () => _showEquipmentHistoryDetail(history),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [Icon(Icons.history, size: 14), SizedBox(width: 4), Text('상세보기', style: TextStyle(fontSize: 12))],
),
),
),
// 비고
ShadTableCell(
child: Tooltip(
message: history.remark ?? '비고 없음',
child: Text(history.remark ?? '-', overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodySmall.copyWith(color: ShadcnTheme.mutedForeground)),
),
),
];
}).toList(),
),
),
);
}
@@ -539,4 +505,4 @@ class _InventoryHistoryScreenState extends State<InventoryHistoryScreen> {
),
);
}
}
}

View File

@@ -192,25 +192,26 @@ class _MaintenanceAlertDashboardState extends State<MaintenanceAlertDashboard> {
Color color;
IconData icon;
// Phase 10: 색체심리학 기반 알림 색상 체계 적용
switch (type) {
case 'expiring_7':
color = Colors.red.shade600;
color = ShadcnTheme.alertCritical7; // 7일 이내 - 위험 (레드)
icon = Icons.priority_high_outlined;
break;
case 'expiring_30':
color = Colors.orange.shade600;
color = ShadcnTheme.alertWarning30; // 30일 이내 - 경고 (오렌지)
icon = Icons.warning_amber_outlined;
break;
case 'expiring_60':
color = Colors.amber.shade600;
color = ShadcnTheme.alertWarning60; // 60일 이내 - 주의 (앰버)
icon = Icons.schedule_outlined;
break;
case 'expired':
color = Colors.red.shade800;
color = ShadcnTheme.alertExpired; // 만료됨 - 심각 (진한 레드)
icon = Icons.error_outline;
break;
default:
color = Colors.grey.shade600;
color = ShadcnTheme.alertNormal; // 정상 - 안전 (그린)
icon = Icons.info_outline;
}
@@ -449,13 +450,14 @@ class _MaintenanceAlertDashboardState extends State<MaintenanceAlertDashboard> {
),
),
// 고객사
// 고객사 - Phase 10: 회사 타입별 색상 적용
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
child: Text(
controller.getCompanyName(maintenance),
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foreground,
color: ShadcnTheme.companyCustomer, // 고객사 - 진한 그린
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -468,12 +470,13 @@ class _MaintenanceAlertDashboardState extends State<MaintenanceAlertDashboard> {
child: Text(
'${maintenance.endedAt.year}-${maintenance.endedAt.month.toString().padLeft(2, '0')}-${maintenance.endedAt.day.toString().padLeft(2, '0')}',
style: ShadcnTheme.bodySmall.copyWith(
// Phase 10: 만료 상태별 색상 체계 적용
color: isExpired
? Colors.red.shade600
? ShadcnTheme.alertExpired // 만료됨 - 심각 (진한 레드)
: isExpiringSoon
? Colors.orange.shade600
: ShadcnTheme.foreground,
fontWeight: isExpired || isExpiringSoon ? FontWeight.w600 : FontWeight.normal,
? ShadcnTheme.alertWarning30 // 만료 임박 - 경고 (오렌지)
: ShadcnTheme.alertNormal, // 정상 - 안전 (그린)
fontWeight: isExpired || isExpiringSoon ? FontWeight.w600 : FontWeight.w500,
),
),
),
@@ -506,11 +509,12 @@ class _MaintenanceAlertDashboardState extends State<MaintenanceAlertDashboard> {
? '${daysRemaining.abs()}일 지연'
: '$daysRemaining일 남음',
style: ShadcnTheme.bodySmall.copyWith(
// Phase 10: 남은 일수 상태별 색상 체계 적용
color: isExpired
? Colors.red.shade600
? ShadcnTheme.alertExpired // 지연 - 심각 (진한 레드)
: isExpiringSoon
? Colors.orange.shade600
: Colors.green.shade600,
? ShadcnTheme.alertWarning30 // 임박 - 경고 (오렌지)
: ShadcnTheme.alertNormal, // 충분 - 안전 (그린)
fontWeight: FontWeight.w600,
),
),
@@ -543,14 +547,15 @@ class _MaintenanceAlertDashboardState extends State<MaintenanceAlertDashboard> {
}
/// 유지보수 타입별 색상
// Phase 10: 유지보수 타입별 색상 체계
Color _getMaintenanceTypeColor(String maintenanceType) {
switch (maintenanceType) {
case 'V': // 방문
return Colors.blue.shade600;
case 'R': // 원격
return Colors.green.shade600;
case 'V': // 방문 - 본사/지점 계열 (블루)
return ShadcnTheme.companyHeadquarters;
case 'R': // 원격 - 협력/성장 계열 (그린)
return ShadcnTheme.companyPartner;
default:
return Colors.grey.shade600;
return ShadcnTheme.muted;
}
}

View File

@@ -70,13 +70,26 @@ class _MaintenanceListState extends State<MaintenanceList> {
value: _controller,
child: Scaffold(
backgroundColor: ShadcnTheme.background,
body: Column(
children: [
_buildActionBar(),
_buildFilterBar(),
Expanded(child: _buildMainContent()),
_buildBottomBar(),
],
body: Consumer<MaintenanceController>(
builder: (context, controller, child) {
return Column(
children: [
_buildActionBar(),
_buildFilterBar(),
Expanded(child: _buildMainContent()),
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
decoration: BoxDecoration(
color: ShadcnTheme.card,
border: Border(
top: BorderSide(color: ShadcnTheme.border),
),
),
child: _buildPagination(controller),
),
],
);
},
),
),
);
@@ -260,209 +273,299 @@ class _MaintenanceListState extends State<MaintenanceList> {
/// 데이터 테이블
Widget _buildDataTable(MaintenanceController controller) {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _horizontalScrollController,
child: DataTable(
columns: _buildHeaders(),
rows: _buildRows(controller.maintenances),
final maintenances = controller.maintenances;
if (maintenances.isEmpty) {
return const StandardEmptyState(
icon: Icons.build_circle_outlined,
title: '유지보수가 없습니다',
message: '새로운 유지보수를 등록해보세요.',
);
}
return Container(
decoration: BoxDecoration(
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Column(
children: [
// 고정 헤더
Container(
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing4, vertical: ShadcnTheme.spacing3),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(bottom: BorderSide(color: ShadcnTheme.border)),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(ShadcnTheme.radiusMd),
topRight: Radius.circular(ShadcnTheme.radiusMd),
),
),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _horizontalScrollController,
child: _buildFixedHeader(),
),
),
// 스크롤 가능한 바디
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _horizontalScrollController,
child: SizedBox(
width: _calculateTableWidth(),
child: ListView.builder(
itemCount: maintenances.length,
itemBuilder: (context, index) => _buildTableRow(maintenances[index], index),
),
),
),
),
],
),
);
}
/// 테이블 헤더
List<DataColumn> _buildHeaders() {
return [
const DataColumn(label: Text('선택')),
const DataColumn(label: Text('ID')),
const DataColumn(label: Text('장비 정보')),
const DataColumn(label: Text('유지보수 타입')),
const DataColumn(label: Text('시작일')),
const DataColumn(label: Text('종료일')),
if (_showDetailedColumns) ...[
const DataColumn(label: Text('주기')),
const DataColumn(label: Text('상태')),
const DataColumn(label: Text('남은 일수')),
],
const DataColumn(label: Text('작업')),
];
}
/// 테이블 로우
List<DataRow> _buildRows(List<MaintenanceDto> maintenances) {
return maintenances.map((maintenance) {
final isSelected = _selectedItems.contains(maintenance.id);
return DataRow(
selected: isSelected,
onSelectChanged: (_) => _showMaintenanceDetail(maintenance),
cells: [
// 선택 체크박스
DataCell(
Checkbox(
value: isSelected,
onChanged: (value) {
setState(() {
if (value == true) {
_selectedItems.add(maintenance.id!);
} else {
_selectedItems.remove(maintenance.id!);
}
});
},
),
),
// ID
DataCell(Text(maintenance.id?.toString() ?? '-')),
// 장비 정보
DataCell(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
maintenance.equipmentSerial ?? '시리얼 번호 없음',
style: const TextStyle(fontWeight: FontWeight.w500),
),
if (maintenance.equipmentModel != null)
Text(
maintenance.equipmentModel!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
),
// 유지보수 타입
DataCell(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getMaintenanceTypeColor(maintenance.maintenanceType),
borderRadius: BorderRadius.circular(4),
),
child: Text(
MaintenanceType.getDisplayName(maintenance.maintenanceType),
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
// 시작일
DataCell(Text(DateFormat('yyyy-MM-dd').format(maintenance.startedAt))),
// 종료일
DataCell(Text(DateFormat('yyyy-MM-dd').format(maintenance.endedAt))),
// 상세 컬럼들
/// 고정 헤더 빌드
Widget _buildFixedHeader() {
return SizedBox(
width: _calculateTableWidth(),
child: Row(
children: [
_buildHeaderCell('선택', 60),
_buildHeaderCell('ID', 80),
_buildHeaderCell('장비 정보', 200),
_buildHeaderCell('유지보수 타입', 120),
_buildHeaderCell('시작일', 100),
_buildHeaderCell('종료일', 100),
if (_showDetailedColumns) ...[
// 주기
DataCell(Text('${maintenance.periodMonth}개월')),
// 상태
DataCell(
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _controller.getMaintenanceStatusColor(maintenance),
borderRadius: BorderRadius.circular(4),
),
child: Text(
_controller.getMaintenanceStatusText(maintenance),
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
),
),
// 남은 일수
DataCell(
Text(
maintenance.daysRemaining != null
? '${maintenance.daysRemaining}'
: '-',
style: TextStyle(
color: maintenance.daysRemaining != null &&
maintenance.daysRemaining! <= 30
? Colors.red
: null,
),
),
),
_buildHeaderCell('주기', 80),
_buildHeaderCell('상태', 100),
_buildHeaderCell('남은 일수', 100),
],
// 작업 버튼들
DataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
child: const Icon(Icons.edit, size: 16),
onPressed: () => _showMaintenanceForm(maintenance: maintenance),
),
const SizedBox(width: 4),
ShadButton.ghost(
child: Icon(
Icons.delete,
size: 16,
color: Colors.red[400],
),
onPressed: () => _deleteMaintenance(maintenance),
),
],
),
),
_buildHeaderCell('작업', 120),
],
);
}).toList();
}
/// 하단바 (페이지네이션)
Widget _buildBottomBar() {
return Consumer<MaintenanceController>(
builder: (context, controller, child) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: ShadcnTheme.card,
border: Border(
top: BorderSide(color: ShadcnTheme.border),
),
),
child: Row(
children: [
// 선택된 항목 정보
if (_selectedItems.isNotEmpty)
Text('${_selectedItems.length}개 선택됨'),
const Spacer(),
// 페이지네이션
Pagination(
totalCount: controller.totalCount,
currentPage: controller.currentPage,
pageSize: 20, // MaintenanceController._perPage 상수값
onPageChanged: (page) => controller.goToPage(page),
),
],
),
);
},
),
);
}
/// 테이블 총 너비 계산
double _calculateTableWidth() {
double width = 60 + 80 + 200 + 120 + 100 + 100 + 120; // 기본 컬럼들
if (_showDetailedColumns) {
width += 80 + 100 + 100; // 상세 컬럼들
}
return width;
}
/// 헤더 셀 빌드
Widget _buildHeaderCell(String text, double width) {
return Container(
width: width,
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing2),
child: Text(
text,
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
);
}
/// 테이블 행 빌드
Widget _buildTableRow(MaintenanceDto maintenance, int index) {
return Container(
decoration: BoxDecoration(
color: index.isEven ? ShadcnTheme.muted.withValues(alpha: 0.1) : null,
border: Border(bottom: BorderSide(color: ShadcnTheme.border.withValues(alpha: 0.3))),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showMaintenanceDetail(maintenance),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing4, vertical: ShadcnTheme.spacing3),
child: Row(
children: [
// 선택 체크박스
SizedBox(
width: 60,
child: ShadCheckbox(
value: _selectedItems.contains(maintenance.id),
onChanged: (value) {
setState(() {
if (value == true) {
_selectedItems.add(maintenance.id!);
} else {
_selectedItems.remove(maintenance.id!);
}
});
},
),
),
// ID
SizedBox(
width: 80,
child: Text(
maintenance.id?.toString() ?? '-',
style: ShadcnTheme.bodySmall,
),
),
// 장비 정보
SizedBox(
width: 200,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
maintenance.equipmentSerial ?? '시리얼 번호 없음',
style: const TextStyle(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis,
),
if (maintenance.equipmentModel != null)
Text(
maintenance.equipmentModel!,
style: TextStyle(
fontSize: 12,
color: ShadcnTheme.mutedForeground,
),
overflow: TextOverflow.ellipsis,
),
],
),
),
// 유지보수 타입
SizedBox(
width: 120,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing2, vertical: ShadcnTheme.spacing1),
decoration: BoxDecoration(
color: _getMaintenanceTypeColor(maintenance.maintenanceType),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm),
),
child: Text(
MaintenanceType.getDisplayName(maintenance.maintenanceType),
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
),
// 시작일
SizedBox(
width: 100,
child: Text(
DateFormat('yyyy-MM-dd').format(maintenance.startedAt),
style: ShadcnTheme.bodySmall,
),
),
// 종료일
SizedBox(
width: 100,
child: Text(
DateFormat('yyyy-MM-dd').format(maintenance.endedAt),
style: ShadcnTheme.bodySmall,
),
),
// 상세 컬럼들
if (_showDetailedColumns) ...[
// 주기
SizedBox(
width: 80,
child: Text(
'${maintenance.periodMonth}개월',
style: ShadcnTheme.bodySmall,
),
),
// 상태
SizedBox(
width: 100,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing2, vertical: ShadcnTheme.spacing1),
decoration: BoxDecoration(
color: _controller.getMaintenanceStatusColor(maintenance),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm),
),
child: Text(
_controller.getMaintenanceStatusText(maintenance),
style: const TextStyle(
fontSize: 12,
color: Colors.white,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
),
// 남은 일수
SizedBox(
width: 100,
child: Text(
maintenance.daysRemaining != null
? '${maintenance.daysRemaining}'
: '-',
style: TextStyle(
color: maintenance.daysRemaining != null &&
maintenance.daysRemaining! <= 30
? ShadcnTheme.destructive
: ShadcnTheme.foreground,
),
),
),
],
// 작업 버튼들
SizedBox(
width: 120,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
child: const Icon(Icons.edit, size: 16),
onPressed: () => _showMaintenanceForm(maintenance: maintenance),
),
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
child: Icon(
Icons.delete,
size: 16,
color: ShadcnTheme.destructive,
),
onPressed: () => _deleteMaintenance(maintenance),
),
],
),
),
],
),
),
),
),
);
}
/// 하단 페이지네이션
Widget _buildPagination(MaintenanceController controller) {
return Pagination(
totalCount: controller.totalCount,
currentPage: controller.currentPage,
pageSize: 20, // MaintenanceController._perPage 상수값
onPageChanged: (page) => controller.goToPage(page),
);
}
// 유틸리티 메서드들
Color _getMaintenanceTypeColor(String type) {
switch (type) {

View File

@@ -6,6 +6,7 @@ import 'package:superport/screens/model/controllers/model_controller.dart';
import 'package:superport/screens/model/model_form_dialog.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/injection_container.dart' as di;
@@ -18,6 +19,10 @@ class ModelListScreen extends StatefulWidget {
class _ModelListScreenState extends State<ModelListScreen> {
late final ModelController _controller;
// 클라이언트 사이드 페이지네이션
int _currentPage = 1;
static const int _pageSize = 10;
@override
void initState() {
@@ -28,6 +33,33 @@ class _ModelListScreenState extends State<ModelListScreen> {
});
}
// 현재 페이지의 모델 목록 반환
List<ModelDto> _getCurrentPageModels() {
final allModels = _controller.models;
final startIndex = (_currentPage - 1) * _pageSize;
final endIndex = startIndex + _pageSize;
if (startIndex >= allModels.length) return [];
if (endIndex >= allModels.length) return allModels.sublist(startIndex);
return allModels.sublist(startIndex, endIndex);
}
// 총 페이지 수 계산
int _getTotalPages() {
return (_controller.models.length / _pageSize).ceil();
}
// 페이지 이동
void _goToPage(int page) {
final totalPages = _getTotalPages();
if (page < 1 || page > totalPages) return;
setState(() {
_currentPage = page;
});
}
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
@@ -78,21 +110,21 @@ class _ModelListScreenState extends State<ModelListScreen> {
'전체 모델',
controller.models.length.toString(),
Icons.category,
ShadcnTheme.primary,
ShadcnTheme.companyCustomer,
),
const SizedBox(width: ShadcnTheme.spacing4),
_buildStatCard(
'제조사',
controller.vendors.length.toString(),
Icons.business,
ShadcnTheme.success,
ShadcnTheme.companyPartner,
),
const SizedBox(width: ShadcnTheme.spacing4),
_buildStatCard(
'활성 모델',
controller.models.where((m) => !m.isDeleted).length.toString(),
Icons.check_circle,
ShadcnTheme.info,
ShadcnTheme.equipmentIn,
),
],
);
@@ -105,7 +137,12 @@ class _ModelListScreenState extends State<ModelListScreen> {
flex: 2,
child: ShadInput(
placeholder: const Text('모델명 검색...'),
onChanged: controller.setSearchQuery,
onChanged: (value) {
setState(() {
_currentPage = 1; // 검색 시 첫 페이지로 리셋
});
controller.setSearchQuery(value);
},
),
),
const SizedBox(width: ShadcnTheme.spacing4),
@@ -135,7 +172,12 @@ class _ModelListScreenState extends State<ModelListScreen> {
return const Text('전체');
}
},
onChanged: controller.setVendorFilter,
onChanged: (value) {
setState(() {
_currentPage = 1; // 필터 변경 시 첫 페이지로 리셋
});
controller.setVendorFilter(value);
},
),
),
],
@@ -169,200 +211,121 @@ class _ModelListScreenState extends State<ModelListScreen> {
);
}
/// 헤더 셀 빌더
Widget _buildHeaderCell(
String text, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final child = Container(
alignment: Alignment.centerLeft,
child: Text(
text,
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
);
if (useExpanded) {
return Expanded(flex: flex, child: child);
} else {
return SizedBox(width: minWidth, child: child);
}
}
/// 데이터 셀 빌더
Widget _buildDataCell(
Widget child, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final container = Container(
alignment: Alignment.centerLeft,
child: child,
);
if (useExpanded) {
return Expanded(flex: flex, child: container);
} else {
return SizedBox(width: minWidth, child: container);
}
}
/// 헤더 셀 리스트
List<Widget> _buildHeaderCells() {
return [
_buildHeaderCell('ID', flex: 0, useExpanded: false, minWidth: 60),
_buildHeaderCell('제조사', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('모델명', flex: 3, useExpanded: true, minWidth: 120),
_buildHeaderCell('등록일', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('상태', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('작업', flex: 0, useExpanded: false, minWidth: 100),
];
}
/// 테이블 행 빌더
Widget _buildTableRow(ModelDto model, int index) {
final vendor = _controller.getVendorById(model.vendorsId);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: index.isEven
? ShadcnTheme.muted.withValues(alpha: 0.1)
: null,
border: const Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
_buildDataCell(
Text(
model.id.toString(),
style: ShadcnTheme.bodySmall,
),
flex: 0,
useExpanded: false,
minWidth: 60,
),
_buildDataCell(
Text(
vendor?.name ?? '알 수 없음',
style: ShadcnTheme.bodyMedium,
),
flex: 2,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
Text(
model.name,
style: ShadcnTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w500,
),
),
flex: 3,
useExpanded: true,
minWidth: 120,
),
_buildDataCell(
Text(
model.registeredAt != null
? DateFormat('yyyy-MM-dd').format(model.registeredAt)
: '-',
style: ShadcnTheme.bodySmall,
),
flex: 2,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
_buildStatusChip(model.isDeleted),
flex: 0,
useExpanded: false,
minWidth: 80,
),
_buildDataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
onPressed: () => _showEditDialog(model),
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
onPressed: () => _showDeleteConfirmDialog(model),
child: const Icon(Icons.delete, size: 16),
),
],
),
flex: 0,
useExpanded: false,
minWidth: 100,
),
],
),
);
}
Widget _buildDataTable(ModelController controller) {
final models = controller.models;
final allModels = controller.models;
final currentPageModels = _getCurrentPageModels();
if (allModels.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.category_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: ShadcnTheme.spacing4),
Text(
'등록된 모델이 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
);
}
return Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Column(
children: [
// 고정 헤더
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(bottom: BorderSide(color: Colors.black)),
),
child: Row(children: _buildHeaderCells()),
),
// 스크롤 바디
Expanded(
child: models.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.category_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: 16),
Text(
'등록된 모델이 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
child: Padding(
padding: const EdgeInsets.all(12.0),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('ID')),
ShadTableCell.header(child: Text('제조사')),
ShadTableCell.header(child: Text('모델명')),
ShadTableCell.header(child: Text('등록일')),
ShadTableCell.header(child: Text('상태')),
ShadTableCell.header(child: Text('작업')),
],
children: currentPageModels.map((model) {
final vendor = _controller.getVendorById(model.vendorsId);
return [
ShadTableCell(child: Text(model.id.toString(), style: ShadcnTheme.bodySmall)),
ShadTableCell(child: Text(vendor?.name ?? '알 수 없음', overflow: TextOverflow.ellipsis)),
ShadTableCell(child: Text(model.name, overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500))),
ShadTableCell(child: Text(model.registeredAt != null ? DateFormat('yyyy-MM-dd').format(model.registeredAt) : '-', style: ShadcnTheme.bodySmall)),
ShadTableCell(child: _buildStatusChip(model.isDeleted)),
ShadTableCell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
onPressed: () => _showEditDialog(model),
child: const Icon(Icons.edit, size: 16),
),
)
: ListView.builder(
itemCount: models.length,
itemBuilder: (context, index) => _buildTableRow(models[index], index),
),
),
],
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
onPressed: () => _showDeleteConfirmDialog(model),
child: Icon(Icons.delete, size: 16, color: ShadcnTheme.destructive),
),
],
),
),
];
}).toList(),
),
),
);
}
Widget _buildPagination(ModelController controller) {
// 모델 목록은 현재 페이지네이션이 없는 것 같으니 빈 위젯 반환
return const SizedBox();
final totalCount = controller.models.length;
final totalPages = _getTotalPages();
if (totalCount <= _pageSize) {
return Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing3),
decoration: BoxDecoration(
color: ShadcnTheme.card,
border: Border(
top: BorderSide(color: ShadcnTheme.border),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$totalCount개 모델',
style: ShadcnTheme.bodySmall,
),
],
),
);
}
return Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing3),
decoration: BoxDecoration(
color: ShadcnTheme.card,
border: Border(
top: BorderSide(color: ShadcnTheme.border),
),
),
child: Pagination(
totalCount: totalCount,
currentPage: _currentPage,
pageSize: _pageSize,
onPageChanged: _goToPage,
),
);
}
Widget _buildStatCard(
@@ -416,11 +379,15 @@ class _ModelListScreenState extends State<ModelListScreen> {
Widget _buildStatusChip(bool isDeleted) {
if (isDeleted) {
return ShadBadge.destructive(
return ShadBadge(
backgroundColor: ShadcnTheme.equipmentDisposal.withValues(alpha: 0.1),
foregroundColor: ShadcnTheme.equipmentDisposal,
child: const Text('비활성'),
);
} else {
return ShadBadge.secondary(
return ShadBadge(
backgroundColor: ShadcnTheme.equipmentIn.withValues(alpha: 0.1),
foregroundColor: ShadcnTheme.equipmentIn,
child: const Text('활성'),
);
}
@@ -467,4 +434,4 @@ class _ModelListScreenState extends State<ModelListScreen> {
),
);
}
}
}

View File

@@ -153,163 +153,6 @@ class _RentListScreenState extends State<RentListScreen> {
_controller.loadRents();
}
/// 헤더 셀 빌더
Widget _buildHeaderCell(
String text, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final child = Container(
alignment: Alignment.centerLeft,
child: Text(
text,
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
);
if (useExpanded) {
return Expanded(flex: flex, child: child);
} else {
return SizedBox(width: minWidth, child: child);
}
}
/// 데이터 셀 빌더
Widget _buildDataCell(
Widget child, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final container = Container(
alignment: Alignment.centerLeft,
child: child,
);
if (useExpanded) {
return Expanded(flex: flex, child: container);
} else {
return SizedBox(width: minWidth, child: container);
}
}
/// 헤더 셀 리스트
List<Widget> _buildHeaderCells() {
return [
_buildHeaderCell('ID', flex: 0, useExpanded: false, minWidth: 60),
_buildHeaderCell('장비 이력 ID', flex: 1, useExpanded: true, minWidth: 100),
_buildHeaderCell('시작일', flex: 1, useExpanded: true, minWidth: 100),
_buildHeaderCell('종료일', flex: 1, useExpanded: true, minWidth: 100),
_buildHeaderCell('기간 (일)', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('상태', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('작업', flex: 0, useExpanded: false, minWidth: 120),
];
}
/// 테이블 행 빌더
Widget _buildTableRow(RentDto rent, int index) {
final days = _controller.calculateRentDays(rent.startedAt, rent.endedAt);
final status = _controller.getRentStatus(rent);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: index.isEven
? ShadcnTheme.muted.withValues(alpha: 0.1)
: null,
border: const Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
_buildDataCell(
Text(
rent.id?.toString() ?? '-',
style: ShadcnTheme.bodySmall,
),
flex: 0,
useExpanded: false,
minWidth: 60,
),
_buildDataCell(
Text(
rent.equipmentHistoryId.toString(),
style: ShadcnTheme.bodyMedium,
),
flex: 1,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
Text(
DateFormat('yyyy-MM-dd').format(rent.startedAt),
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
Text(
DateFormat('yyyy-MM-dd').format(rent.endedAt),
style: ShadcnTheme.bodySmall,
),
flex: 1,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
Text(
'$days일',
style: ShadcnTheme.bodySmall,
textAlign: TextAlign.center,
),
flex: 0,
useExpanded: false,
minWidth: 80,
),
_buildDataCell(
_buildStatusChip(status),
flex: 0,
useExpanded: false,
minWidth: 80,
),
_buildDataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _showEditDialog(rent),
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: 4),
if (status == '진행중')
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _returnRent(rent),
child: const Icon(Icons.assignment_return, size: 16),
),
if (status == '진행중')
const SizedBox(width: 4),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _deleteRent(rent),
child: const Icon(Icons.delete, size: 16),
),
],
),
flex: 0,
useExpanded: false,
minWidth: 120,
),
],
),
);
}
/// 상태 배지 빌더
Widget _buildStatusChip(String? status) {
switch (status) {
@@ -332,59 +175,196 @@ class _RentListScreenState extends State<RentListScreen> {
}
}
/// 데이터 테이블 빌더
Widget _buildDataTable(RentController controller) {
final rents = controller.rents;
if (rents.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_center_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: ShadcnTheme.spacing4),
Text(
'등록된 임대 계약이 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
);
}
return Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Column(
children: [
// 고정 헤더
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing4, vertical: ShadcnTheme.spacing3),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(bottom: BorderSide(color: Colors.black)),
border: Border(bottom: BorderSide(color: ShadcnTheme.border)),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(ShadcnTheme.radiusMd),
topRight: Radius.circular(ShadcnTheme.radiusMd),
),
),
child: Row(
children: [
_buildHeaderCell('ID', 60),
_buildHeaderCell('장비 이력 ID', 120),
_buildHeaderCell('시작일', 100),
_buildHeaderCell('종료일', 100),
_buildHeaderCell('기간 (일)', 80),
_buildHeaderCell('상태', 80),
_buildHeaderCell('작업', 140),
],
),
child: Row(children: _buildHeaderCells()),
),
// 스크롤 바디
// 스크롤 가능한 바디
Expanded(
child: rents.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_center_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: 16),
Text(
'등록된 임대 계약이 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
)
: ListView.builder(
itemCount: rents.length,
itemBuilder: (context, index) => _buildTableRow(rents[index], index),
),
child: ListView.builder(
itemCount: rents.length,
itemBuilder: (context, index) => _buildTableRow(rents[index], index),
),
),
],
),
);
}
/// 헤더 셀 빌드
Widget _buildHeaderCell(String text, double width) {
return SizedBox(
width: width,
child: Text(
text,
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
);
}
/// 테이블 행 빌드
Widget _buildTableRow(RentDto rent, int index) {
final days = _controller.calculateRentDays(rent.startedAt, rent.endedAt);
final status = _controller.getRentStatus(rent);
return Container(
decoration: BoxDecoration(
color: index.isEven ? ShadcnTheme.muted.withValues(alpha: 0.1) : null,
border: Border(bottom: BorderSide(color: ShadcnTheme.border.withValues(alpha: 0.3))),
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showEditDialog(rent),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing4, vertical: ShadcnTheme.spacing3),
child: Row(
children: [
// ID
SizedBox(
width: 60,
child: Text(
rent.id?.toString() ?? '-',
style: ShadcnTheme.bodySmall,
),
),
// 장비 이력 ID
SizedBox(
width: 120,
child: Text(
rent.equipmentHistoryId.toString(),
style: ShadcnTheme.bodyMedium,
),
),
// 시작일
SizedBox(
width: 100,
child: Text(
DateFormat('yyyy-MM-dd').format(rent.startedAt),
style: ShadcnTheme.bodySmall,
),
),
// 종료일
SizedBox(
width: 100,
child: Text(
DateFormat('yyyy-MM-dd').format(rent.endedAt),
style: ShadcnTheme.bodySmall,
),
),
// 기간 (일)
SizedBox(
width: 80,
child: Text(
'$days일',
style: ShadcnTheme.bodySmall,
),
),
// 상태
SizedBox(
width: 80,
child: _buildStatusChip(status),
),
// 작업 버튼들
SizedBox(
width: 140,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _showEditDialog(rent),
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: ShadcnTheme.spacing1),
if (status == '진행중')
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _returnRent(rent),
child: const Icon(Icons.assignment_return, size: 16),
),
if (status == '진행중')
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: () => _deleteRent(rent),
child: Icon(
Icons.delete,
size: 16,
color: ShadcnTheme.destructive,
),
),
],
),
),
],
),
),
),
),
);
}
/// 검색바 빌더
Widget _buildSearchBar() {
return Row(
@@ -465,13 +445,21 @@ class _RentListScreenState extends State<RentListScreen> {
Widget? _buildPagination() {
return Consumer<RentController>(
builder: (context, controller, child) {
if (controller.totalPages <= 1) return const SizedBox.shrink();
return Pagination(
totalCount: controller.totalRents,
currentPage: controller.currentPage,
pageSize: AppConstants.rentPageSize,
onPageChanged: (page) => controller.loadRents(page: page),
// 항상 페이지네이션 정보 표시 (총 개수라도)
return Container(
padding: const EdgeInsets.symmetric(vertical: ShadcnTheme.spacing2),
child: controller.totalPages > 1
? Pagination(
totalCount: controller.totalRents,
currentPage: controller.currentPage,
pageSize: AppConstants.rentPageSize,
onPageChanged: (page) => controller.loadRents(page: page),
)
: Text(
'${controller.totalRents}개 임대 계약',
style: ShadcnTheme.bodySmall,
textAlign: TextAlign.center,
),
);
},
);

View File

@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import 'package:superport/screens/user/controllers/user_form_controller.dart';
import 'package:superport/utils/formatters/korean_phone_formatter.dart';
import 'package:superport/screens/common/widgets/standard_dropdown.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
// 사용자 등록/수정 화면 (UI만 담당, 상태/로직 분리)
class UserFormScreen extends StatefulWidget {
@@ -39,17 +40,37 @@ class _UserFormScreenState extends State<UserFormScreen> {
},
child: Consumer<UserFormController>(
builder: (context, controller, child) {
return Scaffold(
appBar: AppBar(
title: Text(controller.isEditMode ? '사용자 수정' : '사용자 등록'),
),
body: controller.isLoading
// Phase 10: FormLayoutTemplate 적용
return FormLayoutTemplate(
title: controller.isEditMode ? '사용자 수정' : '사용자 등록',
isLoading: controller.isLoading,
onSave: () async {
// 폼 검증
if (!controller.formKey.currentState!.validate()) {
return;
}
controller.formKey.currentState!.save();
// 사용자 저장
bool success = false;
await controller.saveUser((error) {
if (error == null) {
success = true;
} else {
// 에러 처리는 controller에서 관리
}
});
if (success && mounted) {
Navigator.of(context).pop(true);
}
},
onCancel: () => Navigator.of(context).pop(),
child: controller.isLoading
? const Center(child: ShadProgress())
: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: controller.formKey,
child: SingleChildScrollView(
: Form(
key: controller.formKey,
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -85,32 +106,35 @@ class _UserFormScreenState extends State<UserFormScreen> {
// 회사 선택 (*필수)
_buildCompanyDropdown(controller),
const SizedBox(height: 24),
// 중복 검사 상태 메시지 영역 (고정 높이)
SizedBox(
height: 40,
child: Center(
child: controller.isCheckingEmailDuplicate
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ShadProgress(),
SizedBox(width: 8),
Text('중복 검사 중...'),
],
)
: controller.emailDuplicateMessage != null
? Text(
controller.emailDuplicateMessage!,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
)
: Container(),
// 중복 검사 상태 메시지 영역
if (controller.isCheckingEmailDuplicate)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ShadProgress(),
SizedBox(width: 8),
Text('중복 검사 중...'),
],
),
),
),
if (controller.emailDuplicateMessage != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
controller.emailDuplicateMessage!,
style: const TextStyle(
color: Colors.red,
fontWeight: FontWeight.bold,
),
),
),
),
),
// 오류 메시지 표시
if (controller.error != null)
@@ -121,22 +145,10 @@ class _UserFormScreenState extends State<UserFormScreen> {
description: Text(controller.error!),
),
),
// 저장 버튼
SizedBox(
width: double.infinity,
child: ShadButton(
onPressed: controller.isLoading || controller.isCheckingEmailDuplicate
? null
: () => _onSaveUser(controller),
size: ShadButtonSize.lg,
child: Text(controller.isEditMode ? '수정하기' : '등록하기'),
),
),
],
),
),
),
),
);
},
),
@@ -153,7 +165,6 @@ class _UserFormScreenState extends State<UserFormScreen> {
String? Function(String?)? validator,
void Function(String?)? onSaved,
void Function(String)? onChanged,
Widget? suffixIcon,
}) {
final controller = TextEditingController(text: initialValue.isNotEmpty ? initialValue : '');
return Padding(
@@ -247,45 +258,4 @@ class _UserFormScreenState extends State<UserFormScreen> {
);
}
// 저장 버튼 클릭 시 사용자 저장
void _onSaveUser(UserFormController controller) async {
// 먼저 폼 유효성 검사
if (controller.formKey.currentState?.validate() != true) {
return;
}
// 폼 데이터 저장
controller.formKey.currentState?.save();
// 이메일 중복 검사 (저장 시점)
final emailIsUnique = await controller.checkDuplicateEmail(controller.email);
if (!emailIsUnique) {
// 중복이 발견되면 저장하지 않음
return;
}
// 이메일 중복이 없으면 저장 진행
await controller.saveUser((error) {
if (error != null) {
ShadToaster.of(context).show(
ShadToast.destructive(
title: const Text('오류'),
description: Text(error),
),
);
} else {
ShadToaster.of(context).show(
ShadToast(
title: const Text('성공'),
description: Text(
controller.isEditMode ? '사용자 정보가 수정되었습니다' : '사용자가 등록되었습니다',
),
),
);
Navigator.pop(context, true);
}
});
}
}

View File

@@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'package:superport/models/user_model.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.dart';
import 'package:superport/screens/common/layouts/base_list_screen.dart';
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
import 'package:superport/screens/common/widgets/pagination.dart';
@@ -55,24 +54,28 @@ class _UserListState extends State<UserList> {
/// 사용자 권한 표시 배지
Widget _buildUserRoleBadge(UserRole role) {
final roleName = role.displayName;
ShadcnBadgeVariant variant;
Color backgroundColor;
Color foregroundColor;
switch (role) {
case UserRole.admin:
variant = ShadcnBadgeVariant.destructive;
backgroundColor = ShadcnTheme.alertCritical7.withValues(alpha: 0.1);
foregroundColor = ShadcnTheme.alertCritical7;
break;
case UserRole.manager:
variant = ShadcnBadgeVariant.primary;
backgroundColor = ShadcnTheme.companyHeadquarters.withValues(alpha: 0.1);
foregroundColor = ShadcnTheme.companyHeadquarters;
break;
case UserRole.staff:
variant = ShadcnBadgeVariant.secondary;
backgroundColor = ShadcnTheme.equipmentDisposal.withValues(alpha: 0.1);
foregroundColor = ShadcnTheme.equipmentDisposal;
break;
}
return ShadcnBadge(
text: roleName,
variant: variant,
size: ShadcnBadgeSize.small,
return ShadBadge(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
child: Text(roleName),
);
}
@@ -202,21 +205,21 @@ class _UserListState extends State<UserList> {
'전체 사용자',
_controller.total.toString(),
Icons.people,
ShadcnTheme.primary,
ShadcnTheme.companyHeadquarters,
),
const SizedBox(width: ShadcnTheme.spacing4),
_buildStatCard(
'활성 사용자',
_controller.users.where((u) => u.isActive).length.toString(),
Icons.check_circle,
ShadcnTheme.success,
ShadcnTheme.equipmentIn,
),
const SizedBox(width: ShadcnTheme.spacing4),
_buildStatCard(
'비활성 사용자',
_controller.users.where((u) => !u.isActive).length.toString(),
Icons.person_off,
ShadcnTheme.mutedForeground,
ShadcnTheme.equipmentDisposal,
),
],
);
@@ -265,203 +268,92 @@ class _UserListState extends State<UserList> {
Widget _buildDataTable() {
final users = _controller.users;
if (users.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: 16),
Text(
'등록된 사용자가 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
);
}
return Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Column(
children: [
// 고정 헤더
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(bottom: BorderSide(color: Colors.black)),
),
child: Row(children: _buildHeaderCells()),
),
// 스크롤 바디
Expanded(
child: users.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.people_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: 16),
Text(
'등록된 사용자가 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
)
: ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => _buildTableRow(users[index], index),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('번호')),
ShadTableCell.header(child: Text('이름')),
ShadTableCell.header(child: Text('이메일')),
ShadTableCell.header(child: Text('회사')),
ShadTableCell.header(child: Text('권한')),
ShadTableCell.header(child: Text('상태')),
ShadTableCell.header(child: Text('작업')),
],
children: [
for (int index = 0; index < users.length; index++)
[
// 번호
ShadTableCell(child: Text(((_controller.currentPage - 1) * _controller.pageSize + index + 1).toString(), style: ShadcnTheme.bodySmall)),
// 이름
ShadTableCell(child: Text(users[index].name, overflow: TextOverflow.ellipsis)),
// 이메일
ShadTableCell(child: Text(users[index].email ?? '-', overflow: TextOverflow.ellipsis, style: ShadcnTheme.bodySmall)),
// 회사
ShadTableCell(child: Text(users[index].companyName ?? '-', overflow: TextOverflow.ellipsis)),
// 권한
ShadTableCell(child: _buildUserRoleBadge(users[index].role)),
// 상태
ShadTableCell(child: _buildStatusChip(users[index].isActive)),
// 작업
ShadTableCell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
onPressed: users[index].id != null ? () => _navigateToEdit(users[index].id!) : null,
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
onPressed: () => _showStatusChangeDialog(users[index]),
child: Icon(users[index].isActive ? Icons.person_off : Icons.person, size: 16),
),
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
onPressed: users[index].id != null ? () => _showDeleteDialog(users[index].id!, users[index].name) : null,
child: const Icon(Icons.delete, size: 16),
),
],
),
),
],
),
);
}
List<Widget> _buildHeaderCells() {
return [
_buildHeaderCell('번호', flex: 0, useExpanded: false, minWidth: 50),
_buildHeaderCell('이름', flex: 2, useExpanded: true, minWidth: 80),
_buildHeaderCell('이메일', flex: 3, useExpanded: true, minWidth: 120),
_buildHeaderCell('회사', flex: 2, useExpanded: true, minWidth: 80),
_buildHeaderCell('권한', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('상태', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('작업', flex: 0, useExpanded: false, minWidth: 120),
];
}
Widget _buildHeaderCell(
String text, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final child = Container(
alignment: Alignment.centerLeft,
child: Text(
text,
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
);
if (useExpanded) {
return Expanded(flex: flex, child: child);
} else {
return SizedBox(width: minWidth, child: child);
}
}
Widget _buildDataCell(
Widget child, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final container = Container(
alignment: Alignment.centerLeft,
child: child,
);
if (useExpanded) {
return Expanded(flex: flex, child: container);
} else {
return SizedBox(width: minWidth, child: container);
}
}
Widget _buildTableRow(User user, int index) {
final rowNumber = (_controller.currentPage - 1) * _controller.pageSize + index + 1;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: index.isEven
? ShadcnTheme.muted.withValues(alpha: 0.1)
: null,
border: const Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
_buildDataCell(
Text(
rowNumber.toString(),
style: ShadcnTheme.bodySmall,
),
flex: 0,
useExpanded: false,
minWidth: 50,
),
_buildDataCell(
Text(
user.name,
style: ShadcnTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w500,
),
),
flex: 2,
useExpanded: true,
minWidth: 80,
),
_buildDataCell(
Text(
user.email ?? '',
style: ShadcnTheme.bodyMedium,
),
flex: 3,
useExpanded: true,
minWidth: 120,
),
_buildDataCell(
Text(
'-', // Company name not available in current model
style: ShadcnTheme.bodySmall,
),
flex: 2,
useExpanded: true,
minWidth: 80,
),
_buildDataCell(
_buildUserRoleBadge(user.role),
flex: 0,
useExpanded: false,
minWidth: 80,
),
_buildDataCell(
_buildStatusChip(user.isActive),
flex: 0,
useExpanded: false,
minWidth: 80,
),
_buildDataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
onPressed: user.id != null ? () => _navigateToEdit(user.id!) : null,
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
onPressed: () => _showStatusChangeDialog(user),
child: Icon(
user.isActive ? Icons.person_off : Icons.person,
size: 16,
),
),
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
onPressed: user.id != null ? () => _showDeleteDialog(user.id!, user.name) : null,
child: const Icon(Icons.delete, size: 16),
),
],
),
flex: 0,
useExpanded: false,
minWidth: 120,
),
],
],
),
),
);
}
// (Deprecated) 기존 커스텀 테이블 빌더 유틸들은 ShadTable 전환으로 제거되었습니다.
Widget _buildPagination() {
if (_controller.totalPages <= 1) return const SizedBox();
@@ -524,11 +416,15 @@ class _UserListState extends State<UserList> {
Widget _buildStatusChip(bool isActive) {
if (isActive) {
return ShadBadge.secondary(
return ShadBadge(
backgroundColor: ShadcnTheme.equipmentIn.withValues(alpha: 0.1),
foregroundColor: ShadcnTheme.equipmentIn,
child: const Text('활성'),
);
} else {
return ShadBadge.destructive(
return ShadBadge(
backgroundColor: ShadcnTheme.equipmentDisposal.withValues(alpha: 0.1),
foregroundColor: ShadcnTheme.equipmentDisposal,
child: const Text('비활성'),
);
}
@@ -536,4 +432,3 @@ class _UserListState extends State<UserList> {
// StandardDataRow 임시 정의
}

View File

@@ -164,21 +164,21 @@ class _VendorListScreenState extends State<VendorListScreen> {
'전체 벤더',
controller.totalCount.toString(),
Icons.business,
ShadcnTheme.primary,
ShadcnTheme.companyPartner,
),
const SizedBox(width: ShadcnTheme.spacing4),
_buildStatCard(
'활성 벤더',
controller.vendors.where((v) => !v.isDeleted).length.toString(),
Icons.check_circle,
ShadcnTheme.success,
ShadcnTheme.equipmentIn,
),
const SizedBox(width: ShadcnTheme.spacing4),
_buildStatCard(
'비활성 벤더',
controller.vendors.where((v) => v.isDeleted).length.toString(),
Icons.cancel,
ShadcnTheme.mutedForeground,
ShadcnTheme.equipmentDisposal,
),
],
);
@@ -222,187 +222,88 @@ class _VendorListScreenState extends State<VendorListScreen> {
);
}
/// 헤더 셀 빌더
Widget _buildHeaderCell(
String text, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final child = Container(
alignment: Alignment.centerLeft,
child: Text(
text,
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
),
);
if (useExpanded) {
return Expanded(flex: flex, child: child);
} else {
return SizedBox(width: minWidth, child: child);
}
}
/// 데이터 셀 빌더
Widget _buildDataCell(
Widget child, {
required int flex,
required bool useExpanded,
required double minWidth,
}) {
final container = Container(
alignment: Alignment.centerLeft,
child: child,
);
if (useExpanded) {
return Expanded(flex: flex, child: container);
} else {
return SizedBox(width: minWidth, child: container);
}
}
/// 헤더 셀 리스트
List<Widget> _buildHeaderCells() {
return [
_buildHeaderCell('번호', flex: 0, useExpanded: false, minWidth: 50),
_buildHeaderCell('벤더명', flex: 3, useExpanded: true, minWidth: 120),
_buildHeaderCell('등록일', flex: 2, useExpanded: true, minWidth: 100),
_buildHeaderCell('상태', flex: 0, useExpanded: false, minWidth: 80),
_buildHeaderCell('작업', flex: 0, useExpanded: false, minWidth: 100),
];
}
/// 테이블 행 빌더
Widget _buildTableRow(dynamic vendor, int index) {
final rowNumber = (_controller.currentPage - 1) * _controller.pageSize + index + 1;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: index.isEven
? ShadcnTheme.muted.withValues(alpha: 0.1)
: null,
border: const Border(
bottom: BorderSide(color: Colors.black),
),
),
child: Row(
children: [
_buildDataCell(
Text(
rowNumber.toString(),
style: ShadcnTheme.bodySmall,
),
flex: 0,
useExpanded: false,
minWidth: 50,
),
_buildDataCell(
Text(
vendor.name,
style: ShadcnTheme.bodyMedium.copyWith(
fontWeight: FontWeight.w500,
),
),
flex: 3,
useExpanded: true,
minWidth: 120,
),
_buildDataCell(
Text(
vendor.createdAt != null
? DateFormat('yyyy-MM-dd').format(vendor.createdAt!)
: '-',
style: ShadcnTheme.bodySmall,
),
flex: 2,
useExpanded: true,
minWidth: 100,
),
_buildDataCell(
_buildStatusChip(vendor.isDeleted),
flex: 0,
useExpanded: false,
minWidth: 80,
),
_buildDataCell(
Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
onPressed: () => _showEditDialog(vendor.id!),
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
onPressed: () => _showDeleteConfirmDialog(vendor.id!, vendor.name),
child: const Icon(Icons.delete, size: 16),
),
],
),
flex: 0,
useExpanded: false,
minWidth: 100,
),
],
),
);
}
Widget _buildDataTable(VendorController controller) {
final vendors = controller.vendors;
if (vendors.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: ShadcnTheme.spacing4),
Text(
'등록된 벤더가 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
);
}
return Container(
width: double.infinity,
decoration: BoxDecoration(
border: Border.all(color: Colors.black),
border: Border.all(color: ShadcnTheme.border),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Column(
children: [
// 고정 헤더
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: ShadcnTheme.muted.withValues(alpha: 0.3),
border: Border(bottom: BorderSide(color: Colors.black)),
),
child: Row(children: _buildHeaderCells()),
),
// 스크롤 바디
Expanded(
child: vendors.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.business_outlined,
size: 64,
color: ShadcnTheme.mutedForeground,
),
const SizedBox(height: 16),
Text(
'등록된 벤더가 없습니다',
style: ShadcnTheme.bodyMedium.copyWith(
color: ShadcnTheme.mutedForeground,
),
),
],
),
)
: ListView.builder(
itemCount: vendors.length,
itemBuilder: (context, index) => _buildTableRow(vendors[index], index),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: ShadTable.list(
header: const [
ShadTableCell.header(child: Text('번호')),
ShadTableCell.header(child: Text('벤더명')),
ShadTableCell.header(child: Text('등록일')),
ShadTableCell.header(child: Text('상태')),
ShadTableCell.header(child: Text('작업')),
],
children: [
for (int index = 0; index < vendors.length; index++)
[
// 번호
ShadTableCell(child: Text(((_controller.currentPage - 1) * _controller.pageSize + index + 1).toString(), style: ShadcnTheme.bodySmall)),
// 벤더명
ShadTableCell(child: Text(vendors[index].name, overflow: TextOverflow.ellipsis)),
// 등록일
ShadTableCell(child: Text(
vendors[index].createdAt != null
? DateFormat('yyyy-MM-dd').format(vendors[index].createdAt!)
: '-',
style: ShadcnTheme.bodySmall,
)),
// 상태
ShadTableCell(child: _buildStatusChip(vendors[index].isDeleted)),
// 작업
ShadTableCell(
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ShadButton.ghost(
onPressed: () => _showEditDialog(vendors[index].id!),
child: const Icon(Icons.edit, size: 16),
),
const SizedBox(width: ShadcnTheme.spacing1),
ShadButton.ghost(
onPressed: () => _showDeleteConfirmDialog(vendors[index].id!, vendors[index].name),
child: Icon(Icons.delete, size: 16, color: ShadcnTheme.destructive),
),
],
),
),
],
),
],
],
),
),
);
}
Widget _buildPagination(VendorController controller) {
if (controller.totalPages <= 1) return const SizedBox();
@@ -465,16 +366,18 @@ class _VendorListScreenState extends State<VendorListScreen> {
Widget _buildStatusChip(bool isDeleted) {
if (isDeleted) {
return ShadBadge.destructive(
return ShadBadge(
backgroundColor: ShadcnTheme.equipmentDisposal.withValues(alpha: 0.1),
foregroundColor: ShadcnTheme.equipmentDisposal,
child: const Text('비활성'),
);
} else {
return ShadBadge.secondary(
return ShadBadge(
backgroundColor: ShadcnTheme.equipmentIn.withValues(alpha: 0.1),
foregroundColor: ShadcnTheme.equipmentIn,
child: const Text('활성'),
);
}
}
// StandardDataRow 클래스 정의 (임시)
}

View File

@@ -290,142 +290,5 @@ class ZipcodeTable extends StatelessWidget {
);
}
void _showAddressDetails(BuildContext context, ZipcodeDto zipcode) {
showDialog(
context: context,
builder: (context) => ShadDialog(
title: const Text('우편번호 상세정보'),
description: const Text('선택한 우편번호의 상세 정보입니다'),
child: Container(
width: 400,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 우편번호
_buildInfoRow(
context,
'우편번호',
zipcode.zipcode.toString().padLeft(5, '0'),
Icons.local_post_office,
),
const SizedBox(height: 12),
// 시도
_buildInfoRow(
context,
'시도',
zipcode.sido,
Icons.location_city,
),
const SizedBox(height: 12),
// 구/군
_buildInfoRow(
context,
'구/군',
zipcode.gu,
Icons.location_on,
),
const SizedBox(height: 12),
// 상세주소
_buildInfoRow(
context,
'상세주소',
zipcode.etc,
Icons.home,
),
const SizedBox(height: 12),
// 전체주소
_buildInfoRow(
context,
'전체주소',
zipcode.fullAddress,
Icons.place,
),
const SizedBox(height: 20),
// 액션 버튼
Row(
children: [
Expanded(
child: ShadButton.outline(
onPressed: () => _copyToClipboard(
context,
zipcode.fullAddress,
'전체주소'
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.copy, size: 16),
SizedBox(width: 6),
Text('주소 복사'),
],
),
),
),
const SizedBox(width: 8),
Expanded(
child: ShadButton(
onPressed: () {
Navigator.of(context).pop();
onSelect(zipcode);
},
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.check, size: 16),
SizedBox(width: 6),
Text('선택'),
],
),
),
),
],
),
],
),
),
),
);
}
Widget _buildInfoRow(BuildContext context, String label, String value, IconData icon) {
final theme = ShadTheme.of(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 16,
color: theme.colorScheme.mutedForeground,
),
const SizedBox(width: 8),
SizedBox(
width: 60,
child: Text(
label,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w600,
color: theme.colorScheme.mutedForeground,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
value,
style: theme.textTheme.small.copyWith(
fontWeight: FontWeight.w500,
),
),
),
],
);
}
}
}