결재 비활성 안내 개선 및 테이블 기능 보강

This commit is contained in:
JiWoong Sul
2025-09-29 15:49:06 +09:00
parent fef7108479
commit 98724762ec
18 changed files with 1134 additions and 297 deletions

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
/// 기능이 비활성화된 상태에서 안내 메시지를 보여주는 공통 플레이스홀더.
///
/// - 기능 플래그나 서버 준비 상태 등으로 화면을 대신할 때 사용한다.
/// - 사용자가 다음 액션을 쉽게 파악할 수 있도록 제목/설명을 함께 제공한다.
class FeatureDisabledPlaceholder extends StatelessWidget {
const FeatureDisabledPlaceholder({
super.key,
required this.title,
required this.description,
this.icon,
this.hints = const <Widget>[],
});
final String title;
final String description;
final IconData? icon;
final List<Widget> hints;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: ShadCard(
title: Row(
children: [
Icon(
icon ?? lucide.LucideIcons.info,
size: 18,
color: theme.colorScheme.mutedForeground,
),
const SizedBox(width: 10),
Text(title, style: theme.textTheme.h3),
],
),
description: Text(description, style: theme.textTheme.muted),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hints.isNotEmpty) ...[
for (final hint in hints)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: hint,
),
] else
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Text(
'기능이 활성화되면 이 영역에서 실제 데이터를 확인할 수 있습니다.',
style: theme.textTheme.small,
),
),
],
),
),
),
);
}
}

View File

@@ -1,8 +1,37 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide;
import 'package:shadcn_ui/shadcn_ui.dart';
/// 테이블 정렬 상태 정보를 보관하는 모델.
class SuperportTableSortState {
const SuperportTableSortState({
required this.columnIndex,
required this.ascending,
});
final int columnIndex;
final bool ascending;
}
/// 테이블 페이지네이션 정보를 보관하는 모델.
class SuperportTablePagination {
const SuperportTablePagination({
required this.currentPage,
required this.totalPages,
required this.totalItems,
required this.pageSize,
this.pageSizeOptions = const <int>[10, 20, 50],
});
final int currentPage;
final int totalPages;
final int totalItems;
final int pageSize;
final List<int> pageSizeOptions;
}
/// ShadTable.list를 감싼 공통 테이블 래퍼.
class SuperportTable extends StatelessWidget {
const SuperportTable({
@@ -14,6 +43,13 @@ class SuperportTable extends StatelessWidget {
this.maxHeight,
this.onRowTap,
this.emptyLabel = '데이터가 없습니다.',
this.sortableColumns,
this.sortState,
this.onSortChanged,
this.pagination,
this.onPageChange,
this.onPageSizeChange,
this.isLoading = false,
}) : _columns = columns,
_rows = rows,
_headerCells = null,
@@ -28,6 +64,13 @@ class SuperportTable extends StatelessWidget {
this.maxHeight,
this.onRowTap,
this.emptyLabel = '데이터가 없습니다.',
this.sortableColumns,
this.sortState,
this.onSortChanged,
this.pagination,
this.onPageChange,
this.onPageSizeChange,
this.isLoading = false,
}) : _columns = null,
_rows = null,
_headerCells = header,
@@ -42,6 +85,13 @@ class SuperportTable extends StatelessWidget {
final double? maxHeight;
final void Function(int index)? onRowTap;
final String emptyLabel;
final Set<int>? sortableColumns;
final SuperportTableSortState? sortState;
final void Function(int columnIndex, bool ascending)? onSortChanged;
final SuperportTablePagination? pagination;
final void Function(int page)? onPageChange;
final void Function(int pageSize)? onPageSizeChange;
final bool isLoading;
@override
Widget build(BuildContext context) {
@@ -60,7 +110,7 @@ class SuperportTable extends StatelessWidget {
if (header == null) {
throw StateError('header cells must not be null when using fromCells');
}
headerCells = header;
headerCells = [...header];
tableRows = rows;
} else {
final rows = _rows;
@@ -71,13 +121,33 @@ class SuperportTable extends StatelessWidget {
child: Center(child: Text(emptyLabel, style: theme.textTheme.muted)),
);
}
headerCells = _columns!
.map(
(cell) => cell is ShadTableCell
? cell
: ShadTableCell.header(child: cell),
)
.toList();
final columns = _columns!;
final sortable = sortableColumns ?? const <int>{};
headerCells = <ShadTableCell>[];
for (var i = 0; i < columns.length; i++) {
final columnWidget = columns[i];
if (columnWidget is ShadTableCell) {
headerCells.add(columnWidget);
continue;
}
final shouldAttachSorter =
onSortChanged != null && sortable.contains(i);
final headerChild = shouldAttachSorter
? _SortableHeader(
isActive: sortState?.columnIndex == i,
ascending: sortState?.ascending ?? true,
onTap: () {
final isActive = sortState?.columnIndex == i;
final nextAscending = isActive
? !(sortState?.ascending ?? true)
: true;
onSortChanged!(i, nextAscending);
},
child: columnWidget,
)
: columnWidget;
headerCells.add(ShadTableCell.header(child: headerChild));
}
tableRows = [
for (final row in rows)
row
@@ -98,7 +168,7 @@ class SuperportTable extends StatelessWidget {
: math.min(estimatedHeight, maxHeight!),
);
return SizedBox(
final tableView = SizedBox(
height: effectiveHeight,
child: ShadTable.list(
header: headerCells,
@@ -109,5 +179,140 @@ class SuperportTable extends StatelessWidget {
children: tableRows,
),
);
final pagination = this.pagination;
if (pagination == null) {
return tableView;
}
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
tableView,
const SizedBox(height: 12),
_PaginationFooter(
pagination: pagination,
onPageChange: onPageChange,
onPageSizeChange: onPageSizeChange,
isLoading: isLoading,
),
],
);
}
}
class _SortableHeader extends StatelessWidget {
const _SortableHeader({
required this.child,
required this.onTap,
required this.isActive,
required this.ascending,
});
final Widget child;
final VoidCallback onTap;
final bool isActive;
final bool ascending;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final icon = isActive
? (ascending
? lucide.LucideIcons.arrowUp
: lucide.LucideIcons.arrowDown)
: lucide.LucideIcons.arrowUpDown;
final color = isActive
? theme.colorScheme.foreground
: theme.colorScheme.mutedForeground;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(6),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(child: child),
const SizedBox(width: 8),
Icon(icon, size: 14, color: color),
],
),
),
);
}
}
class _PaginationFooter extends StatelessWidget {
const _PaginationFooter({
required this.pagination,
required this.onPageChange,
required this.onPageSizeChange,
required this.isLoading,
});
final SuperportTablePagination pagination;
final void Function(int page)? onPageChange;
final void Function(int pageSize)? onPageSizeChange;
final bool isLoading;
@override
Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
final currentPage = pagination.currentPage.clamp(1, pagination.totalPages);
final canGoPrev = currentPage > 1 && !isLoading;
final canGoNext = currentPage < pagination.totalPages && !isLoading;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 170,
child: ShadSelect<int>(
key: ValueKey(pagination.pageSize),
initialValue: pagination.pageSize,
selectedOptionBuilder: (_, value) => Text('$value개 / 페이지'),
onChanged: isLoading
? null
: (value) {
if (value == null || value == pagination.pageSize) {
return;
}
onPageSizeChange?.call(value);
},
options: [
for (final option in pagination.pageSizeOptions)
ShadOption(value: option, child: Text('$option개 / 페이지')),
],
),
),
Row(
children: [
Text(
'${pagination.totalItems}건 · 페이지 $currentPage / ${pagination.totalPages}',
style: theme.textTheme.small,
),
const SizedBox(width: 12),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: canGoPrev
? () => onPageChange?.call(currentPage - 1)
: null,
child: const Icon(lucide.LucideIcons.chevronLeft, size: 16),
),
const SizedBox(width: 8),
ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: canGoNext
? () => onPageChange?.call(currentPage + 1)
: null,
child: const Icon(lucide.LucideIcons.chevronRight, size: 16),
),
],
),
],
);
}
}