- ShadTable: ensure full-width via LayoutBuilder+ConstrainedBox minWidth - BaseListScreen: default data area padding = 0 for table edge-to-edge - Vendor/Model/User/Company/Inventory/Zipcode: set columnSpanExtent per column and add final filler column to absorb remaining width; pin date/status/actions widths; ensure date text is single-line - Equipment: unify card/border style; define fixed column widths + filler; increase checkbox column to 56px to avoid overflow - Rent list: migrate to ShadTable.list with fixed widths + filler column - Rent form dialog: prevent infinite width by bounding ShadProgress with SizedBox and remove Expanded from option rows; add safe selectedOptionBuilder - Admin list: fix const with non-const argument in table column extents - Services/Controller: remove hardcoded perPage=10; use BaseListController perPage; trust server meta (total/totalPages) in equipment pagination - widgets/shad_table: ConstrainedBox(minWidth=viewport) so table stretches Run: flutter analyze → 0 errors (warnings remain).
482 lines
16 KiB
Dart
482 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
import 'package:superport/data/models/model/model_dto.dart';
|
|
import 'package:superport/screens/model/controllers/model_controller.dart';
|
|
import 'package:superport/screens/model/model_form_dialog.dart';
|
|
import 'package:superport/screens/common/layouts/base_list_screen.dart';
|
|
import 'package:superport/screens/common/widgets/standard_action_bar.dart';
|
|
import 'package:superport/screens/common/widgets/pagination.dart';
|
|
import 'package:superport/screens/common/theme_shadcn.dart';
|
|
import 'package:superport/injection_container.dart' as di;
|
|
|
|
class ModelListScreen extends StatefulWidget {
|
|
const ModelListScreen({super.key});
|
|
|
|
@override
|
|
State<ModelListScreen> createState() => _ModelListScreenState();
|
|
}
|
|
|
|
class _ModelListScreenState extends State<ModelListScreen> {
|
|
late final ModelController _controller;
|
|
|
|
// 클라이언트 사이드 페이지네이션
|
|
int _currentPage = 1;
|
|
static const int _pageSize = 10;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = di.sl<ModelController>();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_controller.loadInitialData();
|
|
});
|
|
}
|
|
|
|
// 현재 페이지의 모델 목록 반환
|
|
List<ModelDto> _getCurrentPageModels() {
|
|
final allModels = _controller.models;
|
|
final startIndex = (_currentPage - 1) * _pageSize;
|
|
final endIndex = startIndex + _pageSize;
|
|
|
|
if (startIndex >= allModels.length) return [];
|
|
if (endIndex >= allModels.length) return allModels.sublist(startIndex);
|
|
|
|
return allModels.sublist(startIndex, endIndex);
|
|
}
|
|
|
|
// 총 페이지 수 계산
|
|
int _getTotalPages() {
|
|
return (_controller.models.length / _pageSize).ceil();
|
|
}
|
|
|
|
// 페이지 이동
|
|
void _goToPage(int page) {
|
|
final totalPages = _getTotalPages();
|
|
if (page < 1 || page > totalPages) return;
|
|
|
|
setState(() {
|
|
_currentPage = page;
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ChangeNotifierProvider.value(
|
|
value: _controller,
|
|
child: Scaffold(
|
|
backgroundColor: ShadcnTheme.background,
|
|
appBar: AppBar(
|
|
title: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'모델 관리',
|
|
style: ShadcnTheme.headingH4,
|
|
),
|
|
Text(
|
|
'장비 모델 정보를 관리합니다',
|
|
style: ShadcnTheme.bodySmall,
|
|
),
|
|
],
|
|
),
|
|
backgroundColor: ShadcnTheme.background,
|
|
elevation: 0,
|
|
),
|
|
body: Consumer<ModelController>(
|
|
builder: (context, controller, child) {
|
|
return BaseListScreen(
|
|
headerSection: _buildStatisticsCards(controller),
|
|
searchBar: _buildSearchBar(controller),
|
|
actionBar: _buildActionBar(),
|
|
dataTable: _buildDataTable(controller),
|
|
pagination: _buildPagination(controller),
|
|
isLoading: controller.isLoading,
|
|
error: controller.errorMessage,
|
|
onRefresh: () => controller.loadInitialData(),
|
|
dataAreaPadding: EdgeInsets.zero,
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatisticsCards(ModelController controller) {
|
|
if (controller.isLoading) return const SizedBox();
|
|
|
|
return Row(
|
|
children: [
|
|
_buildStatCard(
|
|
'전체 모델',
|
|
controller.models.length.toString(),
|
|
Icons.category,
|
|
ShadcnTheme.companyCustomer,
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing4),
|
|
_buildStatCard(
|
|
'제조사',
|
|
controller.vendors.length.toString(),
|
|
Icons.business,
|
|
ShadcnTheme.companyPartner,
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing4),
|
|
_buildStatCard(
|
|
'활성 모델',
|
|
controller.models.where((m) => !m.isDeleted).length.toString(),
|
|
Icons.check_circle,
|
|
ShadcnTheme.equipmentIn,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchBar(ModelController controller) {
|
|
return Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: ShadInput(
|
|
placeholder: const Text('모델명 검색...'),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_currentPage = 1; // 검색 시 첫 페이지로 리셋
|
|
});
|
|
controller.setSearchQuery(value);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing4),
|
|
Expanded(
|
|
child: ShadSelect<int?>(
|
|
placeholder: const Text('제조사 선택'),
|
|
options: [
|
|
const ShadOption<int?>(
|
|
value: null,
|
|
child: Text('전체'),
|
|
),
|
|
...controller.vendors.map(
|
|
(vendor) => ShadOption<int?>(
|
|
value: vendor.id,
|
|
child: Text(vendor.name),
|
|
),
|
|
),
|
|
],
|
|
selectedOptionBuilder: (context, value) {
|
|
if (value == null) {
|
|
return const Text('전체');
|
|
}
|
|
try {
|
|
final vendor = controller.vendors.firstWhere((v) => v.id == value);
|
|
return Text(vendor.name);
|
|
} catch (_) {
|
|
return const Text('전체');
|
|
}
|
|
},
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_currentPage = 1; // 필터 변경 시 첫 페이지로 리셋
|
|
});
|
|
controller.setVendorFilter(value);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildActionBar() {
|
|
return StandardActionBar(
|
|
totalCount: _controller.totalCount,
|
|
leftActions: const [
|
|
Text('모델 목록', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
],
|
|
rightActions: [
|
|
ShadButton.outline(
|
|
onPressed: () => _controller.refreshModels(),
|
|
child: const Icon(Icons.refresh, size: 16),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing2),
|
|
ShadButton(
|
|
onPressed: _showCreateDialog,
|
|
child: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.add, size: 16),
|
|
SizedBox(width: ShadcnTheme.spacing1),
|
|
Text('새 모델 등록'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
|
|
Widget _buildDataTable(ModelController controller) {
|
|
final allModels = controller.models;
|
|
final currentPageModels = _getCurrentPageModels();
|
|
|
|
if (allModels.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.category_outlined,
|
|
size: 64,
|
|
color: ShadcnTheme.mutedForeground,
|
|
),
|
|
const SizedBox(height: ShadcnTheme.spacing4),
|
|
Text(
|
|
'등록된 모델이 없습니다',
|
|
style: ShadcnTheme.bodyMedium.copyWith(
|
|
color: ShadcnTheme.mutedForeground,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: ShadcnTheme.border),
|
|
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
// 고정폭 + 마지막 filler 컬럼이 남는 폭을 흡수
|
|
const double actionsW = 200.0;
|
|
const double minTableWidth = 80 + 260 + 320 + 120 + 100 + actionsW + 24;
|
|
final double tableWidth = constraints.maxWidth >= minTableWidth
|
|
? constraints.maxWidth
|
|
: minTableWidth;
|
|
const double modelColumnWidth = 320.0;
|
|
|
|
return SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: SizedBox(
|
|
width: tableWidth,
|
|
child: ShadTable.list(
|
|
columnSpanExtent: (index) {
|
|
switch (index) {
|
|
case 0:
|
|
return const FixedTableSpanExtent(80); // ID
|
|
case 1:
|
|
return const FixedTableSpanExtent(260); // 제조사
|
|
case 2:
|
|
return const FixedTableSpanExtent(modelColumnWidth); // 모델명
|
|
case 3:
|
|
return const FixedTableSpanExtent(120); // 등록일
|
|
case 4:
|
|
return const FixedTableSpanExtent(100); // 상태
|
|
case 5:
|
|
return const FixedTableSpanExtent(actionsW); // 작업
|
|
case 6:
|
|
return const RemainingTableSpanExtent(); // filler
|
|
default:
|
|
return const FixedTableSpanExtent(100);
|
|
}
|
|
},
|
|
header: [
|
|
const ShadTableCell.header(child: Text('ID')),
|
|
const ShadTableCell.header(child: Text('제조사')),
|
|
ShadTableCell.header(child: SizedBox(width: modelColumnWidth, child: const Text('모델명'))),
|
|
ShadTableCell.header(child: SizedBox(width: 120, child: const Text('등록일'))),
|
|
ShadTableCell.header(child: SizedBox(width: 100, child: const Text('상태'))),
|
|
ShadTableCell.header(child: SizedBox(width: actionsW, child: const Text('작업'))),
|
|
const ShadTableCell.header(child: SizedBox.shrink()),
|
|
],
|
|
children: currentPageModels.map((model) {
|
|
final vendor = _controller.getVendorById(model.vendorsId);
|
|
return [
|
|
ShadTableCell(child: Text(model.id.toString(), style: ShadcnTheme.bodySmall)),
|
|
ShadTableCell(child: Text(vendor?.name ?? '알 수 없음', overflow: TextOverflow.ellipsis)),
|
|
ShadTableCell(
|
|
child: SizedBox(
|
|
width: modelColumnWidth,
|
|
child: Text(
|
|
model.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: ShadcnTheme.bodyMedium.copyWith(fontWeight: FontWeight.w500),
|
|
),
|
|
),
|
|
),
|
|
ShadTableCell(
|
|
child: SizedBox(
|
|
width: 120,
|
|
child: Text(
|
|
model.registeredAt != null ? DateFormat('yyyy-MM-dd').format(model.registeredAt) : '-',
|
|
style: ShadcnTheme.bodySmall,
|
|
),
|
|
),
|
|
),
|
|
ShadTableCell(child: SizedBox(width: 100, child: _buildStatusChip(model.isDeleted))),
|
|
ShadTableCell(
|
|
child: SizedBox(
|
|
width: actionsW,
|
|
child: FittedBox(
|
|
alignment: Alignment.centerLeft,
|
|
fit: BoxFit.scaleDown,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
ShadButton.ghost(
|
|
onPressed: () => _showEditDialog(model),
|
|
child: const Icon(Icons.edit, size: 16),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing1),
|
|
ShadButton.ghost(
|
|
onPressed: () => _showDeleteConfirmDialog(model),
|
|
child: Icon(Icons.delete, size: 16, color: ShadcnTheme.destructive),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const ShadTableCell(child: SizedBox.shrink()),
|
|
];
|
|
}).toList(),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
Widget _buildPagination(ModelController controller) {
|
|
final totalCount = controller.models.length;
|
|
final totalPages = _getTotalPages();
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(ShadcnTheme.spacing3),
|
|
decoration: BoxDecoration(
|
|
color: ShadcnTheme.card,
|
|
border: Border(
|
|
top: BorderSide(color: ShadcnTheme.border),
|
|
),
|
|
),
|
|
child: Pagination(
|
|
totalCount: totalCount,
|
|
currentPage: _currentPage,
|
|
pageSize: _pageSize,
|
|
onPageChanged: _goToPage,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatCard(
|
|
String title,
|
|
String value,
|
|
IconData icon,
|
|
Color color,
|
|
) {
|
|
return Expanded(
|
|
child: ShadCard(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(ShadcnTheme.spacing4),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(ShadcnTheme.spacing3),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
|
|
),
|
|
child: Icon(
|
|
icon,
|
|
color: color,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: ShadcnTheme.spacing3),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: ShadcnTheme.bodySmall,
|
|
),
|
|
Text(
|
|
value,
|
|
style: ShadcnTheme.headingH6.copyWith(
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStatusChip(bool isDeleted) {
|
|
if (isDeleted) {
|
|
return ShadBadge(
|
|
backgroundColor: ShadcnTheme.equipmentDisposal.withValues(alpha: 0.1),
|
|
foregroundColor: ShadcnTheme.equipmentDisposal,
|
|
child: const Text('비활성'),
|
|
);
|
|
} else {
|
|
return ShadBadge(
|
|
backgroundColor: ShadcnTheme.equipmentIn.withValues(alpha: 0.1),
|
|
foregroundColor: ShadcnTheme.equipmentIn,
|
|
child: const Text('활성'),
|
|
);
|
|
}
|
|
}
|
|
|
|
void _showCreateDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => ModelFormDialog(
|
|
controller: _controller,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showEditDialog(ModelDto model) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => ModelFormDialog(
|
|
controller: _controller,
|
|
model: model,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDeleteConfirmDialog(ModelDto model) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => ShadDialog(
|
|
title: const Text('모델 삭제'),
|
|
description: Text('${model.name} 모델을 삭제하시겠습니까?'),
|
|
actions: [
|
|
ShadButton.outline(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('취소'),
|
|
),
|
|
ShadButton.destructive(
|
|
onPressed: () async {
|
|
Navigator.of(context).pop();
|
|
await _controller.deleteModel(model.id);
|
|
},
|
|
child: const Text('삭제'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|