diff --git a/lib/screens/common/app_layout_redesign.dart b/lib/screens/common/app_layout_redesign.dart index 2c4dc3a..86e2393 100644 --- a/lib/screens/common/app_layout_redesign.dart +++ b/lib/screens/common/app_layout_redesign.dart @@ -199,7 +199,7 @@ class _AppLayoutRedesignState extends State decoration: BoxDecoration( color: ShadcnTheme.background, borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), boxShadow: ShadcnTheme.cardShadow, ), child: Column( @@ -237,10 +237,9 @@ class _AppLayoutRedesignState extends State height: 64, decoration: BoxDecoration( color: ShadcnTheme.background, - border: Border(bottom: BorderSide(color: ShadcnTheme.border)), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 2), ), @@ -368,7 +367,6 @@ class _AppLayoutRedesignState extends State return Container( decoration: BoxDecoration( color: ShadcnTheme.background, - border: Border(right: BorderSide(color: ShadcnTheme.border)), ), child: SidebarMenuRedesign( currentRoute: _currentRoute, @@ -385,7 +383,7 @@ class _AppLayoutRedesignState extends State return Container( padding: const EdgeInsets.all(ShadcnTheme.spacing6), decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: ShadcnTheme.border)), + border: Border(bottom: BorderSide(color: Colors.black)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/screens/common/components/shadcn_components.dart b/lib/screens/common/components/shadcn_components.dart index c3924fe..977c529 100644 --- a/lib/screens/common/components/shadcn_components.dart +++ b/lib/screens/common/components/shadcn_components.dart @@ -32,7 +32,7 @@ class ShadcnCard extends StatelessWidget { decoration: BoxDecoration( color: ShadcnTheme.card, borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), boxShadow: ShadcnTheme.cardShadow, ), child: child, @@ -145,7 +145,7 @@ class ShadcnButton extends StatelessWidget { return OutlinedButton.styleFrom( backgroundColor: backgroundColor ?? ShadcnTheme.secondary, foregroundColor: textColor ?? ShadcnTheme.secondaryForeground, - side: const BorderSide(color: ShadcnTheme.border), + side: const BorderSide(color: Colors.black), elevation: 0, shadowColor: Colors.transparent, shape: RoundedRectangleBorder( @@ -180,12 +180,12 @@ class ShadcnButton extends StatelessWidget { case ShadcnButtonSize.small: return const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing3, - vertical: ShadcnTheme.spacing1, + vertical: 6, ); case ShadcnButtonSize.medium: return const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing4, - vertical: ShadcnTheme.spacing2, + vertical: 10, ); case ShadcnButtonSize.large: return const EdgeInsets.symmetric( @@ -291,7 +291,7 @@ class ShadcnInput extends StatelessWidget { fillColor: ShadcnTheme.background, contentPadding: const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing3, - vertical: ShadcnTheme.spacing2, + vertical: 10, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), @@ -317,7 +317,7 @@ class ShadcnInput extends StatelessWidget { ), ), hintStyle: ShadcnTheme.bodyMedium.copyWith( - color: ShadcnTheme.mutedForeground, + color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), ), ), ), @@ -392,7 +392,7 @@ class ShadcnBadge extends StatelessWidget { Color _getBorderColor() { switch (variant) { case ShadcnBadgeVariant.outline: - return ShadcnTheme.border; + return Colors.black; default: return Colors.transparent; } @@ -448,7 +448,7 @@ class ShadcnSeparator extends StatelessWidget { return Container( width: direction == Axis.horizontal ? double.infinity : thickness, height: direction == Axis.vertical ? double.infinity : thickness, - color: color ?? ShadcnTheme.border, + color: color ?? Colors.black, ); } } @@ -476,7 +476,7 @@ class ShadcnAvatar extends StatelessWidget { decoration: BoxDecoration( color: backgroundColor ?? ShadcnTheme.muted, shape: BoxShape.circle, - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), ), child: ClipOval( child: diff --git a/lib/screens/common/layouts/base_list_screen.dart b/lib/screens/common/layouts/base_list_screen.dart new file mode 100644 index 0000000..5b442f5 --- /dev/null +++ b/lib/screens/common/layouts/base_list_screen.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_shadcn.dart'; + +/// 모든 리스트 화면의 기본 레이아웃을 제공하는 베이스 위젯 +/// +/// 일관된 레이아웃 구조를 보장하기 위한 템플릿 +class BaseListScreen extends StatelessWidget { + final Widget? headerSection; // 상단 통계 카드 등 + final Widget searchBar; // 검색바 섹션 + final Widget? filterSection; // 필터 섹션 + final Widget actionBar; // 액션 버튼 섹션 + final Widget dataTable; // 데이터 테이블 + final Widget? pagination; // 페이지네이션 + final bool isLoading; + final String? error; + final VoidCallback? onRefresh; + final String emptyMessage; + final IconData emptyIcon; + + const BaseListScreen({ + Key? key, + this.headerSection, + required this.searchBar, + this.filterSection, + required this.actionBar, + required this.dataTable, + this.pagination, + this.isLoading = false, + this.error, + this.onRefresh, + this.emptyMessage = '데이터가 없습니다', + this.emptyIcon = Icons.inbox_outlined, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return _buildLoadingState(); + } + + if (error != null) { + return _buildErrorState(); + } + + return Container( + color: ShadcnTheme.background, + child: SingleChildScrollView( + padding: const EdgeInsets.all(ShadcnTheme.spacing6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 헤더 섹션 (통계 카드 등) + if (headerSection != null) ...[ + headerSection!, + const SizedBox(height: ShadcnTheme.spacing4), + ], + + // 검색바 섹션 + searchBar, + const SizedBox(height: ShadcnTheme.spacing4), + + // 필터 섹션 + if (filterSection != null) ...[ + filterSection!, + const SizedBox(height: ShadcnTheme.spacing4), + ], + + // 액션바 섹션 + actionBar, + const SizedBox(height: ShadcnTheme.spacing4), + + // 데이터 테이블 + dataTable, + + // 페이지네이션 + if (pagination != null) ...[ + const SizedBox(height: ShadcnTheme.spacing4), + pagination!, + ], + ], + ), + ), + ); + } + + Widget _buildLoadingState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: ShadcnTheme.primary), + const SizedBox(height: ShadcnTheme.spacing4), + Text('데이터를 불러오는 중...', style: ShadcnTheme.bodyMuted), + ], + ), + ); + } + + Widget _buildErrorState() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 48, color: ShadcnTheme.destructive), + const SizedBox(height: ShadcnTheme.spacing4), + Text('오류가 발생했습니다', style: ShadcnTheme.headingH4), + const SizedBox(height: ShadcnTheme.spacing2), + Text(error ?? '', style: ShadcnTheme.bodyMuted), + if (onRefresh != null) ...[ + const SizedBox(height: ShadcnTheme.spacing4), + ElevatedButton( + onPressed: onRefresh, + style: ElevatedButton.styleFrom( + backgroundColor: ShadcnTheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + ), + child: const Text('다시 시도'), + ), + ], + ], + ), + ); + } + + Widget buildEmptyState() { + return Container( + padding: const EdgeInsets.all(ShadcnTheme.spacing8), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(emptyIcon, size: 48, color: ShadcnTheme.mutedForeground), + const SizedBox(height: ShadcnTheme.spacing4), + Text(emptyMessage, style: ShadcnTheme.bodyMuted), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/common/theme_shadcn.dart b/lib/screens/common/theme_shadcn.dart index d86acfa..645c518 100644 --- a/lib/screens/common/theme_shadcn.dart +++ b/lib/screens/common/theme_shadcn.dart @@ -3,58 +3,63 @@ import 'package:google_fonts/google_fonts.dart'; /// shadcn/ui 스타일 테마 시스템 class ShadcnTheme { - // shadcn/ui 색상 시스템 + // Teal 기반 색상 시스템 static const Color background = Color(0xFFFFFFFF); - static const Color foreground = Color(0xFF020817); + static const Color foreground = Color(0xFF0F172A); static const Color card = Color(0xFFFFFFFF); - static const Color cardForeground = Color(0xFF020817); + static const Color cardForeground = Color(0xFF0F172A); static const Color popover = Color(0xFFFFFFFF); - static const Color popoverForeground = Color(0xFF020817); - static const Color primary = Color(0xFF0F172A); - static const Color primaryForeground = Color(0xFFF8FAFC); - static const Color secondary = Color(0xFFF1F5F9); - static const Color secondaryForeground = Color(0xFF0F172A); - static const Color muted = Color(0xFFF1F5F9); - static const Color mutedForeground = Color(0xFF64748B); - static const Color accent = Color(0xFFF1F5F9); - static const Color accentForeground = Color(0xFF0F172A); - static const Color destructive = Color(0xFFEF4444); - static const Color destructiveForeground = Color(0xFFF8FAFC); - static const Color border = Color(0xFFE2E8F0); - static const Color input = Color(0xFFE2E8F0); - static const Color ring = Color(0xFF020817); + static const Color popoverForeground = Color(0xFF0F172A); + static const Color primary = Color(0xFF0D9488); // teal-600 + static const Color primaryForeground = Color(0xFFFFFFFF); + static const Color secondary = Color(0xFFF0FDFA); // teal-50 + static const Color secondaryForeground = Color(0xFF134E4A); // teal-900 + static const Color muted = Color(0xFFF1F5F9); // slate-100 + static const Color mutedForeground = Color(0xFF64748B); // slate-500 + static const Color accent = Color(0xFF14B8A6); // teal-500 + static const Color accentForeground = Color(0xFFFFFFFF); + static const Color destructive = Color(0xFFEF4444); // red-500 + static const Color destructiveForeground = Color(0xFFFFFFFF); + static const Color border = Color(0xFFE5E7EB); // gray-200 (기본 border는 연한 회색) + static const Color input = Color(0xFFE5E7EB); // gray-200 + static const Color ring = Color(0xFF14B8A6); // teal-500 static const Color radius = Color(0xFF000000); // 사용하지 않음 - // 그라데이션 색상 - static const Color gradient1 = Color(0xFF6366F1); - static const Color gradient2 = Color(0xFF8B5CF6); - static const Color gradient3 = Color(0xFFEC4899); + // Teal 그라데이션 색상 + static const Color gradient1 = Color(0xFF14B8A6); // teal-500 + static const Color gradient2 = Color(0xFF0D9488); // teal-600 + static const Color gradient3 = Color(0xFF0F766E); // teal-700 // 상태 색상 - static const Color success = Color(0xFF10B981); - static const Color warning = Color(0xFFF59E0B); - static const Color error = Color(0xFFEF4444); - static const Color info = Color(0xFF3B82F6); + static const Color success = Color(0xFF10B981); // emerald-500 + static const Color warning = Color(0xFFF59E0B); // amber-500 + static const Color error = Color(0xFFEF4444); // red-500 + static const Color info = Color(0xFF0891B2); // cyan-600 + + // 추가 색상 (회사 구분용) + static const Color blue = Color(0xFF3B82F6); // blue-500 + static const Color purple = Color(0xFF8B5CF6); // purple-500 + static const Color green = Color(0xFF22C55E); // green-500 // 그림자 설정 static List get cardShadow => [ BoxShadow( - color: Colors.black.withOpacity(0.05), - blurRadius: 6, + color: primary.withValues(alpha: 0.08), + blurRadius: 8, offset: const Offset(0, 2), ), BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 20, - offset: const Offset(0, 10), + color: Colors.black.withValues(alpha: 0.04), + blurRadius: 16, + offset: const Offset(0, 8), ), ]; static List get buttonShadow => [ BoxShadow( - color: Colors.black.withOpacity(0.1), - blurRadius: 3, - offset: const Offset(0, 1), + color: primary.withValues(alpha: 0.2), + blurRadius: 4, + offset: const Offset(0, 2), ), ]; @@ -81,37 +86,37 @@ class ShadcnTheme { static const double radius3xl = 24.0; static const double radiusFull = 9999.0; - // 타이포그래피 시스템 + // 타이포그래피 시스템 (통일된 크기) static TextStyle get headingH1 => GoogleFonts.inter( - fontSize: 36, - fontWeight: FontWeight.w800, - color: foreground, - letterSpacing: -0.02, - ); - - static TextStyle get headingH2 => GoogleFonts.inter( - fontSize: 30, + fontSize: 32, fontWeight: FontWeight.w700, color: foreground, letterSpacing: -0.02, ); - static TextStyle get headingH3 => GoogleFonts.inter( + static TextStyle get headingH2 => GoogleFonts.inter( fontSize: 24, fontWeight: FontWeight.w600, color: foreground, letterSpacing: -0.01, ); - static TextStyle get headingH4 => GoogleFonts.inter( + static TextStyle get headingH3 => GoogleFonts.inter( fontSize: 20, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w500, color: foreground, letterSpacing: -0.01, ); - static TextStyle get bodyLarge => GoogleFonts.inter( + static TextStyle get headingH4 => GoogleFonts.inter( fontSize: 16, + fontWeight: FontWeight.w500, + color: foreground, + letterSpacing: 0, + ); + + static TextStyle get bodyLarge => GoogleFonts.inter( + fontSize: 14, fontWeight: FontWeight.w400, color: foreground, letterSpacing: 0, @@ -194,7 +199,7 @@ class ShadcnTheme { foregroundColor: foreground, elevation: 0, scrolledUnderElevation: 1, - shadowColor: Colors.black.withOpacity(0.1), + shadowColor: Colors.black.withValues(alpha: 0.1), surfaceTintColor: Colors.transparent, titleTextStyle: headingH4, iconTheme: const IconThemeData(color: foreground), @@ -206,7 +211,7 @@ class ShadcnTheme { borderRadius: BorderRadius.circular(radiusLg), side: const BorderSide(color: border, width: 1), ), - shadowColor: Colors.black.withOpacity(0.05), + shadowColor: Colors.black.withValues(alpha: 0.05), ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( diff --git a/lib/screens/common/widgets/pagination.dart b/lib/screens/common/widgets/pagination.dart index a5e2e69..2e0b473 100644 --- a/lib/screens/common/widgets/pagination.dart +++ b/lib/screens/common/widgets/pagination.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_shadcn.dart'; /// 페이지네이션 위젯 (<< < 1 2 3 ... 10 > >>) /// - totalCount: 전체 아이템 수 @@ -33,56 +34,105 @@ class Pagination extends StatelessWidget { for (int i = startPage; i <= endPage; i++) { pageButtons.add( Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - minimumSize: const Size(36, 36), - backgroundColor: i == currentPage ? Colors.blue : Colors.white, - foregroundColor: i == currentPage ? Colors.white : Colors.black, - padding: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 4), + child: InkWell( + onTap: i == currentPage ? null : () => onPageChanged(i), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + child: Container( + height: 32, + constraints: const BoxConstraints(minWidth: 32), + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: i == currentPage ? ShadcnTheme.primary : Colors.transparent, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all( + color: i == currentPage ? ShadcnTheme.primary : Colors.black, + ), + ), + alignment: Alignment.center, + child: Text( + '$i', + style: ShadcnTheme.labelMedium.copyWith( + color: i == currentPage + ? ShadcnTheme.primaryForeground + : ShadcnTheme.foreground, + ), + ), ), - onPressed: i == currentPage ? null : () => onPageChanged(i), - child: Text('$i'), ), ), ); } - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 가장 처음 페이지로 이동 - IconButton( - icon: const Icon(Icons.first_page), - tooltip: '처음', - onPressed: currentPage > 1 ? () => onPageChanged(1) : null, + return Container( + padding: const EdgeInsets.symmetric(vertical: ShadcnTheme.spacing4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // 가장 처음 페이지로 이동 + _buildNavigationButton( + icon: Icons.first_page, + tooltip: '처음', + onPressed: currentPage > 1 ? () => onPageChanged(1) : null, + ), + const SizedBox(width: 4), + // 이전 페이지로 이동 + _buildNavigationButton( + icon: Icons.chevron_left, + tooltip: '이전', + onPressed: currentPage > 1 ? () => onPageChanged(currentPage - 1) : null, + ), + const SizedBox(width: 8), + // 페이지 번호 버튼들 + ...pageButtons, + const SizedBox(width: 8), + // 다음 페이지로 이동 + _buildNavigationButton( + icon: Icons.chevron_right, + tooltip: '다음', + onPressed: currentPage < totalPages + ? () => onPageChanged(currentPage + 1) + : null, + ), + const SizedBox(width: 4), + // 마짉 페이지로 이동 + _buildNavigationButton( + icon: Icons.last_page, + tooltip: '마짉', + onPressed: currentPage < totalPages ? () => onPageChanged(totalPages) : null, + ), + ], + ), + ); + } + + Widget _buildNavigationButton({ + required IconData icon, + required String tooltip, + VoidCallback? onPressed, + }) { + final isDisabled = onPressed == null; + return Tooltip( + message: tooltip, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + child: Container( + height: 32, + width: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all( + color: isDisabled ? ShadcnTheme.muted : Colors.black, + ), + ), + child: Icon( + icon, + size: 18, + color: isDisabled ? ShadcnTheme.mutedForeground : ShadcnTheme.foreground, + ), ), - // 이전 페이지로 이동 - IconButton( - icon: const Icon(Icons.chevron_left), - tooltip: '이전', - onPressed: - currentPage > 1 ? () => onPageChanged(currentPage - 1) : null, - ), - // 페이지 번호 버튼들 - ...pageButtons, - // 다음 페이지로 이동 - IconButton( - icon: const Icon(Icons.chevron_right), - tooltip: '다음', - onPressed: - currentPage < totalPages - ? () => onPageChanged(currentPage + 1) - : null, - ), - // 마지막 페이지로 이동 - IconButton( - icon: const Icon(Icons.last_page), - tooltip: '마지막', - onPressed: - currentPage < totalPages ? () => onPageChanged(totalPages) : null, - ), - ], + ), ); } } diff --git a/lib/screens/common/widgets/standard_action_bar.dart b/lib/screens/common/widgets/standard_action_bar.dart new file mode 100644 index 0000000..e7e480c --- /dev/null +++ b/lib/screens/common/widgets/standard_action_bar.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_shadcn.dart'; +import 'package:superport/screens/common/components/shadcn_components.dart'; + +/// 표준 액션바 위젯 +/// +/// 모든 리스트 화면에서 일관된 액션 버튼 배치와 상태 표시 제공 +class StandardActionBar extends StatelessWidget { + final List leftActions; // 왼쪽 액션 버튼들 + final List rightActions; // 오른쪽 액션 버튼들 + final int? selectedCount; // 선택된 항목 수 + final int totalCount; // 전체 항목 수 + final VoidCallback? onRefresh; // 새로고침 콜백 + final String? statusMessage; // 추가 상태 메시지 + + const StandardActionBar({ + Key? key, + this.leftActions = const [], + this.rightActions = const [], + this.selectedCount, + required this.totalCount, + this.onRefresh, + this.statusMessage, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 왼쪽 액션 버튼들 + Row( + children: [ + ...leftActions.map((action) => Padding( + padding: const EdgeInsets.only(right: ShadcnTheme.spacing2), + child: action, + )), + ], + ), + + // 오른쪽 상태 표시 및 액션들 + Row( + children: [ + // 추가 상태 메시지 + if (statusMessage != null) ...[ + Text(statusMessage!, style: ShadcnTheme.bodyMuted), + const SizedBox(width: ShadcnTheme.spacing3), + ], + + // 선택된 항목 수 표시 + if (selectedCount != null && selectedCount! > 0) ...[ + Container( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + decoration: BoxDecoration( + color: ShadcnTheme.primary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm), + border: Border.all(color: ShadcnTheme.primary.withValues(alpha: 0.3)), + ), + child: Text( + '$selectedCount개 선택됨', + style: TextStyle( + fontWeight: FontWeight.bold, + color: ShadcnTheme.primary, + ), + ), + ), + const SizedBox(width: ShadcnTheme.spacing3), + ], + + // 전체 항목 수 표시 + Container( + padding: const EdgeInsets.symmetric( + vertical: 6, + horizontal: 12, + ), + decoration: BoxDecoration( + color: ShadcnTheme.muted.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm), + ), + child: Text( + '총 $totalCount개', + style: ShadcnTheme.bodySmall, + ), + ), + + // 새로고침 버튼 + if (onRefresh != null) ...[ + const SizedBox(width: ShadcnTheme.spacing3), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: onRefresh, + tooltip: '새로고침', + iconSize: 20, + ), + ], + + // 오른쪽 액션 버튼들 + ...rightActions.map((action) => Padding( + padding: const EdgeInsets.only(left: ShadcnTheme.spacing2), + child: action, + )), + ], + ), + ], + ); + } +} + +/// 표준 액션 버튼 그룹 +class StandardActionButtons { + /// 추가 버튼 + static Widget addButton({ + required String text, + required VoidCallback onPressed, + IconData icon = Icons.add, + }) { + return ShadcnButton( + text: text, + onPressed: onPressed, + variant: ShadcnButtonVariant.primary, + textColor: Colors.white, + icon: Icon(icon, size: 16), + ); + } + + /// 삭제 버튼 + static Widget deleteButton({ + required VoidCallback? onPressed, + bool enabled = true, + String text = '삭제', + }) { + return ShadcnButton( + text: text, + onPressed: enabled ? onPressed : null, + variant: enabled + ? ShadcnButtonVariant.destructive + : ShadcnButtonVariant.secondary, + icon: const Icon(Icons.delete, size: 16), + ); + } + + /// 엑셀 내보내기 버튼 + static Widget exportButton({ + required VoidCallback onPressed, + String text = '엑셀 내보내기', + }) { + return ShadcnButton( + text: text, + onPressed: onPressed, + variant: ShadcnButtonVariant.secondary, + icon: const Icon(Icons.download, size: 16), + ); + } + + /// 엑셀 가져오기 버튼 + static Widget importButton({ + required VoidCallback onPressed, + String text = '엑셀 가져오기', + }) { + return ShadcnButton( + text: text, + onPressed: onPressed, + variant: ShadcnButtonVariant.secondary, + icon: const Icon(Icons.upload, size: 16), + ); + } + + /// 새로고침 버튼 + static Widget refreshButton({ + required VoidCallback onPressed, + String text = '새로고침', + }) { + return ShadcnButton( + text: text, + onPressed: onPressed, + variant: ShadcnButtonVariant.secondary, + icon: const Icon(Icons.refresh, size: 16), + ); + } + + /// 필터 초기화 버튼 + static Widget clearFiltersButton({ + required VoidCallback onPressed, + String text = '필터 초기화', + }) { + return ShadcnButton( + text: text, + onPressed: onPressed, + variant: ShadcnButtonVariant.ghost, + icon: const Icon(Icons.clear_all, size: 16), + ); + } +} + +/// 표준 필터 드롭다운 +class StandardFilterDropdown extends StatelessWidget { + final T value; + final List> items; + final ValueChanged onChanged; + final String? hint; + + const StandardFilterDropdown({ + Key? key, + required this.value, + required this.items, + required this.onChanged, + this.hint, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: ShadcnTheme.card, + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + items: items, + onChanged: onChanged, + hint: hint != null ? Text(hint!) : null, + style: ShadcnTheme.bodySmall, + icon: const Icon(Icons.arrow_drop_down, size: 20), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/common/widgets/standard_data_table.dart b/lib/screens/common/widgets/standard_data_table.dart new file mode 100644 index 0000000..3271bef --- /dev/null +++ b/lib/screens/common/widgets/standard_data_table.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_shadcn.dart'; + +/// 표준 데이터 테이블 컬럼 정의 +class DataColumn { + final String label; + final double? width; + final int? flex; + final bool isNumeric; + final TextAlign textAlign; + + DataColumn({ + required this.label, + this.width, + this.flex, + this.isNumeric = false, + TextAlign? textAlign, + }) : textAlign = textAlign ?? (isNumeric ? TextAlign.right : TextAlign.left); +} + +/// 표준 데이터 테이블 위젯 +/// +/// 모든 리스트 화면에서 일관된 테이블 스타일 제공 +class StandardDataTable extends StatelessWidget { + final List columns; + final List rows; + final bool showCheckbox; + final bool? isAllSelected; + final ValueChanged? onSelectAll; + final bool enableHorizontalScroll; + final ScrollController? horizontalScrollController; + final Widget? emptyWidget; + final bool applyZebraStripes; // 짝수 행 배경색 적용 여부 + + const StandardDataTable({ + Key? key, + required this.columns, + required this.rows, + this.showCheckbox = false, + this.isAllSelected, + this.onSelectAll, + this.enableHorizontalScroll = false, + this.horizontalScrollController, + this.emptyWidget, + this.applyZebraStripes = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (rows.isEmpty) { + return _buildEmptyState(); + } + + final table = Container( + width: double.infinity, + decoration: BoxDecoration( + color: ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), + border: Border.all(color: Colors.black), + boxShadow: ShadcnTheme.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 테이블 헤더 + _buildHeader(), + // 테이블 데이터 행들 + ...rows, + ], + ), + ); + + if (enableHorizontalScroll) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: horizontalScrollController, + child: table, + ); + } + + return table; + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing4, + vertical: 10, + ), + decoration: BoxDecoration( + color: ShadcnTheme.muted.withValues(alpha: 0.3), + border: Border( + bottom: BorderSide(color: Colors.black), + ), + ), + child: Row( + children: [ + // 체크박스 컬럼 + if (showCheckbox) + SizedBox( + width: 40, + child: Checkbox( + value: isAllSelected, + onChanged: onSelectAll, + tristate: false, + ), + ), + + // 데이터 컬럼들 + ...columns.map((column) { + Widget child = Text( + column.label, + style: ShadcnTheme.bodyMedium.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: column.textAlign, + ); + + if (column.width != null) { + return SizedBox( + width: column.width, + child: child, + ); + } else if (column.flex != null) { + return Expanded( + flex: column.flex!, + child: child, + ); + } else { + return Expanded(child: child); + } + }), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(ShadcnTheme.spacing8), + decoration: BoxDecoration( + color: ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), + border: Border.all(color: Colors.black), + boxShadow: ShadcnTheme.cardShadow, + ), + child: emptyWidget ?? + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inbox_outlined, + size: 48, + color: ShadcnTheme.muted, + ), + const SizedBox(height: ShadcnTheme.spacing4), + Text( + '데이터가 없습니다', + style: ShadcnTheme.bodyMuted, + ), + ], + ), + ), + ); + } +} + +/// 표준 데이터 행 위젯 +class StandardDataRow extends StatelessWidget { + final int index; + final List cells; + final bool showCheckbox; + final bool? isSelected; + final ValueChanged? onSelect; + final bool applyZebraStripes; + final List columns; + + const StandardDataRow({ + Key? key, + required this.index, + required this.cells, + this.showCheckbox = false, + this.isSelected, + this.onSelect, + this.applyZebraStripes = true, + required this.columns, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing4, + vertical: 4, + ), + decoration: BoxDecoration( + color: applyZebraStripes && index % 2 == 1 + ? ShadcnTheme.muted.withValues(alpha: 0.1) + : null, + border: Border( + bottom: BorderSide(color: Colors.black), + ), + ), + child: Row( + children: [ + // 체크박스 + if (showCheckbox) + SizedBox( + width: 40, + child: Checkbox( + value: isSelected, + onChanged: onSelect, + tristate: false, + ), + ), + + // 데이터 셀들 + ...cells.asMap().entries.map((entry) { + final cellIndex = entry.key; + final cell = entry.value; + + if (cellIndex >= columns.length) return const SizedBox.shrink(); + + final column = columns[cellIndex]; + + if (column.width != null) { + return SizedBox( + width: column.width, + child: cell, + ); + } else if (column.flex != null) { + return Expanded( + flex: column.flex!, + child: cell, + ); + } else { + return Expanded(child: cell); + } + }), + ], + ), + ); + } +} + +/// 표준 관리 버튼 세트 +class StandardActionButtons extends StatelessWidget { + final VoidCallback? onView; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + final List? customButtons; + final double buttonSize; + + const StandardActionButtons({ + Key? key, + this.onView, + this.onEdit, + this.onDelete, + this.customButtons, + this.buttonSize = 32, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (onView != null) + _buildIconButton( + Icons.visibility_outlined, + onView!, + '보기', + ShadcnTheme.primary, + ), + if (onEdit != null) + _buildIconButton( + Icons.edit_outlined, + onEdit!, + '수정', + ShadcnTheme.primary, + ), + if (onDelete != null) + _buildIconButton( + Icons.delete_outline, + onDelete!, + '삭제', + ShadcnTheme.destructive, + ), + if (customButtons != null) ...customButtons!, + ], + ); + } + + Widget _buildIconButton( + IconData icon, + VoidCallback onPressed, + String tooltip, + Color color, + ) { + return IconButton( + constraints: BoxConstraints( + minWidth: buttonSize, + minHeight: buttonSize, + maxWidth: buttonSize, + maxHeight: buttonSize, + ), + padding: const EdgeInsets.all(4), + icon: Icon(icon, size: 16, color: color), + onPressed: onPressed, + tooltip: tooltip, + ); + } +} \ No newline at end of file diff --git a/lib/screens/common/widgets/standard_states.dart b/lib/screens/common/widgets/standard_states.dart new file mode 100644 index 0000000..c0e1713 --- /dev/null +++ b/lib/screens/common/widgets/standard_states.dart @@ -0,0 +1,297 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_shadcn.dart'; +import 'package:superport/screens/common/components/shadcn_components.dart'; + +/// 표준 로딩 상태 위젯 +class StandardLoadingState extends StatelessWidget { + final String message; + + const StandardLoadingState({ + Key? key, + this.message = '데이터를 불러오는 중...', + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator( + color: ShadcnTheme.primary, + strokeWidth: 3, + ), + const SizedBox(height: ShadcnTheme.spacing4), + Text( + message, + style: ShadcnTheme.bodyMuted, + ), + ], + ), + ); + } +} + +/// 표준 에러 상태 위젯 +class StandardErrorState extends StatelessWidget { + final String title; + final String? message; + final VoidCallback? onRetry; + final IconData icon; + + const StandardErrorState({ + Key? key, + this.title = '오류가 발생했습니다', + this.message, + this.onRetry, + this.icon = Icons.error_outline, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: const EdgeInsets.all(ShadcnTheme.spacing6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 64, + color: ShadcnTheme.destructive.withValues(alpha: 0.8), + ), + const SizedBox(height: ShadcnTheme.spacing4), + Text( + title, + style: ShadcnTheme.headingH4, + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: ShadcnTheme.spacing2), + Text( + message!, + style: ShadcnTheme.bodyMuted, + textAlign: TextAlign.center, + ), + ], + if (onRetry != null) ...[ + const SizedBox(height: ShadcnTheme.spacing6), + ShadcnButton( + text: '다시 시도', + onPressed: onRetry, + variant: ShadcnButtonVariant.primary, + textColor: Colors.white, + icon: const Icon(Icons.refresh, size: 16), + ), + ], + ], + ), + ), + ); + } +} + +/// 표준 빈 상태 위젯 +class StandardEmptyState extends StatelessWidget { + final String title; + final String? message; + final Widget? action; + final IconData icon; + + const StandardEmptyState({ + Key? key, + this.title = '데이터가 없습니다', + this.message, + this.action, + this.icon = Icons.inbox_outlined, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + padding: const EdgeInsets.all(ShadcnTheme.spacing8), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 64, + color: ShadcnTheme.mutedForeground.withValues(alpha: 0.5), + ), + const SizedBox(height: ShadcnTheme.spacing4), + Text( + title, + style: ShadcnTheme.headingH4.copyWith( + color: ShadcnTheme.mutedForeground, + ), + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: ShadcnTheme.spacing2), + Text( + message!, + style: ShadcnTheme.bodyMuted, + textAlign: TextAlign.center, + ), + ], + if (action != null) ...[ + const SizedBox(height: ShadcnTheme.spacing6), + action!, + ], + ], + ), + ), + ); + } +} + +/// 표준 정보 메시지 위젯 +class StandardInfoMessage extends StatelessWidget { + final String message; + final IconData icon; + final Color? color; + final VoidCallback? onClose; + + const StandardInfoMessage({ + Key? key, + required this.message, + this.icon = Icons.info_outline, + this.color, + this.onClose, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final displayColor = color ?? ShadcnTheme.primary; + + return Container( + padding: const EdgeInsets.all(ShadcnTheme.spacing3), + decoration: BoxDecoration( + color: displayColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all( + color: displayColor.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + icon, + size: 20, + color: displayColor, + ), + const SizedBox(width: ShadcnTheme.spacing2), + Expanded( + child: Text( + message, + style: TextStyle( + color: displayColor, + fontSize: 14, + ), + ), + ), + if (onClose != null) + IconButton( + icon: Icon( + Icons.close, + size: 16, + color: displayColor, + ), + onPressed: onClose, + padding: EdgeInsets.zero, + constraints: const BoxConstraints( + maxHeight: 24, + maxWidth: 24, + ), + ), + ], + ), + ); + } +} + +/// 표준 통계 카드 위젯 +class StandardStatCard extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final Color color; + final String? subtitle; + + const StandardStatCard({ + Key? key, + required this.title, + required this.value, + required this.icon, + required this.color, + this.subtitle, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(ShadcnTheme.spacing5), + decoration: BoxDecoration( + color: ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all(color: Colors.black), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusSm), + ), + child: Icon( + icon, + size: 20, + color: color, + ), + ), + const SizedBox(width: ShadcnTheme.spacing3), + Expanded( + child: Text( + title, + style: ShadcnTheme.bodySmall.copyWith( + color: ShadcnTheme.mutedForeground, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: ShadcnTheme.spacing3), + Text( + value, + style: ShadcnTheme.headingH3.copyWith( + color: color, + fontWeight: FontWeight.bold, + ), + ), + if (subtitle != null) ...[ + const SizedBox(height: ShadcnTheme.spacing1), + Text( + subtitle!, + style: ShadcnTheme.bodySmall.copyWith( + color: ShadcnTheme.mutedForeground, + ), + ), + ], + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/screens/common/widgets/unified_search_bar.dart b/lib/screens/common/widgets/unified_search_bar.dart new file mode 100644 index 0000000..18e4424 --- /dev/null +++ b/lib/screens/common/widgets/unified_search_bar.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:superport/screens/common/theme_shadcn.dart'; +import 'package:superport/screens/common/components/shadcn_components.dart'; + +/// 통일된 검색바 위젯 +/// +/// 모든 리스트 화면에서 동일한 스타일과 동작을 보장 +class UnifiedSearchBar extends StatelessWidget { + final TextEditingController controller; + final String placeholder; + final VoidCallback onSearch; + final ValueChanged? onChanged; // 실시간 검색을 위한 콜백 + final VoidCallback? onClear; + final Widget? suffixButton; // 검색 버튼 외 추가 버튼 + final List? filters; // 필터 위젯들 + final bool showSearchButton; + + const UnifiedSearchBar({ + Key? key, + required this.controller, + this.placeholder = '검색어를 입력하세요...', + required this.onSearch, + this.onChanged, + this.onClear, + this.suffixButton, + this.filters, + this.showSearchButton = true, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + children: [ + // 검색 입력 필드 + Expanded( + child: Container( + height: 40, + decoration: BoxDecoration( + color: ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all(color: Colors.black), + ), + child: TextField( + controller: controller, + onChanged: onChanged, + onSubmitted: (_) => onSearch(), + decoration: InputDecoration( + hintText: placeholder, + hintStyle: TextStyle(color: ShadcnTheme.muted), + prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20), + suffixIcon: controller.text.isNotEmpty && onClear != null + ? IconButton( + icon: Icon(Icons.clear, color: ShadcnTheme.muted, size: 18), + onPressed: () { + controller.clear(); + onClear!(); + }, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(maxHeight: 40, maxWidth: 40), + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + ), + ), + ), + + // 검색 버튼 + if (showSearchButton) ...[ + const SizedBox(width: ShadcnTheme.spacing2), + SizedBox( + height: 40, + child: ShadcnButton( + text: '검색', + onPressed: onSearch, + variant: ShadcnButtonVariant.primary, + textColor: Colors.white, + icon: const Icon(Icons.search, size: 16), + ), + ), + ], + + // 추가 버튼 (예: 추가, 필터 등) + if (suffixButton != null) ...[ + const SizedBox(width: ShadcnTheme.spacing2), + suffixButton!, + ], + ], + ), + + // 필터 섹션 + if (filters != null && filters!.isNotEmpty) ...[ + const SizedBox(height: ShadcnTheme.spacing3), + Row( + children: [ + ...filters!.map((filter) => Padding( + padding: const EdgeInsets.only(right: ShadcnTheme.spacing2), + child: filter, + )), + ], + ), + ], + ], + ); + } +} \ No newline at end of file diff --git a/lib/screens/company/company_list_redesign.dart b/lib/screens/company/company_list_redesign.dart index ca5a506..3679b7c 100644 --- a/lib/screens/company/company_list_redesign.dart +++ b/lib/screens/company/company_list_redesign.dart @@ -4,11 +4,17 @@ import 'dart:async'; import 'package:superport/models/company_model.dart'; import 'package:superport/screens/common/theme_shadcn.dart'; import 'package:superport/screens/common/components/shadcn_components.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; +import 'package:superport/screens/common/widgets/unified_search_bar.dart'; +import 'package:superport/screens/common/widgets/standard_data_table.dart' as std_table; +import 'package:superport/screens/common/widgets/standard_action_bar.dart'; +import 'package:superport/screens/common/widgets/standard_states.dart'; +import 'package:superport/screens/common/layouts/base_list_screen.dart'; import 'package:superport/services/mock_data_service.dart'; import 'package:superport/screens/company/widgets/company_branch_dialog.dart'; import 'package:superport/screens/company/controllers/company_list_controller.dart'; -/// shadcn/ui 스타일로 재설계된 회사 관리 화면 +/// shadcn/ui 스타일로 재설계된 회사 관리 화면 (통일된 UI 컴포넌트 사용) class CompanyListRedesign extends StatefulWidget { const CompanyListRedesign({super.key}); @@ -18,41 +24,33 @@ class CompanyListRedesign extends StatefulWidget { class _CompanyListRedesignState extends State { late CompanyListController _controller; - final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); Timer? _debounceTimer; + int _currentPage = 1; + final int _pageSize = 10; @override void initState() { super.initState(); _controller = CompanyListController(dataService: MockDataService()); - _controller.initialize(); - _setupScrollListener(); + _controller.initializeWithPageSize(_pageSize); } @override void dispose() { _controller.dispose(); - _scrollController.dispose(); _searchController.dispose(); _debounceTimer?.cancel(); super.dispose(); } - /// 스크롤 리스너 설정 (무한 스크롤) - void _setupScrollListener() { - _scrollController.addListener(() { - if (_scrollController.position.pixels == - _scrollController.position.maxScrollExtent) { - _controller.loadMore(); - } - }); - } - /// 검색어 입력 처리 (디바운싱) void _onSearchChanged(String value) { _debounceTimer?.cancel(); _debounceTimer = Timer(const Duration(milliseconds: 500), () { + setState(() { + _currentPage = 1; + }); _controller.updateSearchKeyword(value); }); } @@ -82,13 +80,15 @@ class _CompanyListRedesignState extends State { onPressed: () async { Navigator.pop(context); final success = await _controller.deleteCompany(id); - if (!success && mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(_controller.error ?? '삭제에 실패했습니다'), - backgroundColor: Colors.red, - ), - ); + if (!success) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(_controller.error ?? '삭제에 실패했습니다'), + backgroundColor: Colors.red, + ), + ); + } } }, child: const Text('삭제'), @@ -127,13 +127,38 @@ class _CompanyListRedesignState extends State { spacing: ShadcnTheme.spacing1, children: types.map((type) { - return ShadcnBadge( - text: companyTypeToString(type), - variant: - type == CompanyType.customer - ? ShadcnBadgeVariant.primary - : ShadcnBadgeVariant.secondary, - size: ShadcnBadgeSize.small, + Color bgColor; + Color borderColor; + Color textColor; + + switch(type) { + case CompanyType.customer: + bgColor = ShadcnTheme.green.withValues(alpha: 0.9); + textColor = Colors.white; + break; + case CompanyType.partner: + bgColor = ShadcnTheme.purple.withValues(alpha: 0.9); + textColor = Colors.white; + break; + default: + bgColor = ShadcnTheme.muted.withValues(alpha: 0.9); + textColor = ShadcnTheme.foreground; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + companyTypeToString(type), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: textColor, + ), + ), ); }).toList(), ); @@ -141,11 +166,22 @@ class _CompanyListRedesignState extends State { /// 본사/지점 구분 배지 생성 Widget _buildCompanyTypeLabel(bool isBranch) { - return ShadcnBadge( - text: isBranch ? '지점' : '본사', - variant: - isBranch ? ShadcnBadgeVariant.outline : ShadcnBadgeVariant.primary, - size: ShadcnBadgeSize.small, + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: isBranch + ? ShadcnTheme.blue.withValues(alpha: 0.9) + : ShadcnTheme.primary.withValues(alpha: 0.9), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + isBranch ? '지점' : '본사', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), ); } @@ -184,7 +220,6 @@ class _CompanyListRedesignState extends State { 'mainCompanyName': null, }); if (company.branches != null) { - print('[CompanyListRedesign] Company ${company.name} has ${company.branches!.length} branches'); for (final branch in company.branches!) { displayCompanies.add({ 'branch': branch, @@ -193,377 +228,214 @@ class _CompanyListRedesignState extends State { 'mainCompanyName': company.name, }); } - } else { - print('[CompanyListRedesign] Company ${company.name} has no branches'); } } final int totalCount = displayCompanies.length; - print('[CompanyListRedesign] Total display items: $totalCount (companies + branches)'); + + // 페이지네이션을 위한 데이터 처리 + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = startIndex + _pageSize; + final List> pagedCompanies = displayCompanies.sublist( + startIndex, + endIndex > displayCompanies.length ? displayCompanies.length : endIndex, + ); - return SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(ShadcnTheme.spacing6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 헤더 및 검색 바 - Row( - children: [ - Expanded( - child: Container( - height: 40, - decoration: BoxDecoration( - color: ShadcnTheme.card, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - border: Border.all(color: ShadcnTheme.border), - ), - child: TextField( - controller: _searchController, - onChanged: _onSearchChanged, - decoration: InputDecoration( - hintText: '회사명, 담당자명, 연락처로 검색', - hintStyle: TextStyle(color: ShadcnTheme.muted), - prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted), - border: InputBorder.none, - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - ), - ), - ), - ), - const SizedBox(width: ShadcnTheme.spacing4), - ShadcnButton( - text: '회사 추가', - onPressed: _navigateToAddScreen, - variant: ShadcnButtonVariant.primary, - textColor: Colors.white, - icon: Icon(Icons.add), - ), - ], - ), + // 로딩 상태 + if (controller.isLoading && controller.companies.isEmpty) { + return const StandardLoadingState( + message: '회사 데이터를 불러오는 중...', + ); + } - const SizedBox(height: ShadcnTheme.spacing4), + return BaseListScreen( + isLoading: false, + error: controller.error, + onRefresh: controller.refresh, + emptyMessage: controller.searchKeyword.isNotEmpty + ? '검색 결과가 없습니다' + : '등록된 회사가 없습니다', + emptyIcon: Icons.business_outlined, + + // 검색바 + searchBar: UnifiedSearchBar( + controller: _searchController, + placeholder: '회사명, 담당자명, 연락처로 검색', + onChanged: _onSearchChanged, // 실시간 검색 (디바운싱) + onSearch: () => _controller.updateSearchKeyword(_searchController.text), // 즉시 검색 + onClear: () { + _searchController.clear(); + _onSearchChanged(''); + }, + suffixButton: StandardActionButtons.addButton( + text: '회사 추가', + onPressed: _navigateToAddScreen, + ), + ), + + // 액션바 + actionBar: StandardActionBar( + leftActions: [], + totalCount: totalCount, + onRefresh: controller.refresh, + statusMessage: controller.searchKeyword.isNotEmpty + ? '"${controller.searchKeyword}" 검색 결과' + : null, + ), - // 결과 정보 - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('총 $totalCount개 회사', style: ShadcnTheme.bodyMuted), - if (controller.searchKeyword.isNotEmpty) - Text( - '"${controller.searchKeyword}" 검색 결과', - style: ShadcnTheme.bodyMuted, - ), - ], - ), + // 에러 메시지 + filterSection: controller.error != null + ? StandardInfoMessage( + message: controller.error!, + icon: Icons.error_outline, + color: ShadcnTheme.destructive, + onClose: controller.clearError, + ) + : null, - const SizedBox(height: ShadcnTheme.spacing4), + // 데이터 테이블 + dataTable: displayCompanies.isEmpty + ? StandardEmptyState( + title: controller.searchKeyword.isNotEmpty + ? '검색 결과가 없습니다' + : '등록된 회사가 없습니다', + icon: Icons.business_outlined, + action: controller.searchKeyword.isEmpty + ? StandardActionButtons.addButton( + text: '첫 회사 추가하기', + onPressed: _navigateToAddScreen, + ) + : null, + ) + : std_table.StandardDataTable( + columns: [ + std_table.DataColumn(label: '번호', flex: 1), + std_table.DataColumn(label: '회사명', flex: 3), + std_table.DataColumn(label: '구분', flex: 2), + std_table.DataColumn(label: '유형', flex: 2), + std_table.DataColumn(label: '연락처', flex: 2), + std_table.DataColumn(label: '관리', flex: 2), + ], + rows: [ + ...pagedCompanies.asMap().entries.map((entry) { + final int index = startIndex + entry.key; + final companyData = entry.value; + final bool isBranch = companyData['isBranch'] as bool; + final Company company = + isBranch + ? _convertBranchToCompany(companyData['branch'] as Branch) + : companyData['company'] as Company; + final String? mainCompanyName = + companyData['mainCompanyName'] as String?; - // 에러 메시지 - if (controller.error != null) - Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing4), - margin: const EdgeInsets.only(bottom: ShadcnTheme.spacing4), - decoration: BoxDecoration( - color: Colors.red.shade50, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - border: Border.all(color: Colors.red.shade200), - ), - child: Row( - children: [ - Icon(Icons.error_outline, color: Colors.red), - const SizedBox(width: ShadcnTheme.spacing2), - Expanded( - child: Text( - controller.error!, - style: TextStyle(color: Colors.red.shade700), - ), - ), - IconButton( - icon: Icon(Icons.close, size: 16), - onPressed: controller.clearError, - padding: EdgeInsets.zero, - constraints: BoxConstraints(maxHeight: 24, maxWidth: 24), - ), - ], - ), - ), - - // 테이블 카드 - Container( - width: double.infinity, - decoration: BoxDecoration( - color: ShadcnTheme.card, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), - border: Border.all(color: ShadcnTheme.border), - boxShadow: ShadcnTheme.cardShadow, - ), - child: controller.isLoading && controller.companies.isEmpty - ? Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing8), - child: Center( - child: CircularProgressIndicator(), - ), - ) - : displayCompanies.isEmpty - ? Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing8), - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.business_outlined, - size: 48, - color: ShadcnTheme.muted, - ), - const SizedBox(height: ShadcnTheme.spacing4), - Text( - controller.searchKeyword.isNotEmpty - ? '검색 결과가 없습니다' - : '등록된 회사가 없습니다', - style: ShadcnTheme.bodyMuted, - ), - ], - ), - ), - ) - : Column( - crossAxisAlignment: CrossAxisAlignment.start, - 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), + return std_table.StandardDataRow( + index: index, + columns: [ + std_table.DataColumn(label: '번호', flex: 1), + std_table.DataColumn(label: '회사명', flex: 3), + std_table.DataColumn(label: '구분', flex: 2), + std_table.DataColumn(label: '유형', flex: 2), + std_table.DataColumn(label: '연락처', flex: 2), + std_table.DataColumn(label: '관리', flex: 2), + ], + cells: [ + // 번호 + Text( + '${index + 1}', + style: ShadcnTheme.bodySmall, ), - ), - child: Row( - children: [ - Expanded( - flex: 1, - child: Text( - '번호', - style: ShadcnTheme.bodyMedium, - ), - ), - Expanded( - flex: 3, - child: Text( - '회사명', - style: ShadcnTheme.bodyMedium, - ), - ), - Expanded( - flex: 2, - child: Text( - '구분', - style: ShadcnTheme.bodyMedium, - ), - ), - Expanded( - flex: 2, - child: Text( - '유형', - style: ShadcnTheme.bodyMedium, - ), - ), - Expanded( - flex: 2, - child: Text( - '연락처', - style: ShadcnTheme.bodyMedium, - ), - ), - Expanded( - flex: 2, - child: Text( - '관리', - style: ShadcnTheme.bodyMedium, - ), - ), - ], - ), - ), - - // 테이블 데이터 - ...displayCompanies.asMap().entries.map((entry) { - final int index = entry.key; - final companyData = entry.value; - final bool isBranch = companyData['isBranch'] as bool; - final Company company = - isBranch - ? _convertBranchToCompany(companyData['branch'] as Branch) - : companyData['company'] as Company; - final String? mainCompanyName = - companyData['mainCompanyName'] as String?; - - return Container( - padding: const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing4, - vertical: ShadcnTheme.spacing3, + // 회사명 + _buildCompanyNameText( + company, + isBranch, + mainCompanyName: mainCompanyName, ), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: ShadcnTheme.border), - ), + // 구분 + _buildCompanyTypeLabel(isBranch), + // 유형 + _buildCompanyTypeChips( + company.companyTypes, ), - child: Row( + // 연락처 + Text( + company.contactPhone ?? '-', + style: ShadcnTheme.bodySmall, + ), + // 관리 + Row( + mainAxisSize: MainAxisSize.min, children: [ - // 번호 - Expanded( - flex: 1, - child: Text( - '${index + 1}', - style: ShadcnTheme.bodySmall, + if (!isBranch && + company.branches != null && + company.branches!.isNotEmpty) ...[ + ShadcnButton( + text: '지점', + onPressed: + () => _showBranchDialog(company), + variant: + ShadcnButtonVariant.ghost, + size: ShadcnButtonSize.small, ), - ), - - // 회사명 - Expanded( - flex: 3, - child: _buildCompanyNameText( - company, - isBranch, - mainCompanyName: mainCompanyName, - ), - ), - - // 구분 - Expanded( - flex: 2, - child: _buildCompanyTypeLabel(isBranch), - ), - - // 유형 - Expanded( - flex: 2, - child: _buildCompanyTypeChips( - company.companyTypes, - ), - ), - - // 연락처 - Expanded( - flex: 2, - child: Text( - company.contactPhone ?? '-', - style: ShadcnTheme.bodySmall, - ), - ), - - // 관리 - Expanded( - flex: 2, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (!isBranch && - company.branches != null && - company.branches!.isNotEmpty) - ShadcnButton( - text: '지점보기', - onPressed: - () => _showBranchDialog(company), - variant: - ShadcnButtonVariant.secondary, - size: ShadcnButtonSize.small, - ), - if (!isBranch && - company.branches != null && - company.branches!.isNotEmpty) - const SizedBox( - width: ShadcnTheme.spacing2, - ), - ShadcnButton( - text: '수정', - onPressed: company.id != null - ? () { - if (isBranch) { - Navigator.pushNamed( - context, - '/company/edit', - arguments: { - 'companyId': companyData['companyId'], - 'isBranch': true, - 'mainCompanyName': mainCompanyName, - 'branchId': company.id, - }, - ).then((result) { - if (result == true) controller.refresh(); - }); - } else { - Navigator.pushNamed( - context, - '/company/edit', - arguments: { - 'companyId': company.id, - 'isBranch': false, - }, - ).then((result) { - if (result == true) controller.refresh(); - }); - } - } - : null, - variant: ShadcnButtonVariant.secondary, - size: ShadcnButtonSize.small, - ), - const SizedBox( - width: ShadcnTheme.spacing2, - ), - ShadcnButton( - text: '삭제', - onPressed: - (!isBranch && company.id != null) - ? () => - _deleteCompany(company.id!) - : null, - variant: - ShadcnButtonVariant.destructive, - size: ShadcnButtonSize.small, - ), - ], + const SizedBox( + width: ShadcnTheme.spacing1, ), + ], + std_table.StandardActionButtons( + onEdit: company.id != null + ? () { + if (isBranch) { + Navigator.pushNamed( + context, + '/company/edit', + arguments: { + 'companyId': companyData['companyId'], + 'isBranch': true, + 'mainCompanyName': mainCompanyName, + 'branchId': company.id, + }, + ).then((result) { + if (result == true) controller.refresh(); + }); + } else { + Navigator.pushNamed( + context, + '/company/edit', + arguments: { + 'companyId': company.id, + 'isBranch': false, + }, + ).then((result) { + if (result == true) controller.refresh(); + }); + } + } + : null, + onDelete: (!isBranch && company.id != null) + ? () => _deleteCompany(company.id!) + : null, ), ], ), - ); - }), - ], - ), - ), - - // 무한 스크롤 로딩 인디케이터 - if (controller.isLoading && controller.companies.isNotEmpty) - Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing4), - child: Center( - child: CircularProgressIndicator(), - ), + ], + ); + }), + ], ), - // 더 이상 로드할 데이터가 없을 때 메시지 - if (!controller.hasMore && controller.companies.isNotEmpty) - Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing4), - child: Center( - child: Text( - '모든 회사를 불러왔습니다', - style: ShadcnTheme.bodyMuted, - ), - ), - ), - ], + // 페이지네이션 (항상 표시) + pagination: Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, ), ); }, ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/company/controllers/company_list_controller.dart b/lib/screens/company/controllers/company_list_controller.dart index eedd4ea..a9a5473 100644 --- a/lib/screens/company/controllers/company_list_controller.dart +++ b/lib/screens/company/controllers/company_list_controller.dart @@ -21,7 +21,7 @@ class CompanyListController extends ChangeNotifier { // 페이지네이션 int _currentPage = 1; - final int _perPage = 20; + int _perPage = 20; bool _hasMore = true; // 필터 @@ -40,6 +40,12 @@ class CompanyListController extends ChangeNotifier { Future initialize() async { await loadData(isRefresh: true); } + + // 페이지 크기를 지정하여 초기화 + Future initializeWithPageSize(int pageSize) async { + _perPage = pageSize; + await loadData(isRefresh: true); + } // 데이터 로드 및 필터 적용 Future loadData({bool isRefresh = false}) async { diff --git a/lib/screens/equipment/equipment_list_redesign.dart b/lib/screens/equipment/equipment_list_redesign.dart index 65fefbe..ccc7d9d 100644 --- a/lib/screens/equipment/equipment_list_redesign.dart +++ b/lib/screens/equipment/equipment_list_redesign.dart @@ -2,6 +2,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:superport/screens/common/theme_shadcn.dart'; import 'package:superport/screens/common/components/shadcn_components.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; +import 'package:superport/screens/common/widgets/unified_search_bar.dart'; +import 'package:superport/screens/common/widgets/standard_action_bar.dart'; +import 'package:superport/screens/common/widgets/standard_data_table.dart' as std_table; +import 'package:superport/screens/common/widgets/standard_states.dart'; import 'package:superport/screens/equipment/controllers/equipment_list_controller.dart'; import 'package:superport/services/mock_data_service.dart'; import 'package:superport/models/equipment_unified_model.dart'; @@ -24,7 +29,6 @@ class _EquipmentListRedesignState extends State { bool _showDetailedColumns = true; final TextEditingController _searchController = TextEditingController(); final ScrollController _horizontalScrollController = ScrollController(); - final ScrollController _verticalScrollController = ScrollController(); String _selectedStatus = 'all'; // String _searchKeyword = ''; // Removed - unused field String _appliedSearchKeyword = ''; @@ -37,9 +41,6 @@ class _EquipmentListRedesignState extends State { _controller = EquipmentListController(dataService: MockDataService()); _setInitialFilter(); - // 무한 스크롤 리스너 추가 - _verticalScrollController.addListener(_onScroll); - // API 호출을 위해 Future로 변경 WidgetsBinding.instance.addPostFrameCallback((_) { _controller.loadData(); // 비동기 호출 @@ -50,7 +51,6 @@ class _EquipmentListRedesignState extends State { void dispose() { _searchController.dispose(); _horizontalScrollController.dispose(); - _verticalScrollController.dispose(); _controller.dispose(); super.dispose(); } @@ -149,16 +149,6 @@ class _EquipmentListRedesignState extends State { _controller.selectedEquipmentIds.contains('${e.id}:${e.status}')); } - /// 스크롤 이벤트 처리 (무한 스크롤) - void _onScroll() { - if (_verticalScrollController.position.pixels >= - _verticalScrollController.position.maxScrollExtent * 0.8) { - // 스크롤이 80% 이상 내려갔을 때 다음 페이지 로드 - if (!_controller.isLoading && _controller.hasMore) { - _controller.loadData(); - } - } - } /// 필터링된 장비 목록 반환 List _getFilteredEquipments() { @@ -433,42 +423,66 @@ class _EquipmentListRedesignState extends State { // 검색 입력 Expanded( flex: 2, - child: ShadcnInput( - controller: _searchController, - placeholder: '장비명, 제조사, 카테고리, 시리얼번호 등...', - prefixIcon: const Icon(Icons.search), + child: Container( + height: 40, + decoration: BoxDecoration( + color: ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all(color: Colors.black), + ), + child: TextField( + controller: _searchController, + onSubmitted: (_) => _onSearch(), + decoration: InputDecoration( + hintText: '장비명, 제조사, 카테고리, 시리얼번호 등...', + hintStyle: TextStyle(color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), fontSize: 14), + prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + style: ShadcnTheme.bodyMedium, + ), ), ), const SizedBox(width: 16), // 검색 버튼 - ShadcnButton( - text: '검색', - onPressed: _onSearch, - variant: ShadcnButtonVariant.primary, - textColor: Colors.white, - icon: const Icon(Icons.search, size: 16), + SizedBox( + height: 40, + child: ShadcnButton( + text: '검색', + onPressed: _onSearch, + variant: ShadcnButtonVariant.primary, + textColor: Colors.white, + icon: const Icon(Icons.search, size: 16), + ), ), const SizedBox(width: 16), // 상태 필터 드롭다운 Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( - border: Border.all(color: ShadcnTheme.border), + color: ShadcnTheme.card, + border: Border.all(color: Colors.black), borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), ), - child: DropdownButton( - value: _selectedStatus, - onChanged: (value) => _onStatusFilterChanged(value!), - underline: const SizedBox.shrink(), - items: const [ - DropdownMenuItem(value: 'all', child: Text('전체')), - DropdownMenuItem(value: 'in', child: Text('입고')), - DropdownMenuItem(value: 'out', child: Text('출고')), - DropdownMenuItem(value: 'rent', child: Text('대여')), - ], + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _selectedStatus, + onChanged: (value) => _onStatusFilterChanged(value!), + style: TextStyle(fontSize: 14, color: ShadcnTheme.foreground), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: const [ + DropdownMenuItem(value: 'all', child: Text('전체')), + DropdownMenuItem(value: 'in', child: Text('입고')), + DropdownMenuItem(value: 'out', child: Text('출고')), + DropdownMenuItem(value: 'rent', child: Text('대여')), + ], + ), ), ), ], @@ -708,26 +722,26 @@ class _EquipmentListRedesignState extends State { double _calculateTableWidth(List pagedEquipments) { double totalWidth = 0; - // 기본 컬럼들 + // 기본 컬럼들 (너비 최적화) totalWidth += 40; // 체크박스 - totalWidth += 60; // 번호 - totalWidth += 150; // 제조사 - totalWidth += 150; // 장비명 - totalWidth += 150; // 카테고리 - totalWidth += 60; // 수량 - totalWidth += 80; // 상태 - totalWidth += 100; // 날짜 - totalWidth += 100; // 관리 + totalWidth += 50; // 번호 + totalWidth += 120; // 제조사 + totalWidth += 120; // 장비명 + totalWidth += 100; // 카테고리 + totalWidth += 50; // 수량 + totalWidth += 70; // 상태 + totalWidth += 80; // 날짜 + totalWidth += 90; // 관리 // 상세 컬럼들 (조건부) if (_showDetailedColumns) { - totalWidth += 150; // 시리얼번호 - totalWidth += 150; // 바코드 + totalWidth += 120; // 시리얼번호 + totalWidth += 120; // 바코드 // 출고 정보 (조건부) if (pagedEquipments.any((e) => e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent)) { - totalWidth += 150; // 회사 - totalWidth += 100; // 담당자 + totalWidth += 120; // 회사 + totalWidth += 80; // 담당자 } } @@ -761,7 +775,7 @@ class _EquipmentListRedesignState extends State { minWidth: MediaQuery.of(context).size.width - 48, // padding 고려 ), decoration: BoxDecoration( - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), ), child: @@ -791,12 +805,12 @@ class _EquipmentListRedesignState extends State { Container( padding: const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing4, - vertical: ShadcnTheme.spacing3, + vertical: 10, ), decoration: BoxDecoration( color: ShadcnTheme.muted.withValues(alpha: 0.3), border: Border( - bottom: BorderSide(color: ShadcnTheme.border), + bottom: BorderSide(color: Colors.black), ), ), child: Row( @@ -811,65 +825,65 @@ class _EquipmentListRedesignState extends State { ), // 번호 SizedBox( - width: 60, - child: Text('번호', style: ShadcnTheme.bodyMedium), + width: 50, + child: Text('번호', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 제조사 - Expanded( - flex: 2, - child: Text('제조사', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 120, + child: Text('제조사', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 장비명 - Expanded( - flex: 2, - child: Text('장비명', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 120, + child: Text('장비명', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 카테고리 - Expanded( - flex: 2, - child: Text('카테고리', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 100, + child: Text('카테고리', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 상세 정보 (조건부) if (_showDetailedColumns) ...[ - Expanded( - flex: 2, - child: Text('시리얼번호', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 120, + child: Text('시리얼번호', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), - Expanded( - flex: 2, - child: Text('바코드', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 120, + child: Text('바코드', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), ], // 수량 SizedBox( - width: 60, - child: Text('수량', style: ShadcnTheme.bodyMedium), + width: 50, + child: Text('수량', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 상태 - Expanded( - flex: 1, - child: Text('상태', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 70, + child: Text('상태', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 날짜 - Expanded( - flex: 1, - child: Text('날짜', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 80, + child: Text('날짜', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 출고 정보 (조건부 - 테이블에 출고/대여 항목이 있을 때만) if (_showDetailedColumns && pagedEquipments.any((e) => e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent)) ...[ - Expanded( - flex: 2, - child: Text('회사', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 120, + child: Text('회사', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), - Expanded( - flex: 1, - child: Text('담당자', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 80, + child: Text('담당자', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), ], // 관리 - Expanded( - flex: 1, - child: Text('관리', style: ShadcnTheme.bodyMedium), + SizedBox( + width: 90, + child: Text('관리', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), ], ), @@ -883,11 +897,11 @@ class _EquipmentListRedesignState extends State { return Container( padding: const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing4, - vertical: ShadcnTheme.spacing3, + vertical: 4, ), decoration: BoxDecoration( border: Border( - bottom: BorderSide(color: ShadcnTheme.border), + bottom: BorderSide(color: Colors.black), ), ), child: Row( @@ -902,15 +916,15 @@ class _EquipmentListRedesignState extends State { ), // 번호 SizedBox( - width: 60, + width: 50, child: Text( '${startIndex + index + 1}', style: ShadcnTheme.bodySmall, ), ), // 제조사 - Expanded( - flex: 2, + SizedBox( + width: 120, child: Text( equipment.equipment.manufacturer, style: ShadcnTheme.bodySmall, @@ -918,8 +932,8 @@ class _EquipmentListRedesignState extends State { ), ), // 장비명 - Expanded( - flex: 2, + SizedBox( + width: 120, child: Text( equipment.equipment.name, style: ShadcnTheme.bodySmall, @@ -927,22 +941,22 @@ class _EquipmentListRedesignState extends State { ), ), // 카테고리 - Expanded( - flex: 2, + SizedBox( + width: 100, child: _buildCategoryWithTooltip(equipment), ), // 상세 정보 (조건부) if (_showDetailedColumns) ...[ - Expanded( - flex: 2, + SizedBox( + width: 120, child: Text( equipment.equipment.serialNumber ?? '-', style: ShadcnTheme.bodySmall, overflow: TextOverflow.ellipsis, ), ), - Expanded( - flex: 2, + SizedBox( + width: 120, child: Text( equipment.equipment.barcode ?? '-', style: ShadcnTheme.bodySmall, @@ -952,15 +966,15 @@ class _EquipmentListRedesignState extends State { ], // 수량 SizedBox( - width: 60, + width: 50, child: Text( '${equipment.equipment.quantity}', style: ShadcnTheme.bodySmall, ), ), // 상태 - Expanded( - flex: 1, + SizedBox( + width: 70, child: ShadcnBadge( text: _getStatusDisplayText( equipment.status, @@ -972,8 +986,8 @@ class _EquipmentListRedesignState extends State { ), ), // 날짜 - Expanded( - flex: 1, + SizedBox( + width: 80, child: Text( equipment.date.toString().substring(0, 10), style: ShadcnTheme.bodySmall, @@ -981,8 +995,8 @@ class _EquipmentListRedesignState extends State { ), // 출고 정보 (조건부) if (_showDetailedColumns && pagedEquipments.any((e) => e.status == EquipmentStatus.out || e.status == EquipmentStatus.rent)) ...[ - Expanded( - flex: 2, + SizedBox( + width: 120, child: Text( equipment.status == EquipmentStatus.out || equipment.status == EquipmentStatus.rent ? _controller.getOutEquipmentInfo(equipment.id!, 'company') @@ -991,8 +1005,8 @@ class _EquipmentListRedesignState extends State { overflow: TextOverflow.ellipsis, ), ), - Expanded( - flex: 1, + SizedBox( + width: 80, child: Text( equipment.status == EquipmentStatus.out || equipment.status == EquipmentStatus.rent ? _controller.getOutEquipmentInfo(equipment.id!, 'manager') @@ -1003,8 +1017,8 @@ class _EquipmentListRedesignState extends State { ), ], // 관리 버튼 - Expanded( - flex: 1, + SizedBox( + width: 90, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1057,48 +1071,17 @@ class _EquipmentListRedesignState extends State { ), ), - // 페이지네이션 + // 페이지네이션 컴포넌트 if (totalCount > _pageSize) - Container( - padding: const EdgeInsets.symmetric( - vertical: ShadcnTheme.spacing4, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // 이전 페이지 버튼 - ShadcnButton( - text: '이전', - onPressed: - _currentPage > 1 - ? () => setState(() => _currentPage--) - : null, - variant: ShadcnButtonVariant.secondary, - size: ShadcnButtonSize.small, - ), - - const SizedBox(width: ShadcnTheme.spacing4), - - // 페이지 정보 - Text( - '$_currentPage / ${((totalCount - 1) ~/ _pageSize) + 1}', - style: ShadcnTheme.bodyMedium, - ), - - const SizedBox(width: ShadcnTheme.spacing4), - - // 다음 페이지 버튼 - ShadcnButton( - text: '다음', - onPressed: - _currentPage < ((totalCount - 1) ~/ _pageSize) + 1 - ? () => setState(() => _currentPage++) - : null, - variant: ShadcnButtonVariant.secondary, - size: ShadcnButtonSize.small, - ), - ], - ), + Pagination( + totalCount: totalCount, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, ), ], ), @@ -1108,11 +1091,11 @@ class _EquipmentListRedesignState extends State { /// 상태 표시 텍스트 반환 String _getStatusDisplayText(String status) { switch (status) { - case EquipmentStatus.in_: + case 'I': // EquipmentStatus.in_ return '입고'; - case EquipmentStatus.out: + case 'O': // EquipmentStatus.out return '출고'; - case EquipmentStatus.rent: + case 'T': // EquipmentStatus.rent return '대여'; default: return '알수없음'; @@ -1122,11 +1105,11 @@ class _EquipmentListRedesignState extends State { /// 상태에 따른 배지 변형 반환 ShadcnBadgeVariant _getStatusBadgeVariant(String status) { switch (status) { - case EquipmentStatus.in_: + case 'I': // EquipmentStatus.in_ return ShadcnBadgeVariant.success; - case EquipmentStatus.out: + case 'O': // EquipmentStatus.out return ShadcnBadgeVariant.destructive; - case EquipmentStatus.rent: + case 'T': // EquipmentStatus.rent return ShadcnBadgeVariant.warning; default: return ShadcnBadgeVariant.secondary; diff --git a/lib/screens/license/license_list_redesign.dart b/lib/screens/license/license_list_redesign.dart index bd2984d..c1d3626 100644 --- a/lib/screens/license/license_list_redesign.dart +++ b/lib/screens/license/license_list_redesign.dart @@ -3,6 +3,7 @@ import 'package:provider/provider.dart'; import 'package:superport/models/license_model.dart'; import 'package:superport/screens/common/theme_shadcn.dart'; import 'package:superport/screens/common/components/shadcn_components.dart'; +import 'package:superport/screens/common/widgets/pagination.dart'; import 'package:superport/screens/license/controllers/license_list_controller.dart'; import 'package:superport/utils/constants.dart'; import 'package:superport/services/mock_data_service.dart'; @@ -22,7 +23,8 @@ class _LicenseListRedesignState extends State { final MockDataService _dataService = MockDataService(); final TextEditingController _searchController = TextEditingController(); final ScrollController _horizontalScrollController = ScrollController(); - final ScrollController _verticalScrollController = ScrollController(); + int _currentPage = 1; + final int _pageSize = 10; // 날짜 포맷터 final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); @@ -52,36 +54,29 @@ class _LicenseListRedesignState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { _controller.loadData(); }); - - // 무한 스크롤 리스너 추가 - _verticalScrollController.addListener(_onScroll); } @override void dispose() { // 리스너 제거 - _verticalScrollController.removeListener(_onScroll); _controller.removeListener(_handleControllerUpdate); // 컨트롤러 dispose _searchController.dispose(); _horizontalScrollController.dispose(); - _verticalScrollController.dispose(); _controller.dispose(); super.dispose(); } - /// 스크롤 이벤트 처리 (무한 스크롤) - void _onScroll() { - if (!_verticalScrollController.hasClients) return; - - if (_verticalScrollController.position.pixels >= - _verticalScrollController.position.maxScrollExtent * 0.8) { - // 스크롤이 80% 이상 내려갔을 때 다음 페이지 로드 - if (!_controller.isLoading && _controller.hasMore) { - _controller.loadNextPage(); - } - } + /// 페이지네이션용 라이선스 가져오기 + List _getPagedLicenses() { + final licenses = _controller.licenses; + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = startIndex + _pageSize; + return licenses.sublist( + startIndex, + endIndex > licenses.length ? licenses.length : endIndex, + ); } /// 검색 실행 @@ -302,26 +297,32 @@ class _LicenseListRedesignState extends State { /// 통계 카드 위젯 Widget _buildStatCard(String title, String value, IconData icon, Color color) { return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: ShadcnTheme.card, borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Row( - children: [ - Icon(icon, size: 20, color: color), - const SizedBox(width: 8), - Text(title, style: ShadcnTheme.bodySmall), - ], + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: color), ), - const SizedBox(height: 12), + const SizedBox(width: 12), + Text(title, style: ShadcnTheme.bodySmall), + const Spacer(), Text( value, - style: ShadcnTheme.headingH3.copyWith(color: color), + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color, + ), ), ], ), @@ -340,60 +341,84 @@ class _LicenseListRedesignState extends State { // 검색 입력 Expanded( flex: 2, - child: ShadcnInput( - controller: _searchController, - placeholder: '제품명, 라이선스 키, 벤더명, 회사명 검색...', - prefixIcon: const Icon(Icons.search), + child: Container( + height: 40, + decoration: BoxDecoration( + color: ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + border: Border.all(color: Colors.black), + ), + child: TextField( + controller: _searchController, + onSubmitted: (_) => _onSearch(), + decoration: InputDecoration( + hintText: '제품명, 라이선스 키, 벤더명, 회사명 검색...', + hintStyle: TextStyle(color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), fontSize: 14), + prefixIcon: Icon(Icons.search, color: ShadcnTheme.muted, size: 20), + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + style: ShadcnTheme.bodyMedium, + ), ), ), const SizedBox(width: 16), // 검색 버튼 - ShadcnButton( - text: '검색', - onPressed: _onSearch, - variant: ShadcnButtonVariant.primary, - textColor: Colors.white, - icon: const Icon(Icons.search, size: 16), + SizedBox( + height: 40, + child: ShadcnButton( + text: '검색', + onPressed: _onSearch, + variant: ShadcnButtonVariant.primary, + textColor: Colors.white, + icon: const Icon(Icons.search, size: 16), + ), ), const SizedBox(width: 16), // 상태 필터 드롭다운 Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( - border: Border.all(color: ShadcnTheme.border), + color: ShadcnTheme.card, + border: Border.all(color: Colors.black), borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), ), - child: DropdownButton( - value: _controller.statusFilter, - onChanged: (value) { - if (value != null) { - _controller.changeStatusFilter(value); - } - }, - underline: const SizedBox.shrink(), - items: const [ - DropdownMenuItem( - value: LicenseStatusFilter.all, - child: Text('전체'), - ), - DropdownMenuItem( - value: LicenseStatusFilter.active, - child: Text('활성'), - ), - DropdownMenuItem( - value: LicenseStatusFilter.inactive, - child: Text('비활성'), - ), - DropdownMenuItem( - value: LicenseStatusFilter.expiringSoon, - child: Text('만료예정'), - ), - DropdownMenuItem( - value: LicenseStatusFilter.expired, - child: Text('만료됨'), - ), - ], + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: _controller.statusFilter, + onChanged: (value) { + if (value != null) { + _controller.changeStatusFilter(value); + } + }, + style: TextStyle(fontSize: 14, color: ShadcnTheme.foreground), + icon: const Icon(Icons.arrow_drop_down, size: 20), + items: const [ + DropdownMenuItem( + value: LicenseStatusFilter.all, + child: Text('전체'), + ), + DropdownMenuItem( + value: LicenseStatusFilter.active, + child: Text('활성'), + ), + DropdownMenuItem( + value: LicenseStatusFilter.inactive, + child: Text('비활성'), + ), + DropdownMenuItem( + value: LicenseStatusFilter.expiringSoon, + child: Text('만료예정'), + ), + DropdownMenuItem( + value: LicenseStatusFilter.expired, + child: Text('만료됨'), + ), + ], + ), ), ), ], @@ -544,122 +569,38 @@ class _LicenseListRedesignState extends State { ); } + final pagedLicenses = _getPagedLicenses(); + return SingleChildScrollView( padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - controller: _horizontalScrollController, - child: Container( - constraints: BoxConstraints( - minWidth: MediaQuery.of(context).size.width - 48, - ), - 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), - ), - ), - child: Row( - children: [ - // 체크박스 - SizedBox( - width: 40, - child: Checkbox( - value: _controller.isAllSelected, - onChanged: (value) => _controller.selectAll(value), - tristate: false, - ), - ), - // 번호 - const SizedBox( - width: 60, - child: Text('번호', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 제품명 - const SizedBox( - width: 200, - child: Text('제품명', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 라이선스 키 - const SizedBox( - width: 150, - child: Text('라이선스 키', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 벤더 - const SizedBox( - width: 120, - child: Text('벤더', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 회사명 - const SizedBox( - width: 150, - child: Text('회사명', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 할당 사용자 - const SizedBox( - width: 100, - child: Text('할당 사용자', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 상태 - const SizedBox( - width: 80, - child: Text('상태', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 구매일 - const SizedBox( - width: 100, - child: Text('구매일', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 만료일 - const SizedBox( - width: 100, - child: Text('만료일', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 남은 일수 - const SizedBox( - width: 80, - child: Text('남은 일수', style: TextStyle(fontWeight: FontWeight.bold)), - ), - // 관리 - const SizedBox( - width: 100, - child: Text('관리', style: TextStyle(fontWeight: FontWeight.bold)), - ), - ], - ), + child: Column( + children: [ + // 테이블 컨테이너 (가로 스크롤 지원) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: _horizontalScrollController, + child: Container( + constraints: BoxConstraints( + minWidth: MediaQuery.of(context).size.width - 48, // padding 고려 ), - - // 테이블 데이터 - SizedBox( - height: 400, + decoration: BoxDecoration( + border: Border.all(color: Colors.black), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + ), + child: SizedBox( width: 1360, // 모든 컬럼 너비의 합 - child: ListView.builder( - controller: _verticalScrollController, - itemCount: licenses.length, - itemBuilder: (context, index) { - final license = licenses[index]; - final daysRemaining = _controller.getDaysUntilExpiry(license); - - return Container( + child: Column( + children: [ + // 테이블 헤더 + Container( padding: const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing4, - vertical: ShadcnTheme.spacing3, + vertical: 10, ), decoration: BoxDecoration( + color: ShadcnTheme.muted.withValues(alpha: 0.3), border: Border( - bottom: BorderSide(color: ShadcnTheme.border), + bottom: BorderSide(color: Colors.black), ), ), child: Row( @@ -668,168 +609,249 @@ class _LicenseListRedesignState extends State { SizedBox( width: 40, child: Checkbox( - value: license.id != null && _controller.selectedLicenseIds.contains(license.id), - onChanged: license.id != null - ? (value) => _controller.selectLicense(license.id, value) - : null, + value: _controller.isAllSelected, + onChanged: (value) => _controller.selectAll(value), tristate: false, ), ), // 번호 - SizedBox( + const SizedBox( width: 60, - child: Text('${index + 1}', style: ShadcnTheme.bodySmall), + child: Text('번호', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 제품명 - SizedBox( + const SizedBox( width: 200, - child: Text( - license.productName ?? '-', - style: ShadcnTheme.bodyMedium, - overflow: TextOverflow.ellipsis, - ), + child: Text('제품명', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 라이선스 키 - SizedBox( + const SizedBox( width: 150, - child: Text( - license.licenseKey, - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), + child: Text('라이선스 키', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 벤더 - SizedBox( + const SizedBox( width: 120, - child: Text( - license.vendor ?? '-', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), + child: Text('벤더', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 회사명 - SizedBox( + const SizedBox( width: 150, - child: Text( - license.companyName ?? '-', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), + child: Text('회사명', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 할당 사용자 - SizedBox( + const SizedBox( width: 100, - child: Text( - license.assignedUserName ?? '-', - style: ShadcnTheme.bodySmall, - overflow: TextOverflow.ellipsis, - ), + child: Text('할당 사용자', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 상태 - SizedBox( + const SizedBox( width: 80, - child: _buildStatusBadge(license, daysRemaining), + child: Text('상태', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 구매일 - SizedBox( + const SizedBox( width: 100, - child: Text( - license.purchaseDate != null - ? _dateFormat.format(license.purchaseDate!) - : '-', - style: ShadcnTheme.bodySmall, - ), + child: Text('구매일', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 만료일 - SizedBox( + const SizedBox( width: 100, - child: Text( - license.expiryDate != null - ? _dateFormat.format(license.expiryDate!) - : '-', - style: ShadcnTheme.bodySmall, - ), + child: Text('만료일', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 남은 일수 - SizedBox( + const SizedBox( width: 80, - child: Text( - _getDaysRemainingText(daysRemaining), - style: TextStyle( - fontSize: 12, - color: _getDaysRemainingColor(daysRemaining), - fontWeight: daysRemaining != null && daysRemaining <= 30 - ? FontWeight.bold - : FontWeight.normal, - ), - ), + child: Text('남은 일수', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), // 관리 - SizedBox( + const SizedBox( width: 100, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - padding: EdgeInsets.zero, - icon: Icon( - Icons.edit, - size: 16, - color: ShadcnTheme.primary, - ), - onPressed: license.id != null - ? () => _navigateToEdit(license.id!) - : null, - tooltip: '수정', - ), - IconButton( - constraints: const BoxConstraints( - minWidth: 32, - minHeight: 32, - ), - padding: EdgeInsets.zero, - icon: Icon( - Icons.delete, - size: 16, - color: ShadcnTheme.destructive, - ), - onPressed: license.id != null - ? () => _showDeleteDialog(license.id!) - : null, - tooltip: '삭제', - ), - ], - ), + child: Text('관리', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), ), ], ), - ); - }, + ), + + // 테이블 데이터 + ...pagedLicenses.asMap().entries.map((entry) { + final displayIndex = entry.key; + final license = entry.value; + final index = (_currentPage - 1) * _pageSize + displayIndex; + final daysRemaining = _controller.getDaysUntilExpiry(license); + + return Container( + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing4, + vertical: 4, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.black), + ), + ), + child: Row( + children: [ + // 체크박스 + SizedBox( + width: 40, + child: Checkbox( + value: license.id != null && _controller.selectedLicenseIds.contains(license.id), + onChanged: license.id != null + ? (value) => _controller.selectLicense(license.id, value) + : null, + tristate: false, + ), + ), + // 번호 + SizedBox( + width: 60, + child: Text('${index + 1}', style: ShadcnTheme.bodySmall), + ), + // 제품명 + SizedBox( + width: 200, + child: Text( + license.productName ?? '-', + style: ShadcnTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + // 라이선스 키 + SizedBox( + width: 150, + child: Text( + license.licenseKey, + style: ShadcnTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + // 벤더 + SizedBox( + width: 120, + child: Text( + license.vendor ?? '-', + style: ShadcnTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + // 회사명 + SizedBox( + width: 150, + child: Text( + license.companyName ?? '-', + style: ShadcnTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + // 할당 사용자 + SizedBox( + width: 100, + child: Text( + license.assignedUserName ?? '-', + style: ShadcnTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ), + // 상태 + SizedBox( + width: 80, + child: _buildStatusBadge(license, daysRemaining), + ), + // 구매일 + SizedBox( + width: 100, + child: Text( + license.purchaseDate != null + ? _dateFormat.format(license.purchaseDate!) + : '-', + style: ShadcnTheme.bodySmall, + ), + ), + // 만료일 + SizedBox( + width: 100, + child: Text( + license.expiryDate != null + ? _dateFormat.format(license.expiryDate!) + : '-', + style: ShadcnTheme.bodySmall, + ), + ), + // 남은 일수 + SizedBox( + width: 80, + child: Text( + _getDaysRemainingText(daysRemaining), + style: TextStyle( + fontSize: 12, + color: _getDaysRemainingColor(daysRemaining), + fontWeight: daysRemaining != null && daysRemaining <= 30 + ? FontWeight.bold + : FontWeight.normal, + ), + ), + ), + // 관리 + SizedBox( + width: 100, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: IconButton( + constraints: const BoxConstraints( + minWidth: 30, + minHeight: 30, + ), + padding: const EdgeInsets.all(4), + icon: const Icon(Icons.edit_outlined, size: 16), + onPressed: license.id != null + ? () => _navigateToEdit(license.id!) + : null, + tooltip: '수정', + ), + ), + Flexible( + child: IconButton( + constraints: const BoxConstraints( + minWidth: 30, + minHeight: 30, + ), + padding: const EdgeInsets.all(4), + icon: const Icon(Icons.delete_outline, size: 16), + onPressed: license.id != null + ? () => _showDeleteDialog(license.id!) + : null, + tooltip: '삭제', + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ], ), ), - - // 더 보기 버튼 - if (_controller.hasMore) - Container( - padding: const EdgeInsets.all(16), - child: Center( - child: _controller.isLoading - ? const CircularProgressIndicator() - : ShadcnButton( - text: '더 보기', - onPressed: () => _controller.loadNextPage(), - variant: ShadcnButtonVariant.secondary, - icon: const Icon(Icons.expand_more, size: 16), - ), - ), - ), - ], + ), ), - ), + + // 페이지네이션 컴포넌트 (항상 표시) + if (licenses.length > _pageSize) + Pagination( + totalCount: licenses.length, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, + ), + ], ), ); } diff --git a/lib/screens/login/widgets/login_view_redesign.dart b/lib/screens/login/widgets/login_view_redesign.dart index 66cec58..516bbb8 100644 --- a/lib/screens/login/widgets/login_view_redesign.dart +++ b/lib/screens/login/widgets/login_view_redesign.dart @@ -296,7 +296,7 @@ class _LoginViewRedesignState extends State decoration: BoxDecoration( color: ShadcnTheme.muted, borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), ), child: Row( children: [ diff --git a/lib/screens/overview/overview_screen_redesign.dart b/lib/screens/overview/overview_screen_redesign.dart index 35222dd..c3a2b57 100644 --- a/lib/screens/overview/overview_screen_redesign.dart +++ b/lib/screens/overview/overview_screen_redesign.dart @@ -621,7 +621,7 @@ class _OverviewScreenRedesignState extends State { width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), borderRadius: BorderRadius.circular(6), ), child: Row( diff --git a/lib/screens/user/user_list_redesign.dart b/lib/screens/user/user_list_redesign.dart index 29c642e..a4057dd 100644 --- a/lib/screens/user/user_list_redesign.dart +++ b/lib/screens/user/user_list_redesign.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.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/widgets/pagination.dart'; import 'package:superport/screens/user/controllers/user_list_controller.dart'; import 'package:superport/utils/constants.dart'; import 'package:superport/services/mock_data_service.dart'; @@ -19,8 +20,9 @@ class UserListRedesign extends StatefulWidget { class _UserListRedesignState extends State { final MockDataService _dataService = MockDataService(); - final ScrollController _scrollController = ScrollController(); final TextEditingController _searchController = TextEditingController(); + int _currentPage = 1; + final int _pageSize = 10; @override void initState() { @@ -31,9 +33,6 @@ class _UserListRedesignState extends State { context.read().loadUsers(); }); - // 무한 스크롤 설정 - _scrollController.addListener(_onScroll); - // 검색 디바운싱 _searchController.addListener(() { _onSearchChanged(_searchController.text); @@ -42,23 +41,19 @@ class _UserListRedesignState extends State { @override void dispose() { - _scrollController.dispose(); _searchController.dispose(); super.dispose(); } - /// 스크롤 이벤트 처리 - void _onScroll() { - if (_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 200) { - context.read().loadMore(); - } - } /// 검색어 변경 처리 (디바운싱) Timer? _debounce; void _onSearchChanged(String query) { if (_debounce?.isActive ?? false) _debounce!.cancel(); _debounce = Timer(const Duration(milliseconds: 300), () { + setState(() { + _currentPage = 1; + }); context.read().setSearchQuery(query); }); } @@ -230,8 +225,16 @@ class _UserListRedesignState extends State { ); } + // 페이지네이션을 위한 데이터 처리 + final int totalUsers = controller.users.length; + final int startIndex = (_currentPage - 1) * _pageSize; + final int endIndex = startIndex + _pageSize; + final List pagedUsers = controller.users.sublist( + startIndex, + endIndex > totalUsers ? totalUsers : endIndex, + ); + return SingleChildScrollView( - controller: _scrollController, padding: const EdgeInsets.all(ShadcnTheme.spacing6), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -241,7 +244,7 @@ class _UserListRedesignState extends State { elevation: 0, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - side: BorderSide(color: ShadcnTheme.border), + side: BorderSide(color: Colors.black), ), child: Padding( padding: const EdgeInsets.all(ShadcnTheme.spacing4), @@ -381,7 +384,7 @@ class _UserListRedesignState extends State { Container( width: double.infinity, decoration: BoxDecoration( - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), ), child: Column( @@ -396,7 +399,7 @@ class _UserListRedesignState extends State { decoration: BoxDecoration( color: ShadcnTheme.muted.withValues(alpha: 0.3), border: Border( - bottom: BorderSide(color: ShadcnTheme.border), + bottom: BorderSide(color: Colors.black), ), ), child: Row( @@ -429,15 +432,15 @@ class _UserListRedesignState extends State { ), ) else - ...controller.users.asMap().entries.map((entry) { - final int index = entry.key; + ...pagedUsers.asMap().entries.map((entry) { + final int index = startIndex + entry.key; final User user = entry.value; return Container( padding: const EdgeInsets.all(ShadcnTheme.spacing4), decoration: BoxDecoration( border: Border( - bottom: BorderSide(color: ShadcnTheme.border), + bottom: BorderSide(color: Colors.black), ), color: index % 2 == 0 ? null : ShadcnTheme.muted.withValues(alpha: 0.1), ), @@ -594,25 +597,17 @@ class _UserListRedesignState extends State { ), ), - // 무한 스크롤 로딩 인디케이터 - if (controller.isLoadingMore) - Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing4), - child: const Center( - child: CircularProgressIndicator(), - ), - ), - - // 더 이상 데이터가 없을 때 - if (!controller.hasMoreData && controller.users.isNotEmpty) - Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing4), - child: Center( - child: Text( - '모든 사용자를 불러왔습니다', - style: ShadcnTheme.bodyMuted, - ), - ), + // 페이지네이션 컴포넌트 + if (totalUsers > _pageSize) + Pagination( + totalCount: totalUsers, + currentPage: _currentPage, + pageSize: _pageSize, + onPageChanged: (page) { + setState(() { + _currentPage = page; + }); + }, ), ], ), diff --git a/lib/screens/warehouse_location/warehouse_location_list_redesign.dart b/lib/screens/warehouse_location/warehouse_location_list_redesign.dart index 28d41b0..dbf7c50 100644 --- a/lib/screens/warehouse_location/warehouse_location_list_redesign.dart +++ b/lib/screens/warehouse_location/warehouse_location_list_redesign.dart @@ -174,7 +174,7 @@ class _WarehouseLocationListRedesignState Container( width: double.infinity, decoration: BoxDecoration( - border: Border.all(color: ShadcnTheme.border), + border: Border.all(color: Colors.black), borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), ), child: Column( @@ -184,12 +184,12 @@ class _WarehouseLocationListRedesignState Container( padding: const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing4, - vertical: ShadcnTheme.spacing3, + vertical: 10, ), decoration: BoxDecoration( color: ShadcnTheme.muted.withValues(alpha: 0.3), border: Border( - bottom: BorderSide(color: ShadcnTheme.border), + bottom: BorderSide(color: Colors.black), ), ), child: Row( @@ -250,10 +250,13 @@ class _WarehouseLocationListRedesignState final WarehouseLocation location = entry.value; return Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing4), + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing4, + vertical: 4, + ), decoration: BoxDecoration( border: Border( - bottom: BorderSide(color: ShadcnTheme.border), + bottom: BorderSide(color: Colors.black), ), ), child: Row( @@ -326,11 +329,8 @@ class _WarehouseLocationListRedesignState size: 16, color: ShadcnTheme.destructive, ), - onPressed: - location.id != null - ? () => - _showDeleteDialog(location.id!) - : null, + onPressed: () => + _showDeleteDialog(location.id), tooltip: '삭제', ), ), @@ -344,50 +344,50 @@ class _WarehouseLocationListRedesignState ], ), ), - // 페이지네이션 - if (totalCount > _pageSize) ...[ - const SizedBox(height: ShadcnTheme.spacing6), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ShadcnButton( - text: '이전', - onPressed: - _currentPage > 1 - ? () { - setState(() { - _currentPage--; - }); - } - : null, - variant: ShadcnButtonVariant.secondary, - size: ShadcnButtonSize.small, - ), - const SizedBox(width: ShadcnTheme.spacing2), - Text( - '$_currentPage / ${(totalCount / _pageSize).ceil()}', - style: ShadcnTheme.bodyMuted, - ), - const SizedBox(width: ShadcnTheme.spacing2), - ShadcnButton( - text: '다음', - onPressed: - _currentPage < (totalCount / _pageSize).ceil() - ? () { - setState(() { - _currentPage++; - }); - } - : null, - variant: ShadcnButtonVariant.secondary, - size: ShadcnButtonSize.small, - ), - ], - ), - ], + // 페이지네이션 (항상 표시) + const SizedBox(height: ShadcnTheme.spacing6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ShadcnButton( + text: '이전', + onPressed: + _currentPage > 1 + ? () { + setState(() { + _currentPage--; + }); + } + : null, + variant: ShadcnButtonVariant.secondary, + size: ShadcnButtonSize.small, + ), + const SizedBox(width: ShadcnTheme.spacing2), + Text( + totalCount > 0 + ? '$_currentPage / ${(totalCount / _pageSize).ceil()}' + : '1 / 1', + style: ShadcnTheme.bodyMuted, + ), + const SizedBox(width: ShadcnTheme.spacing2), + ShadcnButton( + text: '다음', + onPressed: + _currentPage < (totalCount / _pageSize).ceil() && totalCount > _pageSize + ? () { + setState(() { + _currentPage++; + }); + } + : null, + variant: ShadcnButtonVariant.secondary, + size: ShadcnButtonSize.small, + ), + ], + ), ], ), - ); + ); }, ), );