From 9beb1615275488217e7b33df4f19431d270f2f8f Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 24 Oct 2025 16:24:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(pagination):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=20=EB=8F=84=EC=9E=85=EA=B3=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B4=80=EB=A6=AC=20=EA=B0=80?= =?UTF-8?q?=EC=9D=B4=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 테이블 푸터에서 SuperportPaginationControls를 사용하도록 각 관리 페이지 페이지네이션 로직을 정리 - SuperportPaginationControls 위젯을 추가하고 SuperportTable 푸터를 개선해 페이지 사이즈 선택과 이동 버튼을 분리 - 사용자 등록·계정 관리 요구사항을 문서화한 doc/user_setting.md를 작성하고 AGENTS.md 코멘트 규칙을 업데이트 - flutter analyze를 수행해 빌드 경고가 없음을 확인 --- AGENTS.md | 14 +++ doc/user_setting.md | 109 ++++++++++++++++++ .../presentation/pages/approval_page.dart | 47 +------- .../pages/approval_step_page.dart | 58 ++-------- .../presentation/pages/inbound_page.dart | 21 +++- .../presentation/pages/outbound_page.dart | 85 ++++++++------ .../presentation/pages/rental_page.dart | 79 ++++++++----- .../presentation/pages/customer_page.dart | 37 +----- .../group/presentation/pages/group_page.dart | 70 +++-------- .../pages/group_permission_page.dart | 50 ++------ .../menu/presentation/pages/menu_page.dart | 62 +++------- .../presentation/pages/product_page.dart | 37 +----- .../user/presentation/pages/user_page.dart | 49 ++------ .../presentation/pages/vendor_page.dart | 37 +----- .../presentation/pages/warehouse_page.dart | 37 +----- .../superport_pagination_controls.dart | 98 ++++++++++++++++ lib/widgets/components/superport_table.dart | 44 ++----- 17 files changed, 432 insertions(+), 502 deletions(-) create mode 100644 doc/user_setting.md create mode 100644 lib/widgets/components/superport_pagination_controls.dart diff --git a/AGENTS.md b/AGENTS.md index f391021..44dd1d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,20 @@ Each feature ships with unit tests (`*_test.dart`) living beside the source modu ## Commit & Pull Request Guidelines Commits follow the existing Superport convention: Korean imperative summaries with optional English technical nouns, e.g., `"대여 상세 테이블 정렬 수정"`. For PRs, include (1) a concise summary of user-visible impact, (2) screenshots or GIFs for UI changes, (3) linked issue or JIRA reference, and (4) verification notes (commands run, tests passing). Squash before merge unless release tagging requires history. +## Change Comment Guidelines +- Document every change with a Conventional Commit style summary line: `type(scope): 요약`. Use Korean imperatives for the message body while keeping the scope in English (module/package). +- Follow the summary with a blank line and bullet points that detail the concrete modifications, each starting with `- ` and phrased in past-tense or imperative sentences that mention impacted modules. +- Include tests, scripts, and document updates in the bullet list so reviewers understand coverage. +- When multiple subsystems change, group bullets logically (e.g., backend, frontend, docs) and keep each bullet under 120 characters. +- Example: + ``` + feat(api): 헬스체크에 빌드 메타데이터 노출 + + - BUILD_VERSION 환경변수를 우선으로 사용하고 git 커밋 해시를 fallback으로 설정하는 build.rs를 추가 + - 헬스체크 응답에 빌드 버전을 포함하고 pagination 쿼리 파싱을 위해 serde_urlencoded를 도입하여 테스트를 보강 + - 원격 배포 스크립트에서 BUILD_VERSION을 로컬/원격 실행에 전달하고 문서를 최신 플로우로 업데이트 + ``` + ## Architecture & Environment Notes Initialize environments via `.env.development` / `.env.production` and load them through `Environment.initialize()` before bootstrapping DI. New data sources should expose repository interfaces in `domain/` and rely on the shared `ApiClient` instance. Do not use mock data in the application; always call the real backend (staging/production as appropriate). If an endpoint is not available, mark the feature as disabled behind a feature flag rather than mocking. - Frontend behaviour/data models must strictly follow the deployed backend contract. (프론트엔드는 백엔드 API 계약을 절대 우선으로 준수해야 하며, 누락된 기능은 백엔드 수정 요청 후 진행한다.) diff --git a/doc/user_setting.md b/doc/user_setting.md new file mode 100644 index 0000000..67e3277 --- /dev/null +++ b/doc/user_setting.md @@ -0,0 +1,109 @@ +# 사용자 설정 및 계정 관리 확장 작업 가이드 + +입고 등록/사용자 관리 기능에서 작성자(로그인 사용자) 정보를 정확히 추적하고, 관리자가 신규 사용자를 생성·관리할 수 있도록 백엔드/프런트엔드 동시 진행 항목을 정리했습니다. 기존 인증/세션 흐름과 충돌할 수 있으므로, 아래 항목을 참고해 단계별 검증을 병행하세요. + +## 요구사항 요약 +- 작성자는 현재 로그인한 사용자 계정을 사용한다. (기존 더미 계정 `terabits` 제거 예정) +- 신규 사용자는 관리자만 등록할 수 있으며, 필수 입력 필드는 `employee_id`, `name`, `phone`, `email`, `password`. +- `employee_id`는 영문/숫자만 허용한다. (정규식 예: `^[A-Za-z0-9]{4,32}$`) +- 사용자는 등록 후 `phone`, `email`, `password`만 스스로 수정할 수 있다. +- 관리자 상세 화면에서 **비밀번호 재설정** 버튼을 제공하며, 즉시 8자 랜덤(대/소문자+숫자) 비밀번호를 생성해 이메일로 발송한다. +- 최초 로그인 시 강제 비밀번호 변경을 요구한다. +- 비밀번호 정책: 길이 8~24자, 대/소문자, 숫자, 일반 특수문자 중 각 1자 이상 포함. +- 사용자 비밀번호 변경 화면은 기존 비밀번호, 신규 비밀번호, 신규 비밀번호 확인 3개의 입력란으로 구성한다. +- 로그인 후 우상단 사용자 메뉴에서 이메일/비밀번호/연락처 변경 가능하며 `저장` 버튼으로 확정한다. +- 비밀번호 변경 성공 시 즉시 로그아웃 팝업을 띄우고 확인과 동시에 세션을 만료한다. 취소는 허용하지 않는다. + +## 백엔드 작업 항목 + +### 1. 도메인 및 저장소 구조 정리 +- `users` 테이블/컬렉션에 `employee_id`, `phone`, `password_hash`, `password_updated_at`, `force_password_change` 필드를 추가한다. +- `employee_id` 컬럼은 유니크 인덱스를 생성하고 대소문자 구분 여부를 명확히 한다. +- 기존 `terabits` 계정은 마이그레이션 스크립트에서 관리자 권한 유지 여부만 검토하고, 실사용자 생성 이후 제거 플랜을 마련한다. + +### 2. 사용자 생성 API (`POST /users`) +- 관리자 권한 체크 로직을 선행한다. +- 요청 본문 검증: `employee_id`(정규식), `name`(40자 내외), `phone`(국내/국제 번호 포맷 허용), `email`(RFC 검증), `password`(정책 준수). +- `password`는 Bcrypt/Scrypt 등 기존 정책에 맞춰 해시 저장한다. +- 생성 직후 `force_password_change = true`로 설정하고 응답에 포함한다. +- 사용자 생성 완료 시 비밀번호 초기값을 메일 발송 큐에 전달한다. + +### 3. 사용자 정보 수정 API +- **자기 정보 수정 (`PATCH /users/me`)** + - 허용 필드: `phone`, `email`, `password`. + - `password` 변경 시 기존 비밀번호 검증 후 `force_password_change = false`, `password_updated_at` 갱신. +- **관리자용 수정 (`PATCH /users/{id}`)** + - 허용 필드: `phone`, `email`, 권한 변경 등 필요한 항목만 열어준다. + - 비밀번호 재설정은 별도 엔드포인트로 분리한다. + +### 4. 비밀번호 재설정 API (`POST /users/{id}/reset-password`) +- 관리자 권한 필터 적용. +- 랜덤 8자(대/소문자·숫자 균형) 비밀번호 생성 유틸리티 구현. +- 새 비밀번호 해시 저장 후 `force_password_change = true` 설정. +- 이메일 발송: 템플릿에 임시 비밀번호 및 최초 로그인 안내 포함. +- 감사 로깅: 누가 언제 어떤 사용자 비밀번호를 초기화했는지 저장. + +### 5. 최초 로그인 강제 변경 로직 +- 로그인 성공 시 `force_password_change`가 `true`면 액세스 토큰 발급 대신 “비밀번호 재설정 필요” 상태 코드/에러 코드를 반환한다. +- 프런트엔드는 이 코드를 받아 전용 비밀번호 변경 화면으로 이동한다. +- 기존 Remember-me/세션 로직과의 호환성을 검증한다. + +### 6. 이메일 발송 및 템플릿 +- 신규 계정 생성/비밀번호 재설정 공통 템플릿을 작성하고 변수: `name`, `employee_id`, `temp_password`, `reset_url`. +- SMTP/메일 서비스 환경 변수 재점검(`MAIL_FROM`, `RESET_URL_BASE` 등). +- QA 환경에서는 메일 전송을 sandbox로 대체하고 로그로 임시 비밀번호를 출력한다. + +### 7. 테스트 & 배포 체크리스트 +- [ ] 사용자 생성, 자기 정보 수정, 관리자 초기화 API 통합 테스트 작성. +- [ ] 비밀번호 정책 유효성 테스트 (허용/거부 케이스) 구현. +- [ ] 마이그레이션 스크립트와 롤백 스크립트 준비. +- [ ] 배포 전 staging에서 실제 메일 발송 여부 검증. +- [ ] 기존 로그인 세션/토큰 구조와 충돌 여부 점검. + +## 프런트엔드 작업 항목 + +### 1. 관리자 > 사용자 관리 화면 개편 +- 사용자 목록에 `employee_id`, `name`, `phone`, `email`, `role` 컬럼을 추가하고 서버 페이징을 재검토한다. +- 신규 등록 모달/페이지에 입력 필드를 추가하고 프런트 검증(정규식, 필수 여부)을 구현한다. +- 제출 시 비밀번호 정책 위반 시 프론트에서 선제적으로 에러 메시지 표시(한글 메시지, 정책 안내 문구 포함). +- 생성 성공 후 토스트 및 리스트 리프레시, 임시 비밀번호가 이메일로 발송됨을 명시한다. + +### 2. 관리자 > 사용자 상세 보기 +- 비밀번호 재설정 버튼 추가: 클릭 시 확인 다이얼로그 → API 호출 → 성공 토스트. +- 다이얼로그 문구에 “임시 비밀번호가 이메일로 발송된다”는 안내 포함. +- 감사 로그 필요 시 별도 이벤트 추적(`analytics`/`Sentry breadcrumb`)을 연동한다. + +### 3. 우상단 사용자 메뉴 개선 +- `내 정보` 패널에서 이메일/연락처 수정 필드를 제공하고, 변경 시 Dirty 상태 감지 → `저장` 버튼 활성화. +- 저장 성공 후 사용자 상태 스토어/Provider를 갱신하여 헤더/다른 화면과 동기화. +- 비밀번호 변경 진입 버튼을 분리하고, 모달 또는 전용 페이지에서 3개 입력 필드를 제공한다. + +### 4. 비밀번호 변경 플로우 +- `현재 비밀번호`, `새 비밀번호`, `새 비밀번호 확인` 필드와 실시간 정책 검증(대/소문자, 숫자, 특수문자) UI를 구현한다. +- 저장 시 API 호출 → 성공하면 즉시 비밀번호 변경 완료 다이얼로그/스낵바 → **강제 로그아웃 팝업** 표출. +- 팝업은 확인 버튼만 제공하며, 클릭 시 `authController.signOut()` 호출 후 로그인 페이지로 리다이렉트. +- 실패 케이스 처리: 기존 비밀번호 불일치, 정책 위반, 서버 에러 각각에 맞는 오류 메시지. + +### 5. 최초 로그인 경로 처리 +- 로그인 성공 응답이 “비밀번호 변경 필요” 상태이면 인증 토큰을 저장하지 않고, 전용 비밀번호 변경 화면으로 라우팅. +- 비밀번호 변경 완료 후에는 정상 로그인 플로우 재시도(저장된 자격 증명 재사용 금지). + +### 6. 공통 사항 +- `employee_id`는 읽기 전용 표시로 유지하며, 자기 정보 수정 화면에서는 비활성화한다. +- 폼 검증 메시지는 `lib/core/validation` 또는 기존 유틸 모듈에 추가하고 재사용한다. +- API 타입 정의(DTO/모델) 업데이트: `forcePasswordChange` 플래그, `phone`/`email` 수정 필드 등 반영. +- 테스트: 관리자 화면 위젯 테스트, 비밀번호 변경 위젯 테스트, 상태 관리 유닛 테스트 작성. + +### 7. QA 체크리스트 +- [ ] 신규 사용자 생성 시 임시 비밀번호 안내 모달과 이메일 발송 메세지가 노출된다. +- [ ] 임시 비밀번호로 로그인하면 곧바로 비밀번호 변경 화면으로 이동한다. +- [ ] 비밀번호 변경 후 로그아웃 팝업이 표시되고, 확인 시 실제 로그아웃 된다. +- [ ] 이메일/연락처 저장 시 즉시 프로필 정보가 갱신된다. +- [ ] 관리자 비밀번호 재설정 후 사용자가 로그인 시 새 임시 비밀번호가 요구된다. + +## 동시 작업 및 커뮤니케이션 가이드 +- 백엔드는 임시로 `force_password_change` 상태를 반환하는 Mock 응답을 제공하고, 프런트엔드는 이를 기준으로 화면을 선행 구현한다. +- API 스펙 변경 사항은 `doc/frontend_api_alignment_plan.md`에 연동 기록을 추가하고, DTO 변경은 `dart run build_runner` 실행 시점 합의 후 진행한다. +- 이메일 템플릿, 비밀번호 정책 문구 등 사용자 노출 텍스트는 `product/QA` 승인 후 배포한다. +- 배포 순서: 백엔드 마이그레이션 → 신규 API 배포 → 프런트 배포 → 더미 계정 정리. +- 사이드 이펙트 대비: 로그인 세션 만료, 캐시된 사용자 정보, 기존 Admin UI 권한 체크 로직 변경 시 전체 기능 회귀 테스트를 수행한다. diff --git a/lib/features/approvals/presentation/pages/approval_page.dart b/lib/features/approvals/presentation/pages/approval_page.dart index 512519e..a6ab292 100644 --- a/lib/features/approvals/presentation/pages/approval_page.dart +++ b/lib/features/approvals/presentation/pages/approval_page.dart @@ -13,6 +13,7 @@ import '../../../../widgets/components/feedback.dart'; import '../../../../widgets/components/filter_bar.dart'; import '../../../../widgets/components/superport_dialog.dart'; import '../../../../widgets/components/superport_table.dart'; +import '../../../../widgets/components/superport_pagination_controls.dart'; import '../../../../widgets/components/feature_disabled_placeholder.dart'; import '../../domain/entities/approval.dart'; import '../../domain/entities/approval_template.dart'; @@ -212,9 +213,6 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final isLoadingActions = _controller.isLoadingActions; final isPerformingAction = _controller.isPerformingAction; final processingStepId = _controller.processingStepId; @@ -349,44 +347,11 @@ class _ApprovalEnabledPageState extends State<_ApprovalEnabledPage> { '페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoadingList || currentPage <= 1 - ? null - : () => _controller.fetch(page: 1), - child: const Text('처음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoadingList || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoadingList || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoadingList || - currentPage >= totalPages - ? null - : () => _controller.fetch(page: totalPages), - child: const Text('마지막'), - ), - ], + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoadingList, + onPageSelected: (page) => _controller.fetch(page: page), ), ], ), diff --git a/lib/features/approvals/step/presentation/pages/approval_step_page.dart b/lib/features/approvals/step/presentation/pages/approval_step_page.dart index 0293563..06e84be 100644 --- a/lib/features/approvals/step/presentation/pages/approval_step_page.dart +++ b/lib/features/approvals/step/presentation/pages/approval_step_page.dart @@ -10,6 +10,7 @@ import '../../../../../core/permissions/permission_resources.dart'; import '../../../../../widgets/app_layout.dart'; import '../../../../../widgets/components/filter_bar.dart'; import '../../../../../widgets/components/superport_dialog.dart'; +import '../../../../../widgets/components/superport_pagination_controls.dart'; import '../../../../../widgets/components/feature_disabled_placeholder.dart'; import '../controllers/approval_step_controller.dart'; import '../../domain/entities/approval_step_input.dart'; @@ -119,9 +120,6 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { final totalPages = pageSize == 0 ? 1 : (totalCount / pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final statusOptions = _buildStatusOptions(records); final selectedStatus = _controller.statusId ?? -1; final approverOptions = _buildApproverOptions(records); @@ -411,54 +409,12 @@ class _ApprovalStepEnabledPageState extends State<_ApprovalStepEnabledPage> { Text('총 $totalCount건', style: theme.textTheme.small), Row( children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || - isSaving || - currentPage <= 1 - ? null - : () => _controller.fetch(page: 1), - child: const Text('처음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || - isSaving || - currentPage <= 1 - ? null - : () => _controller.fetch( - page: currentPage - 1, - ), - child: const Text('이전'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || - isSaving || - !hasNext - ? null - : () => _controller.fetch( - page: currentPage + 1, - ), - child: const Text('다음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || - isSaving || - currentPage >= totalPages - ? null - : () => _controller.fetch( - page: totalPages, - ), - child: const Text('마지막'), + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading || isSaving, + onPageSelected: (page) => + _controller.fetch(page: page), ), const SizedBox(width: 12), Text( diff --git a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart index 14eecb1..b29ccff 100644 --- a/lib/features/inventory/inbound/presentation/pages/inbound_page.dart +++ b/lib/features/inventory/inbound/presentation/pages/inbound_page.dart @@ -645,10 +645,10 @@ class _InboundPageState extends State { return records; } - List _buildRecordRow(InboundRecord record) { + List _buildRecordRow(InboundRecord record, int displayIndex) { final primaryItem = record.items.isNotEmpty ? record.items.first : null; return [ - record.number.split('-').last, + displayIndex.toString(), _dateFormatter.format(record.processedAt), record.warehouse, record.transactionNumber, @@ -670,13 +670,15 @@ class _InboundPageState extends State { DeviceBreakpoint breakpoint, ) { final visibleColumns = _visibleColumnsFor(breakpoint); + final baseOffset = _rowNumberOffset(records.length); return ShadTable.list( header: [ for (final index in visibleColumns) ShadTableCell.header(child: Text(InboundTableSpec.headers[index])), ], children: [ - for (final record in records) _buildTableCells(record, visibleColumns), + for (var i = 0; i < records.length; i++) + _buildTableCells(records[i], visibleColumns, baseOffset + i + 1), ], columnSpanExtent: (index) => const FixedTableSpanExtent(InboundTableSpec.columnSpanWidth), @@ -718,8 +720,9 @@ class _InboundPageState extends State { List _buildTableCells( InboundRecord record, List visibleColumns, + int displayIndex, ) { - final values = _buildRecordRow(record); + final values = _buildRecordRow(record, displayIndex); return [ for (final index in visibleColumns) ShadTableCell( @@ -728,6 +731,16 @@ class _InboundPageState extends State { ]; } + int _rowNumberOffset(int currentCount) { + final page = _result?.page ?? _currentPage; + final pageSize = _result?.pageSize ?? _pageSize; + final safePage = page > 0 ? page : 1; + final safePageSize = pageSize > 0 + ? pageSize + : (currentCount > 0 ? currentCount : 1); + return (safePage - 1) * safePageSize; + } + Future _showDetailDialog(InboundRecord record) async { await showInventoryTransactionDetailDialog( context: context, diff --git a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart index 55be40d..45c34b9 100644 --- a/lib/features/inventory/outbound/presentation/pages/outbound_page.dart +++ b/lib/features/inventory/outbound/presentation/pages/outbound_page.dart @@ -574,39 +574,46 @@ class _OutboundPageState extends State { style: theme.textTheme.muted, ), ) - : ShadTable.list( - header: OutboundTableSpec.headers - .map( - (header) => - ShadTableCell.header(child: Text(header)), - ) - .toList(), - children: [ - for (final row in visibleRecords.map( - _buildRecordRow, - )) - [ - for (final value in row) - ShadTableCell( - child: Text( - value, - overflow: TextOverflow.ellipsis, + : () { + final baseOffset = _rowNumberOffset( + visibleRecords.length, + ); + return ShadTable.list( + header: OutboundTableSpec.headers + .map( + (header) => + ShadTableCell.header(child: Text(header)), + ) + .toList(), + children: [ + for (var i = 0; i < visibleRecords.length; i++) + [ + for (final value in _buildRecordRow( + visibleRecords[i], + baseOffset + i + 1, + )) + ShadTableCell( + child: Text( + value, + overflow: TextOverflow.ellipsis, + ), ), - ), - ], - ], - columnSpanExtent: (index) => - const FixedTableSpanExtent( - OutboundTableSpec.columnSpanWidth, - ), - rowSpanExtent: (index) => const FixedTableSpanExtent( - OutboundTableSpec.rowSpanHeight, - ), - onRowTap: (rowIndex) { - final record = visibleRecords[rowIndex]; - _selectRecord(record, openDetail: true); - }, - ), + ], + ], + columnSpanExtent: (index) => + const FixedTableSpanExtent( + OutboundTableSpec.columnSpanWidth, + ), + rowSpanExtent: (index) => + const FixedTableSpanExtent( + OutboundTableSpec.rowSpanHeight, + ), + onRowTap: (rowIndex) { + final record = visibleRecords[rowIndex]; + _selectRecord(record, openDetail: true); + }, + ); + }(), ), if (filtered.isNotEmpty) ...[ const SizedBox(height: 12), @@ -787,10 +794,10 @@ class _OutboundPageState extends State { } } - List _buildRecordRow(OutboundRecord record) { + List _buildRecordRow(OutboundRecord record, int displayIndex) { final primaryItem = record.items.isNotEmpty ? record.items.first : null; return [ - record.number.split('-').last, + displayIndex.toString(), _dateFormatter.format(record.processedAt), record.warehouse, record.transactionNumber, @@ -808,6 +815,16 @@ class _OutboundPageState extends State { ]; } + int _rowNumberOffset(int currentCount) { + final page = _result?.page ?? _currentPage; + final pageSize = _result?.pageSize ?? _pageSize; + final safePage = page > 0 ? page : 1; + final safePageSize = pageSize > 0 + ? pageSize + : (currentCount > 0 ? currentCount : 1); + return (safePage - 1) * safePageSize; + } + Future _showDetailDialog(OutboundRecord record) async { await showInventoryTransactionDetailDialog( context: context, diff --git a/lib/features/inventory/rental/presentation/pages/rental_page.dart b/lib/features/inventory/rental/presentation/pages/rental_page.dart index 9193944..a0ddf4e 100644 --- a/lib/features/inventory/rental/presentation/pages/rental_page.dart +++ b/lib/features/inventory/rental/presentation/pages/rental_page.dart @@ -522,36 +522,45 @@ class _RentalPageState extends State { : '대여 데이터가 없습니다.', description: _errorMessage ?? '검색 조건을 조정해 다시 시도하세요.', ) - : ShadTable.list( - header: RentalTableSpec.headers - .map( - (header) => - ShadTableCell.header(child: Text(header)), - ) - .toList(), - children: [ - for (final record in visibleRecords) - _buildRecordRow(record).map( - (value) => ShadTableCell( - child: Text( - value, - overflow: TextOverflow.ellipsis, + : () { + final baseOffset = _rowNumberOffset( + visibleRecords.length, + ); + return ShadTable.list( + header: RentalTableSpec.headers + .map( + (header) => + ShadTableCell.header(child: Text(header)), + ) + .toList(), + children: [ + for (var i = 0; i < visibleRecords.length; i++) + _buildRecordRow( + visibleRecords[i], + baseOffset + i + 1, + ).map( + (value) => ShadTableCell( + child: Text( + value, + overflow: TextOverflow.ellipsis, + ), ), ), - ), - ], - columnSpanExtent: (index) => - const FixedTableSpanExtent( - RentalTableSpec.columnSpanWidth, - ), - rowSpanExtent: (index) => const FixedTableSpanExtent( - RentalTableSpec.rowSpanHeight, - ), - onRowTap: (rowIndex) { - final record = visibleRecords[rowIndex]; - _selectRecord(record, openDetail: true); - }, - ), + ], + columnSpanExtent: (index) => + const FixedTableSpanExtent( + RentalTableSpec.columnSpanWidth, + ), + rowSpanExtent: (index) => + const FixedTableSpanExtent( + RentalTableSpec.rowSpanHeight, + ), + onRowTap: (rowIndex) { + final record = visibleRecords[rowIndex]; + _selectRecord(record, openDetail: true); + }, + ); + }(), ), if (filtered.isNotEmpty) ...[ const SizedBox(height: 12), @@ -747,9 +756,9 @@ class _RentalPageState extends State { } } - List _buildRecordRow(RentalRecord record) { + List _buildRecordRow(RentalRecord record, int displayIndex) { return [ - record.number.split('-').last, + displayIndex.toString(), _dateFormatter.format(record.processedAt), record.warehouse, record.rentalType, @@ -765,6 +774,16 @@ class _RentalPageState extends State { ]; } + int _rowNumberOffset(int currentCount) { + final page = _result?.page ?? _currentPage; + final pageSize = _result?.pageSize ?? _pageSize; + final safePage = page > 0 ? page : 1; + final safePageSize = pageSize > 0 + ? pageSize + : (currentCount > 0 ? currentCount : 1); + return (safePage - 1) * safePageSize; + } + Future _showDetailDialog(RentalRecord record) async { await showInventoryTransactionDetailDialog( context: context, diff --git a/lib/features/masters/customer/presentation/pages/customer_page.dart b/lib/features/masters/customer/presentation/pages/customer_page.dart index b3c92e2..104f67b 100644 --- a/lib/features/masters/customer/presentation/pages/customer_page.dart +++ b/lib/features/masters/customer/presentation/pages/customer_page.dart @@ -7,6 +7,7 @@ import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; import 'package:superport_v2/features/util/postal_search/presentation/widgets/postal_search_dialog.dart'; import 'package:superport_v2/widgets/components/responsive_section.dart'; @@ -191,9 +192,6 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final showReset = _searchController.text.isNotEmpty || @@ -323,34 +321,11 @@ class _CustomerEnabledPageState extends State<_CustomerEnabledPage> { alignment: WrapAlignment.end, runAlignment: WrapAlignment.end, children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _goToPage(1), - child: const Text('처음'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _goToPage(currentPage - 1), - child: const Text('이전'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _goToPage(currentPage + 1), - child: const Text('다음'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || currentPage >= totalPages - ? null - : () => _goToPage(totalPages), - child: const Text('마지막'), + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading, + onPageSelected: _goToPage, ), ], ), diff --git a/lib/features/masters/group/presentation/pages/group_page.dart b/lib/features/masters/group/presentation/pages/group_page.dart index 14ce799..668760d 100644 --- a/lib/features/masters/group/presentation/pages/group_page.dart +++ b/lib/features/masters/group/presentation/pages/group_page.dart @@ -6,6 +6,7 @@ import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -131,9 +132,6 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final showReset = _searchController.text.isNotEmpty || @@ -252,41 +250,11 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { '페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: 1), - child: const Text('처음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || currentPage >= totalPages - ? null - : () => _controller.fetch(page: totalPages), - child: const Text('마지막'), - ), - ], + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading, + onPageSelected: (page) => _controller.fetch(page: page), ), ], ), @@ -386,9 +354,9 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { onPressed: isSaving ? null : () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(), + dialogContext, + rootNavigator: true, + ).pop(), child: const Text('취소'), ); }, @@ -418,10 +386,10 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { isActive: isActiveNotifier.value, note: note.isEmpty ? null : note, ); - final navigator = Navigator.of( - dialogContext, - rootNavigator: true, - ); + final navigator = Navigator.of( + dialogContext, + rootNavigator: true, + ); final response = isEdit ? await _controller.update(groupId!, input) : await _controller.create(input); @@ -577,17 +545,13 @@ class _GroupEnabledPageState extends State<_GroupEnabledPage> { content: Text('"${group.groupName}" 그룹을 삭제하시겠습니까?'), actions: [ TextButton( - onPressed: () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(false), + onPressed: () => + Navigator.of(dialogContext, rootNavigator: true).pop(false), child: const Text('취소'), ), TextButton( - onPressed: () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(true), + onPressed: () => + Navigator.of(dialogContext, rootNavigator: true).pop(true), child: const Text('삭제'), ), ], diff --git a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart index 30286ba..9ca246e 100644 --- a/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart +++ b/lib/features/masters/group_permission/presentation/pages/group_permission_page.dart @@ -7,6 +7,7 @@ import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/permissions/permission_manager.dart'; @@ -182,9 +183,6 @@ class _GroupPermissionEnabledPageState final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final showReset = _searchController.text.isNotEmpty || @@ -365,41 +363,11 @@ class _GroupPermissionEnabledPageState '페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: 1), - child: const Text('처음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || currentPage >= totalPages - ? null - : () => _controller.fetch(page: totalPages), - child: const Text('마지막'), - ), - ], + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading, + onPageSelected: (page) => _controller.fetch(page: page), ), ], ), @@ -500,9 +468,9 @@ class _GroupPermissionEnabledPageState onPressed: isSaving ? null : () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(false), + dialogContext, + rootNavigator: true, + ).pop(false), child: const Text('취소'), ); }, diff --git a/lib/features/masters/menu/presentation/pages/menu_page.dart b/lib/features/masters/menu/presentation/pages/menu_page.dart index 6f603ac..fea6f2f 100644 --- a/lib/features/masters/menu/presentation/pages/menu_page.dart +++ b/lib/features/masters/menu/presentation/pages/menu_page.dart @@ -6,6 +6,7 @@ import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../widgets/spec_page.dart'; @@ -152,9 +153,6 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final showReset = _searchController.text.isNotEmpty || @@ -308,41 +306,11 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { '페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: 1), - child: const Text('처음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || currentPage >= totalPages - ? null - : () => _controller.fetch(page: totalPages), - child: const Text('마지막'), - ), - ], + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading, + onPageSelected: (page) => _controller.fetch(page: page), ), ], ), @@ -436,9 +404,9 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { onPressed: isSaving ? null : () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(false), + dialogContext, + rootNavigator: true, + ).pop(false), child: const Text('취소'), ); }, @@ -730,10 +698,8 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { secondaryAction: Builder( builder: (dialogContext) { return ShadButton.ghost( - onPressed: () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(false), + onPressed: () => + Navigator.of(dialogContext, rootNavigator: true).pop(false), child: const Text('취소'), ); }, @@ -741,10 +707,8 @@ class _MenuEnabledPageState extends State<_MenuEnabledPage> { primaryAction: Builder( builder: (dialogContext) { return ShadButton.destructive( - onPressed: () => Navigator.of( - dialogContext, - rootNavigator: true, - ).pop(true), + onPressed: () => + Navigator.of(dialogContext, rootNavigator: true).pop(true), child: const Text('삭제'), ); }, diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 07d435a..39e0a61 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -7,6 +7,7 @@ import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import 'package:superport_v2/widgets/components/superport_table.dart'; import 'package:superport_v2/widgets/components/responsive_section.dart'; @@ -157,9 +158,6 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final showReset = _searchController.text.isNotEmpty || @@ -324,34 +322,11 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { alignment: WrapAlignment.end, runAlignment: WrapAlignment.end, children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _goToPage(1), - child: const Text('처음'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _goToPage(currentPage - 1), - child: const Text('이전'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _goToPage(currentPage + 1), - child: const Text('다음'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || currentPage >= totalPages - ? null - : () => _goToPage(totalPages), - child: const Text('마지막'), + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading, + onPageSelected: _goToPage, ), ], ), diff --git a/lib/features/masters/user/presentation/pages/user_page.dart b/lib/features/masters/user/presentation/pages/user_page.dart index 7e547b1..3ddf729 100644 --- a/lib/features/masters/user/presentation/pages/user_page.dart +++ b/lib/features/masters/user/presentation/pages/user_page.dart @@ -6,6 +6,7 @@ import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import '../../../../../core/config/environment.dart'; import '../../../../../core/permissions/permission_manager.dart'; @@ -164,9 +165,6 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final showReset = _searchController.text.isNotEmpty || @@ -292,41 +290,11 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { '페이지 $currentPage / $totalPages', style: theme.textTheme.small, ), - Row( - children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: 1), - child: const Text('처음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _controller.fetch(page: currentPage - 1), - child: const Text('이전'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _controller.fetch(page: currentPage + 1), - child: const Text('다음'), - ), - const SizedBox(width: 8), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || currentPage >= totalPages - ? null - : () => _controller.fetch(page: totalPages), - child: const Text('마지막'), - ), - ], + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading, + onPageSelected: (page) => _controller.fetch(page: page), ), ], ), @@ -475,10 +443,7 @@ class _UserEnabledPageState extends State<_UserEnabledPage> { secondaryAction: ValueListenableBuilder( valueListenable: saving, builder: (context, isSaving, _) { - final navigator = Navigator.of( - context, - rootNavigator: true, - ); + final navigator = Navigator.of(context, rootNavigator: true); return ShadButton.ghost( onPressed: isSaving ? null : () => navigator.pop(false), child: const Text('취소'), diff --git a/lib/features/masters/vendor/presentation/pages/vendor_page.dart b/lib/features/masters/vendor/presentation/pages/vendor_page.dart index d44226d..1889468 100644 --- a/lib/features/masters/vendor/presentation/pages/vendor_page.dart +++ b/lib/features/masters/vendor/presentation/pages/vendor_page.dart @@ -7,6 +7,7 @@ import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import 'package:superport_v2/widgets/components/superport_table.dart'; import 'package:superport_v2/widgets/components/responsive_section.dart'; @@ -150,9 +151,6 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; return AppLayout( title: '제조사(벤더) 관리', @@ -256,34 +254,11 @@ class _VendorEnabledPageState extends State<_VendorEnabledPage> { alignment: WrapAlignment.end, runAlignment: WrapAlignment.end, children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _goToPage(1), - child: const Text('처음'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _goToPage(currentPage - 1), - child: const Text('이전'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _goToPage(currentPage + 1), - child: const Text('다음'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || currentPage >= totalPages - ? null - : () => _goToPage(totalPages), - child: const Text('마지막'), + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading, + onPageSelected: _goToPage, ), ], ), diff --git a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart index b73233d..75f3dc4 100644 --- a/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart +++ b/lib/features/masters/warehouse/presentation/pages/warehouse_page.dart @@ -7,6 +7,7 @@ import 'package:superport_v2/core/constants/app_sections.dart'; import 'package:superport_v2/widgets/app_layout.dart'; import 'package:superport_v2/widgets/components/filter_bar.dart'; import 'package:superport_v2/widgets/components/superport_dialog.dart'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; import 'package:superport_v2/widgets/components/superport_table.dart'; import 'package:superport_v2/widgets/components/responsive_section.dart'; import 'package:superport_v2/features/util/postal_search/presentation/models/postal_search_result.dart'; @@ -159,9 +160,6 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { final totalPages = result == null || result.pageSize == 0 ? 1 : (result.total / result.pageSize).ceil().clamp(1, 9999); - final hasNext = result == null - ? false - : (result.page * result.pageSize) < result.total; final showReset = _searchController.text.isNotEmpty || @@ -268,34 +266,11 @@ class _WarehouseEnabledPageState extends State<_WarehouseEnabledPage> { alignment: WrapAlignment.end, runAlignment: WrapAlignment.end, children: [ - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _goToPage(1), - child: const Text('처음'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || currentPage <= 1 - ? null - : () => _goToPage(currentPage - 1), - child: const Text('이전'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: _controller.isLoading || !hasNext - ? null - : () => _goToPage(currentPage + 1), - child: const Text('다음'), - ), - ShadButton.outline( - size: ShadButtonSize.sm, - onPressed: - _controller.isLoading || currentPage >= totalPages - ? null - : () => _goToPage(totalPages), - child: const Text('마지막'), + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + isBusy: _controller.isLoading, + onPageSelected: _goToPage, ), ], ), diff --git a/lib/widgets/components/superport_pagination_controls.dart b/lib/widgets/components/superport_pagination_controls.dart new file mode 100644 index 0000000..111ebfe --- /dev/null +++ b/lib/widgets/components/superport_pagination_controls.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; +import 'package:shadcn_ui/shadcn_ui.dart'; + +/// 페이지 이동용 <<, <, >, >> 버튼을 공통 스타일로 제공하는 위젯. +class SuperportPaginationControls extends StatelessWidget { + const SuperportPaginationControls({ + super.key, + required this.currentPage, + required this.totalPages, + this.onPageSelected, + this.isBusy = false, + this.size = ShadButtonSize.sm, + this.spacing = 8, + this.mainAxisSize = MainAxisSize.min, + }); + + /// 현재 선택된 페이지 번호(1-base). + final int currentPage; + + /// 전체 페이지 수. + final int totalPages; + + /// 페이지 변경 콜백. `null`이면 모든 버튼을 비활성화한다. + final void Function(int page)? onPageSelected; + + /// 비동기 작업 중 여부. `true`면 버튼이 비활성화된다. + final bool isBusy; + + /// 버튼 크기 옵션. + final ShadButtonSize size; + + /// 버튼 간 간격. + final double spacing; + + /// 내부 Row의 MainAxisSize. + final MainAxisSize mainAxisSize; + + @override + Widget build(BuildContext context) { + final int safeTotal = totalPages <= 0 ? 1 : totalPages; + final int safeCurrent = currentPage < 1 + ? 1 + : (currentPage > safeTotal ? safeTotal : currentPage); + final bool canInteract = onPageSelected != null && !isBusy; + final bool canGoPrev = canInteract && safeCurrent > 1; + final bool canGoNext = canInteract && safeCurrent < safeTotal; + + return Row( + mainAxisSize: mainAxisSize, + children: _withSpacing([ + _buildButton( + icon: lucide.LucideIcons.chevronsLeft, + enabled: canGoPrev, + onTap: () => onPageSelected?.call(1), + ), + _buildButton( + icon: lucide.LucideIcons.chevronLeft, + enabled: canGoPrev, + onTap: () => onPageSelected?.call(safeCurrent - 1), + ), + _buildButton( + icon: lucide.LucideIcons.chevronRight, + enabled: canGoNext, + onTap: () => onPageSelected?.call(safeCurrent + 1), + ), + _buildButton( + icon: lucide.LucideIcons.chevronsRight, + enabled: canGoNext, + onTap: () => onPageSelected?.call(safeTotal), + ), + ]), + ); + } + + List _withSpacing(List children) { + final result = []; + for (var i = 0; i < children.length; i++) { + if (i > 0) { + result.add(SizedBox(width: spacing)); + } + result.add(children[i]); + } + return result; + } + + Widget _buildButton({ + required IconData icon, + required bool enabled, + required VoidCallback onTap, + }) { + return ShadButton.ghost( + size: size, + onPressed: enabled ? onTap : null, + child: Icon(icon, size: 16), + ); + } +} diff --git a/lib/widgets/components/superport_table.dart b/lib/widgets/components/superport_table.dart index c252b5b..83d19c9 100644 --- a/lib/widgets/components/superport_table.dart +++ b/lib/widgets/components/superport_table.dart @@ -3,6 +3,7 @@ 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'; +import 'package:superport_v2/widgets/components/superport_pagination_controls.dart'; /// 테이블 정렬 상태 정보를 보관하는 모델. class SuperportTableSortState { @@ -276,15 +277,14 @@ class _PaginationFooter extends StatelessWidget { @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); - final int totalPages = - pagination.totalPages <= 0 ? 1 : pagination.totalPages; + final int totalPages = pagination.totalPages <= 0 + ? 1 + : pagination.totalPages; final int currentPage = pagination.currentPage < 1 ? 1 : (pagination.currentPage > totalPages - ? totalPages - : pagination.currentPage); - final canGoPrev = currentPage > 1 && !isLoading; - final canGoNext = currentPage < totalPages && !isLoading; + ? totalPages + : pagination.currentPage); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -316,33 +316,11 @@ class _PaginationFooter extends StatelessWidget { style: theme.textTheme.small, ), const SizedBox(width: 12), - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: canGoPrev ? () => onPageChange?.call(1) : null, - child: const Icon(lucide.LucideIcons.chevronsLeft, size: 16), - ), - const SizedBox(width: 8), - 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), - ), - const SizedBox(width: 8), - ShadButton.ghost( - size: ShadButtonSize.sm, - onPressed: - canGoNext ? () => onPageChange?.call(totalPages) : null, - child: const Icon(lucide.LucideIcons.chevronsRight, size: 16), + SuperportPaginationControls( + currentPage: currentPage, + totalPages: totalPages, + onPageSelected: onPageChange, + isBusy: isLoading, ), ], ),