diff --git a/CLAUDE.md b/CLAUDE.md index fb02e6c1..5e76d64b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -271,6 +271,27 @@ const [data, setData] = useState(() => { --- +## Common Component Usage Rules +**Priority**: ๐Ÿ”ด + +์‹ ๊ทœ ํŽ˜์ด์ง€/๋ชจ๋‹ฌ ์ž‘์—… ์‹œ **๋ฐ˜๋“œ์‹œ** ๊ณตํ†ต ํŒจํ„ด ๊ฐ€์ด๋“œ๋ฅผ ๋จผ์ € ์ฝ๊ณ  ๊ธฐ์กด ๊ตฌ์กฐ๋ฅผ ๋”ฐ๋ฅผ ๊ฒƒ. + +**ํŠธ๋ฆฌ๊ฑฐ โ†’ ๊ฐ€์ด๋“œ ์ฝ๊ธฐ:** +| ์ž‘์—… ์œ ํ˜• | ์ฝ์„ ํŒŒ์ผ | +|-----------|----------| +| ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ/์„ ํƒ ํŒ์—… | `claudedocs/guides/[GUIDE] common-page-patterns.md` โ†’ "๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ" ์„น์…˜ | +| ๋ฆฌ์ŠคํŠธ/๋ชฉ๋ก ํŽ˜์ด์ง€ | `claudedocs/guides/[GUIDE] common-page-patterns.md` โ†’ "๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง€" ์„น์…˜ | +| ์ƒ์„ธ/์ˆ˜์ •/๋“ฑ๋ก ํŽ˜์ด์ง€ | `claudedocs/guides/[GUIDE] common-page-patterns.md` โ†’ "์ƒ์„ธ/ํผ ํŽ˜์ด์ง€" ์„น์…˜ | +| ์ƒˆ organisms ํ•„์š” | `src/components/organisms/index.ts` ๋จผ์ € ํ™•์ธ โ†’ ์—†์œผ๋ฉด ์ƒ์„ฑ | + +**ํ•ต์‹ฌ ์›์น™:** +- ์ƒˆ ํŒŒ์ผ ๋งŒ๋“ค๊ธฐ ์ „ `organisms/`, `molecules/` export ๋ชฉ๋ก ํ™•์ธ +- ๊ฒ€์ƒ‰+์„ ํƒ ๋ชจ๋‹ฌ โ†’ `SearchableSelectionModal` ์‚ฌ์šฉ (์ง์ ‘ Dialog ์กฐํ•ฉ ๊ธˆ์ง€) +- ๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง€ โ†’ `UniversalListPage` ๋˜๋Š” organisms ์กฐํ•ฉ +- ์ƒ์„ธ/ํผ โ†’ Card + ๊ธฐ์กด ํŒจํ„ด ๋”ฐ๋ฅด๊ธฐ + +--- + ## User Environment **Priority**: ๐ŸŸข diff --git a/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md b/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md index 763a9c00..432fbd97 100644 --- a/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md +++ b/claudedocs/architecture/[PLAN-2026-02-06] refactoring-roadmap.md @@ -2,7 +2,7 @@ **์ž‘์„ฑ์ผ**: 2026-02-06 **๋ชฉ์ **: ์ „์ฒด ์ฝ”๋“œ๋ฒ ์ด์Šค ๋ฆฌํŒฉํ† ๋ง ํฌ์ธํŠธ ์ ๊ฒ€ ๋ฐ ์‹คํ–‰ ๊ณ„ํš -**์ƒํƒœ**: Phase 1 ์™„๋ฃŒ, Phase 3 ํ”„๋กœํ† ํƒ€์ž… ๊ฒ€์ฆ ์™„๋ฃŒ +**์ƒํƒœ**: Phase 1 ์™„๋ฃŒ, Phase 3 ์™„๋ฃŒ (๊ณต์šฉ ์œ ํ‹ธ ์ถ”์ถœ), Phase 4 SearchableSelectionModal ์™„๋ฃŒ --- @@ -263,55 +263,99 @@ UniversalListPage ํ…œํ”Œ๋ฆฟ์ด ์ด๋ฏธ ๋‚ด๋ถ€ ์ฒ˜๋ฆฌ โ†’ ๋ถˆํ•„์š” ํŒ์ •. --- -### Phase 3: ์•ก์…˜ ํŒŒ์ผ ์ œ๋„ค๋ฆญํ™” (2-3์ฃผ) `ํ”„๋ก ํŠธ ๋‹จ๋…` `ํ”„๋กœํ† ํƒ€์ž… ๊ฒ€์ฆ ์™„๋ฃŒ` +### Phase 3: ์•ก์…˜ ํŒŒ์ผ ๊ณต์šฉ ์œ ํ‹ธ ์ถ”์ถœ โœ… ์™„๋ฃŒ (2026-02-10) -> CRUD ์„œ๋น„์Šค ํŒฉํ† ๋ฆฌ ์ƒ์„ฑ + actions.ts ์ ์ง„์  ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ +> ์ „์ˆ˜ ๋ถ„์„ โ†’ ํŒฉํ† ๋ฆฌ ROI ์žฌํ‰๊ฐ€ โ†’ ๊ณต์šฉ ์œ ํ‹ธ ์ถ”์ถœ๋กœ ์ „๋žต ๋ณ€๊ฒฝ -**ํ”„๋กœํ† ํƒ€์ž… ๊ฒฐ๊ณผ** (2026-02-09): +**์ „์ˆ˜ ๋ถ„์„ ๊ฒฐ๊ณผ** (82๊ฐœ action ํŒŒ์ผ): ``` -- [x] createCrudService ํŒฉํ† ๋ฆฌ ๊ตฌํ˜„ (src/lib/api/create-crud-service.ts) -- [x] RankManagement/actions.ts ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (111์ค„ โ†’ 77์ค„, -31%) -- [x] Server Action ํ˜ธํ™˜์„ฑ ๊ฒ€์ฆ ์™„๋ฃŒ (5/5 CRUD ์ •์ƒ ๋™์ž‘) -- [x] ๋ž˜ํผ ํ•จ์ˆ˜ ๋ฐฉ์‹ ์ฑ„ํƒ (Next.js Server Action ์ธ์‹ ๋ณด์žฅ) +- 35๊ฐœ: executeServerAction ํŒจํ„ด (Phase 1์—์„œ ํ†ต์ผ) +- 15๊ฐœ: ๋ชจ์˜ ๋ฐ์ดํ„ฐ (mock, API ๋ฏธ์—ฐ๋™) +- 13๊ฐœ: ApiClient ํด๋ž˜์Šค ํŒจํ„ด (๊ฑด์„ค ๋„๋ฉ”์ธ) +- ๋‚˜๋จธ์ง€: ํŠน์ˆ˜ ๋„๋ฉ”์ธ ๋กœ์ง (๊ฒฌ์ , ์ˆ˜์ฃผ, ํ’ˆ๋ชฉ ๋“ฑ) ``` -**๊ฒ€์ฆ๋œ ์‚ฌ์‹ค**: -- Server Action + ํŒฉํ† ๋ฆฌ ํŒจํ„ด ํ˜ธํ™˜์„ฑ ๋ฌธ์ œ ์—†์Œ -- ๋ž˜ํผ ํ•จ์ˆ˜ ํ•„์š” (์ง์ ‘ re-export๋Š” ๋ฏธ๊ฒ€์ฆ) -- Tier ๋ถ„๋ฅ˜: Tier 1 ์ •ํ˜• CRUD (~60%, 100% ์ ์šฉ) / Tier 2 CRUD+ํŠน์ˆ˜ (~25%, ๋ถ€๋ถ„ ์ ์šฉ) / Tier 3 ๋ณต์žก ๋„๋ฉ”์ธ (~15%, ๋ฏธ์ ์šฉ) - -**๋‚จ์€ ์ž‘์—…**: +**ํŒฉํ† ๋ฆฌ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ROI ์žฌํ‰๊ฐ€**: ``` -- [ ] Tier 1 settings ๋„๋ฉ”์ธ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (8๊ฐœ) -- [ ] Tier 1 ๊ธฐํƒ€ ์ •ํ˜• CRUD ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (~40๊ฐœ) -- [ ] Tier 2 CRUD ๋ถ€๋ถ„ ํŒฉํ† ๋ฆฌ ์ ์šฉ (~20๊ฐœ) -- [ ] PositionApiData ๋“ฑ ๊ณตํ†ต API ํƒ€์ž… ์ถ”์ถœ +- createCrudService ํŒฉํ† ๋ฆฌ: 2๊ฐœ(Rank, Title)๋งŒ ์ ํ•ฉ โ†’ ROI ~6% (๋„ˆ๋ฌด ๋‚ฎ์Œ) +- ๋Œ€๋ถ€๋ถ„ ํŒŒ์ผ: ํŽ˜์ด์ง€๋„ค์ด์…˜, ์ปค์Šคํ…€ ์ฟผ๋ฆฌ ํŒŒ๋ผ๋ฏธํ„ฐ, ๋„๋ฉ”์ธ ํŠนํ™” ๋กœ์ง์œผ๋กœ ํŒฉํ† ๋ฆฌ ํŒจํ„ด ๋ถ€์ ํ•ฉ +- ๊ฒฐ๋ก : ํŒฉํ† ๋ฆฌ ๋Œ€๋Ÿ‰ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋Œ€์‹  ๊ณต์šฉ ์œ ํ‹ธ ์ถ”์ถœ๋กœ ์ „๋žต ์ „ํ™˜ ``` -**์˜ˆ์ƒ ํšจ๊ณผ**: Tier 1 ๊ธฐ์ค€ ~31% ์ฝ”๋“œ ๊ฐ์†Œ, ์ƒˆ ๋„๋ฉ”์ธ ์ถ”๊ฐ€ ์‹œ๊ฐ„ 30๋ถ„โ†’5๋ถ„ +**์‹คํ–‰ ๊ฒฐ๊ณผ** (2026-02-10): +``` +Step 1: ๊ณต์šฉ ํƒ€์ž… ์ถ”์ถœ (src/lib/api/types.ts) + - [x] PaginatedApiResponse โ€” 25+ ํŒŒ์ผ์—์„œ ์ค‘๋ณต ์ •์˜ ์ œ๊ฑฐ + - [x] PaginationMeta, PaginatedResult โ€” ํ”„๋ก ํŠธ์—”๋“œ ํ‘œ์ค€ ํŽ˜์ด์ง€๋„ค์ด์…˜ ํƒ€์ž… + - [x] toPaginationMeta() โ€” snake_case โ†’ camelCase ๋ณ€ํ™˜ ํ—ฌํผ + - [x] SelectOption โ€” ๊ณต์šฉ ์„ ํƒ ์˜ต์…˜ ํƒ€์ž… + +Step 2: ๊ณต์šฉ ๋ฃฉ์—… ํ—ฌํผ ์ถ”์ถœ (src/lib/api/shared-lookups.ts) + - [x] fetchVendorOptions() โ€” ๊ฑฐ๋ž˜์ฒ˜ ๋ชฉ๋ก ์กฐํšŒ (4๊ฐœ ํŒŒ์ผ ์ค‘๋ณต ์ œ๊ฑฐ) + - [x] fetchBankAccountOptions() โ€” ๊ณ„์ขŒ ๋ชฉ๋ก ์กฐํšŒ (์‹ฌํ”Œ) + - [x] fetchBankAccountDetailOptions() โ€” ๊ณ„์ขŒ ์ƒ์„ธ ์กฐํšŒ (bankName, accountNumber ํฌํ•จ) + - [x] BankAccountOption ํƒ€์ž… + +Step 3: PaginatedResponse ํƒ€์ž… ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (~20๊ฐœ ํŒŒ์ผ) + - [x] ์ œ๋„ค๋ฆญ ํŒจํ„ด (interface PaginatedResponse) โ†’ import PaginatedApiResponse + - [x] ๋„๋ฉ”์ธ ํŒจํ„ด (interface XxxPaginatedResponse) โ†’ type alias + - ์Šคํ‚ต: VendorManagement/types.ts (page?/size? ๋น„ํ‘œ์ค€), PermissionManagement/types.ts (meta ๋ž˜ํผ) + +Step 4: ๊ณต์šฉ ๋ฃฉ์—… ํ—ฌํผ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (4๊ฐœ ํŒŒ์ผ) + - [x] DepositManagement/actions.ts โ€” getVendors + getBankAccounts ๊ต์ฒด + - [x] WithdrawalManagement/actions.ts โ€” getVendors + getBankAccounts ๊ต์ฒด + - [x] PurchaseManagement/actions.ts โ€” getVendors + getBankAccounts(์ƒ์„ธ) ๊ต์ฒด + - [x] ExpectedExpenseManagement/actions.ts โ€” getBankAccounts(์ƒ์„ธ) ๊ต์ฒด + +Step 5: TypeScript ๊ฒ€์ฆ ํ†ต๊ณผ โœ… +``` + +**์‹ค์ธก ํšจ๊ณผ**: +- PaginatedResponse ์ค‘๋ณต ์ œ๊ฑฐ: ~20๊ฐœ ํŒŒ์ผ, ํŒŒ์ผ๋‹น ~7์ค„ = ~140์ค„ ์ ˆ๊ฐ +- ๊ณต์šฉ ๋ฃฉ์—… ํ—ฌํผ: 4๊ฐœ ํŒŒ์ผ, ํŒŒ์ผ๋‹น ~20์ค„ = ~80์ค„ ์ ˆ๊ฐ +- ์ด ~220์ค„ ์ง์ ‘ ์ ˆ๊ฐ + ํ–ฅํ›„ ์ƒˆ ํŒŒ์ผ์—์„œ ์ค‘๋ณต ๋ฐฉ์ง€ +- createCrudService + TitleManagement ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜: ~36์ค„ ์ ˆ๊ฐ (ํ”„๋กœํ† ํƒ€์ž… ํฌํ•จ) + +**์ƒ์„ฑ๋œ ๊ณต์šฉ ํŒŒ์ผ**: +- `src/lib/api/types.ts` โ€” ๊ณต์šฉ API ํƒ€์ž… (PaginatedApiResponse, PaginationMeta ๋“ฑ) +- `src/lib/api/shared-lookups.ts` โ€” ๊ณต์šฉ ๋ฃฉ์—… ํ—ฌํผ (fetchVendorOptions ๋“ฑ) +- `src/lib/api/create-crud-service.ts` โ€” CRUD ํŒฉํ† ๋ฆฌ (Rank, Title 2๊ฐœ ์‚ฌ์šฉ) --- -### Phase 4: ํ…œํ”Œ๋ฆฟ/ํŒจํ„ด ํ†ต์ผ (2-3์ฃผ) `ํ”„๋ก ํŠธ ๋‹จ๋…` +### Phase 4: ํ…œํ”Œ๋ฆฟ/ํŒจํ„ด ํ†ต์ผ (2-3์ฃผ) `ํ”„๋ก ํŠธ ๋‹จ๋…` `SearchableSelectionModal ์™„๋ฃŒ` > UniversalListPage ํ™•๋Œ€ + ๊ฒ€์ฆ ํ‘œ์ค€ํ™” + ๋ชจ๋‹ฌ ํ†ตํ•ฉ -**์ž‘์—… ํ•ญ๋ชฉ**: +**SearchableSelectionModal ์™„๋ฃŒ** (2026-02-10): +``` +- [x] SearchableSelectionModal ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ + - types.ts, useSearchableData.ts, SearchableSelectionModal.tsx, index.ts + - ๋‹จ์ผ์„ ํƒ(single) + ๋‹ค์ค‘์„ ํƒ(multiple) + listWrapper(ํ…Œ์ด๋ธ”์šฉ) ์ง€์› +- [x] ItemSearchModal ๊ต์ฒด (212โ†’113์ค„, -47%) +- [x] SupplierSearchModal ๊ต์ฒด (268โ†’161์ค„, -40%) +- [x] SalesOrderSelectModal ๊ต์ฒด (163โ†’101์ค„, -38%) +- [x] QuotationSelectDialog ๊ต์ฒด (196โ†’113์ค„, -42%) +- [x] OrderSelectModal ๊ต์ฒด (220โ†’107์ค„, -51%) +- [x] organisms/index.ts export ์ถ”๊ฐ€ +- [x] CLAUDE.md ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ์‚ฌ์šฉ ๊ทœ์น™ + claudedocs ๊ฐ€์ด๋“œ ๋ฌธ์„œ ์ž‘์„ฑ +``` +**์‹ค์ธก ํšจ๊ณผ**: 1,059์ค„ โ†’ 595์ค„ (464์ค„ ์ ˆ๊ฐ, -44%) + ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ~430์ค„ + +**๋‚จ์€ ์ž‘์—…**: ``` - [ ] UniversalListPage ๊ธฐ๋Šฅ ๋ณด๊ฐ• - ๊ณ ๊ธ‰ ํ•„ํ„ฐ UI - ์ปฌ๋Ÿผ ์ปค์Šคํ„ฐ๋งˆ์ด์ง• - ๋‚ด๋ณด๋‚ด๊ธฐ ๊ธฐ๋Šฅ - [ ] ๋ ˆ๊ฑฐ์‹œ ๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง€ โ†’ UniversalListPage ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (์šฐ์„  20๊ฐœ) -- [ ] SearchableSelectionModal ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ์ƒ์„ฑ - - ItemSearchModal, AssigneeSelectModal ๋“ฑ 5๊ฐœ ํ†ตํ•ฉ - [ ] Zod ๊ฒ€์ฆ ์Šคํ‚ค๋งˆ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ๊ตฌ์ถ• - lib/validations/common.ts (์ด๋ฉ”์ผ, ์ „ํ™”, ์‚ฌ์—…์ž๋ฒˆํ˜ธ) - lib/validations/vendor.ts, order.ts, item.ts ๋“ฑ - [ ] ์ˆ˜๋™ ๊ฒ€์ฆ 50+ ํผ โ†’ Zod ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (์šฐ์„  10๊ฐœ) ``` -**์˜ˆ์ƒ ํšจ๊ณผ**: ~5,000์ค„ ์ ˆ๊ฐ, UX ์ผ๊ด€์„ฑ +80% +**์˜ˆ์ƒ ํšจ๊ณผ**: ~5,000์ค„ ์ ˆ๊ฐ (SearchableSelectionModal ~464์ค„ ๋‹ฌ์„ฑ), UX ์ผ๊ด€์„ฑ +80% --- @@ -337,7 +381,8 @@ UniversalListPage ํ…œํ”Œ๋ฆฟ์ด ์ด๋ฏธ ๋‚ด๋ถ€ ์ฒ˜๋ฆฌ โ†’ ๋ถˆํ•„์š” ํŒ์ •. - dev/ ํ”„๋กœํ† ํƒ€์ž… 6๊ฑด (๋น„ํ”„๋กœ๋•์…˜) - [ ] ๊ณตํ†ต ํƒ€์ž… ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์ •๋ฆฌ - types/shared/ ํด๋” ์ƒ์„ฑ - - ApiResponse, PaginatedResponse, FormState ๋“ฑ + - PaginatedApiResponse โœ… Phase 3์—์„œ ์™„๋ฃŒ (src/lib/api/types.ts) + - FormState, SelectOption ๋“ฑ ์ถ”๊ฐ€ ํƒ€์ž… ``` **์˜ˆ์ƒ ํšจ๊ณผ**: ๋ฆฌ์ŠคํŠธ ๋ Œ๋”๋ง 30-50% ๊ฐœ์„ , ํƒ€์ž… ์•ˆ์ „์„ฑ +60% @@ -346,12 +391,13 @@ UniversalListPage ํ…œํ”Œ๋ฆฟ์ด ์ด๋ฏธ ๋‚ด๋ถ€ ์ฒ˜๋ฆฌ โ†’ ๋ถˆํ•„์š” ํŒ์ •. ## ์ „์ฒด ์˜ˆ์ƒ ํšจ๊ณผ ์š”์•ฝ -| ์ง€ํ‘œ | Phase 1 โœ… | Phase 2 | Phase 3 | Phase 4 | Phase 5 | ํ•ฉ๊ณ„ | +| ์ง€ํ‘œ | Phase 1 โœ… | Phase 2 | Phase 3 โœ… | Phase 4 | Phase 5 | ํ•ฉ๊ณ„ | |------|-----------|---------|---------|---------|---------|------| -| ์ฝ”๋“œ ์ ˆ๊ฐ | ~3,750์ค„ (์‹ค์ธก) | (๊ตฌ์กฐ ๊ฐœ์„ ) | ~3,300์ค„ (์‹ค์ธก ๊ธฐ๋ฐ˜ ์ถ”์ •) | ~5,000์ค„ | (ํ’ˆ์งˆ ๊ฐœ์„ ) | **~12,000์ค„+** | -| ํŒจํ„ด ์ผ๊ด€์„ฑ | +60% | +50% | +40% | +80% | +60% | ์ข…ํ•ฉ ๊ฐœ์„  | -| ์œ ์ง€๋ณด์ˆ˜์„ฑ | ๋†’์Œ | ๋งค์šฐ ๋†’์Œ | ๋†’์Œ | ์ค‘๊ฐ„ | ์ค‘๊ฐ„ | ์ข…ํ•ฉ ๊ฐœ์„  | -| ์œ„ํ—˜๋„ | ๋‚ฎ์Œ | ์ค‘๊ฐ„ | ๋‚ฎ์Œ (๊ฒ€์ฆ๋จ) | ๋‚ฎ์Œ | ๋‚ฎ์Œ | - | +| ์ฝ”๋“œ ์ ˆ๊ฐ | ~3,750์ค„ (์‹ค์ธก) | (๊ตฌ์กฐ ๊ฐœ์„ ) | ~256์ค„ (์‹ค์ธก) | ~5,000์ค„ | (ํ’ˆ์งˆ ๊ฐœ์„ ) | **~9,000์ค„+** | +| ์ค‘๋ณต ์ œ๊ฑฐ | 82๊ฐœ action ํ†ต์ผ | - | 25+ ํƒ€์ž… + 4 ๋ฃฉ์—… ํ†ตํ•ฉ | 5 ๋ชจ๋‹ฌ ํ†ตํ•ฉ | - | ์ข…ํ•ฉ ๊ฐœ์„  | +| ํŒจํ„ด ์ผ๊ด€์„ฑ | +60% | +50% | +30% (ํƒ€์ž… ํ‘œ์ค€ํ™”) | +80% | +60% | ์ข…ํ•ฉ ๊ฐœ์„  | +| ์œ ์ง€๋ณด์ˆ˜์„ฑ | ๋†’์Œ | ๋งค์šฐ ๋†’์Œ | ์ค‘๊ฐ„ (๊ณต์šฉ ์œ ํ‹ธ) | ์ค‘๊ฐ„ | ์ค‘๊ฐ„ | ์ข…ํ•ฉ ๊ฐœ์„  | +| ์œ„ํ—˜๋„ | ๋‚ฎ์Œ | ์ค‘๊ฐ„ | ๋‚ฎ์Œ (์™„๋ฃŒ) | ๋‚ฎ์Œ | ๋‚ฎ์Œ | - | --- @@ -359,15 +405,14 @@ UniversalListPage ํ…œํ”Œ๋ฆฟ์ด ์ด๋ฏธ ๋‚ด๋ถ€ ์ฒ˜๋ฆฌ โ†’ ๋ถˆํ•„์š” ํŒ์ •. ``` [์™„๋ฃŒ] -โ”œโ”€ Phase 1 (๊ณตํ†ต ํ›…) โ”€โ”€โ†’ โœ… ์™„๋ฃŒ (2026-02-09) +โ”œโ”€ Phase 1 (๊ณตํ†ต ํ›…) โ”€โ”€โ”€โ”€โ”€โ”€โ†’ โœ… ์™„๋ฃŒ (2026-02-09) +โ”œโ”€ Phase 3 (๊ณต์šฉ ์œ ํ‹ธ ์ถ”์ถœ) โ”€โ”€โ†’ โœ… ์™„๋ฃŒ (2026-02-10) +โ”œโ”€ Phase 4 (SearchableSelectionModal) โ†’ โœ… ์™„๋ฃŒ (2026-02-10) โ”‚ [์ฆ‰์‹œ ์‹œ์ž‘ ๊ฐ€๋Šฅ] -โ”œโ”€ Phase 2 (God ์ปดํฌ๋„ŒํŠธ ๋ถ„๋ฆฌ) โ”€โ”€โ†’ Phase 1 ํ›… ํ™œ์šฉ -โ”œโ”€ Phase 3 (์•ก์…˜ ์ œ๋„ค๋ฆญํ™”) โ”€โ”€โ”€โ”€โ†’ ํ”„๋กœํ† ํƒ€์ž… ๊ฒ€์ฆ ์™„๋ฃŒ, ๋ณธ๊ฒฉ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๊ฐ€๋Šฅ -โ”œโ”€ Phase 5 (์„ฑ๋Šฅ/ํƒ€์ž…) โ”€โ”€โ”€โ”€โ”€โ†’ ์ผ๋ถ€ Phase 1์—์„œ ์„ ์ฒ˜๋ฆฌ๋จ -โ”‚ -[Phase 3 ์™„๋ฃŒ ํ›„] -โ””โ”€ Phase 4 (ํ…œํ”Œ๋ฆฟ ํ†ต์ผ) โ”€โ”€โ”€โ”€โ”€โ†’ ํ›… + ์„œ๋น„์Šค ํ™œ์šฉ +โ”œโ”€ Phase 2 (God ์ปดํฌ๋„ŒํŠธ ๋ถ„๋ฆฌ) โ”€โ”€โ†’ Phase 1 ํ›… + Phase 3 ๊ณต์šฉ ํƒ€์ž… ํ™œ์šฉ +โ”œโ”€ Phase 4 ๋‚จ์€ ์ž‘์—… (UniversalListPage ํ™•๋Œ€, Zod ๊ฒ€์ฆ) +โ”œโ”€ Phase 5 (์„ฑ๋Šฅ/ํƒ€์ž…) โ”€โ”€โ”€โ”€โ”€โ†’ ์ผ๋ถ€ Phase 1/3์—์„œ ์„ ์ฒ˜๋ฆฌ๋จ ``` --- @@ -393,6 +438,8 @@ UniversalListPage ํ…œํ”Œ๋ฆฟ์ด ์ด๋ฏธ ๋‚ด๋ถ€ ์ฒ˜๋ฆฌ โ†’ ๋ถˆํ•„์š” ํŒ์ •. | 2026-02-06 | ์ดˆ๊ธฐ ์ž‘์„ฑ - ์ „์ฒด ์ฝ”๋“œ๋ฒ ์ด์Šค ๋ถ„์„ ๊ธฐ๋ฐ˜ 5 Phase ๋กœ๋“œ๋งต | | 2026-02-09 | Phase 1 ์™„๋ฃŒ ๋ฐ˜์˜ - ์‹ค์ธก ๊ธฐ๋ฐ˜ ํšจ๊ณผ ์ˆ˜์น˜ ๋ณด์ • (8,500์ค„โ†’3,750์ค„), executeServerAction/useDeleteDialog/useStatsLoader 3๊ฐœ ํ›… ์ƒ์„ฑ ์™„๋ฃŒ | | 2026-02-09 | Phase 3 ํ”„๋กœํ† ํƒ€์ž… ๊ฒ€์ฆ ์™„๋ฃŒ - createCrudService ํŒฉํ† ๋ฆฌ ์ƒ์„ฑ, RankManagement 5/5 CRUD ์ •์ƒ, Server Action ํ˜ธํ™˜์„ฑ ํ™•์ธ | +| 2026-02-10 | Phase 4 SearchableSelectionModal ์™„๋ฃŒ - 5๊ฐœ ๋ชจ๋‹ฌ ํ†ตํ•ฉ, 464์ค„ ์ ˆ๊ฐ(-44%), ๊ฐ€์ด๋“œ ๋ฌธ์„œ ์ž‘์„ฑ | +| 2026-02-10 | Phase 3 ์™„๋ฃŒ - ์ „์ˆ˜ ๋ถ„์„ ํ›„ ํŒฉํ† ๋ฆฌ ROI ์žฌํ‰๊ฐ€(~6%), ๊ณต์šฉ ์œ ํ‹ธ ์ถ”์ถœ๋กœ ์ „๋žต ์ „ํ™˜. PaginatedApiResponse 25+ํŒŒ์ผ ํƒ€์ž… ํ†ตํ•ฉ, ๊ณต์šฉ ๋ฃฉ์—… ํ—ฌํผ 4ํŒŒ์ผ ์ค‘๋ณต ์ œ๊ฑฐ, ~256์ค„ ์ ˆ๊ฐ | --- diff --git a/claudedocs/guides/[GUIDE] common-page-patterns.md b/claudedocs/guides/[GUIDE] common-page-patterns.md new file mode 100644 index 00000000..067f76c7 --- /dev/null +++ b/claudedocs/guides/[GUIDE] common-page-patterns.md @@ -0,0 +1,522 @@ +# SAM ํ”„๋กœ์ ํŠธ ๊ณตํ†ต ํŽ˜์ด์ง€/์ปดํฌ๋„ŒํŠธ ํŒจํ„ด ๊ฐ€์ด๋“œ + +์‹ ๊ทœ ํŽ˜์ด์ง€ยท๋ชจ๋‹ฌ ์ž‘์—… ์‹œ ์ด ๋ฌธ์„œ๋ฅผ ์ฐธ๊ณ ํ•˜์—ฌ ๊ธฐ์กด ๊ตฌ์กฐ์™€ ์ผ๊ด€์„ฑ์„ ์œ ์ง€ํ•œ๋‹ค. + +--- + +## ๋ชฉ์ฐจ + +1. [๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๋งต](#1-๊ณตํ†ต-์ปดํฌ๋„ŒํŠธ-๋งต) +2. [๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ (SearchableSelectionModal)](#2-๊ฒ€์ƒ‰-๋ชจ๋‹ฌ) +3. [๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง€](#3-๋ฆฌ์ŠคํŠธ-ํŽ˜์ด์ง€) +4. [์ƒ์„ธ/ํผ ํŽ˜์ด์ง€](#4-์ƒ์„ธํผ-ํŽ˜์ด์ง€) +5. [API ์—ฐ๋™ ํŒจํ„ด](#5-api-์—ฐ๋™-ํŒจํ„ด) +6. [ํŽ˜์ด์ง€ ๋ผ์šฐํŒ… ๊ตฌ์กฐ](#6-ํŽ˜์ด์ง€-๋ผ์šฐํŒ…-๊ตฌ์กฐ) + +--- + +## 1. ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๋งต + +### Organisms (`src/components/organisms/`) + +| ์ปดํฌ๋„ŒํŠธ | ์šฉ๋„ | ์ฃผ์š” Props | +|----------|------|-----------| +| `PageHeader` | ํŽ˜์ด์ง€ ์ œ๋ชฉ, ์„ค๋ช…, ์•„์ด์ฝ˜, ์•ก์…˜ ๋ฒ„ํŠผ | title, description, icon, actions | +| `PageLayout` | ์ตœ๋Œ€ ๋„ˆ๋น„ ๋ž˜ํผ + ๋ฒ„์ „ ์ •๋ณด | children, maxWidth? | +| `StatCards` | ํ†ต๊ณ„ ์นด๋“œ ๊ทธ๋ฆฌ๋“œ | stats[], onStatClick? | +| `SearchFilter` | ๊ฒ€์ƒ‰ ์ž…๋ ฅ + ๋ชจ๋ฐ”์ผ ํ•„ํ„ฐ | searchTerm, onSearchChange, placeholder | +| `DataTable` | ํ…Œ์ด๋ธ” + ํŽ˜์ด์ง€๋„ค์ด์…˜ + ์ •๋ ฌ | columns, renderRow, pagination | +| `MobileCard` / `ListMobileCard` | ๋ชจ๋ฐ”์ผ ์นด๋“œ ๋ ˆ์ด์•„์›ƒ | id, title, infoGrid, badges | +| `EmptyState` | ๋นˆ ์ƒํƒœ (์•„์ด์ฝ˜ + ๋ฉ”์‹œ์ง€ + ์•ก์…˜) | icon, title, description, action | +| `FormSection` | ์นด๋“œ ๋ž˜ํผ (์•„์ด์ฝ˜ + ์ œ๋ชฉ + ์„ค๋ช…) | icon, title, description | +| `FormFieldGrid` | ๋ฐ˜์‘ํ˜• ํ•„๋“œ ๊ทธ๋ฆฌ๋“œ (1~4์—ด) | cols, children | +| `FormActions` | ์ €์žฅ/์ทจ์†Œ ๋ฒ„ํŠผ ๊ทธ๋ฃน | onSave, onCancel, isSaving | +| **`SearchableSelectionModal`** | **๊ฒ€์ƒ‰ โ†’ ๋ชฉ๋ก โ†’ ์„ ํƒ ๋ชจ๋‹ฌ** | **fetchData, renderItem, mode** | + +### Molecules (`src/components/molecules/`) + +| ์ปดํฌ๋„ŒํŠธ | ์šฉ๋„ | +|----------|------| +| `StatusBadge` | ์ƒํƒœ ๋ฑƒ์ง€ (์ƒ‰์ƒ ์ž๋™) | +| `TableActions` | ํ…Œ์ด๋ธ” ํ–‰ ์•ก์…˜ ๋ฒ„ํŠผ | +| `StandardDialog` / `ConfirmDialog` | ํ™•์ธ/๊ฒฝ๊ณ  ๋‹ค์ด์–ผ๋กœ๊ทธ | +| `YearQuarterFilter` | ์—ฐ๋„/๋ถ„๊ธฐ ํ•„ํ„ฐ | +| `MobileFilter` | ๋ชจ๋ฐ”์ผ ํ•„ํ„ฐ UI | + +### Templates (`src/components/templates/`) + +| ์ปดํฌ๋„ŒํŠธ | ์šฉ๋„ | +|----------|------| +| `UniversalListPage` | ๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง€ ์˜ฌ์ธ์› ํ…œํ”Œ๋ฆฟ | + +--- + +## 2. ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ + +### ์–ธ์ œ ์‚ฌ์šฉํ•˜๋‚˜ + +"๊ฒ€์ƒ‰ โ†’ ๋ชฉ๋ก โ†’ ์„ ํƒ" ํŒจํ„ด์ด ํ•„์š”ํ•  ๋•Œ โ†’ `SearchableSelectionModal` ์‚ฌ์šฉ. +Dialog + Input + ๋ฆฌ์ŠคํŠธ๋ฅผ ์ง์ ‘ ์กฐํ•ฉํ•˜์ง€ ์•Š๋Š”๋‹ค. + +### ์œ„์น˜ + +``` +src/components/organisms/SearchableSelectionModal/ +โ”œโ”€โ”€ SearchableSelectionModal.tsx โ€” ๋ฉ”์ธ ์ปดํฌ๋„ŒํŠธ +โ”œโ”€โ”€ useSearchableData.ts โ€” ๊ฒ€์ƒ‰+๋กœ๋”ฉ ํ›… +โ”œโ”€โ”€ types.ts โ€” Props ์ธํ„ฐํŽ˜์ด์Šค +โ””โ”€โ”€ index.ts +``` + +### ํ•ต์‹ฌ Props + +```typescript +SearchableSelectionModal + // ํ•„์ˆ˜ + open: boolean + onOpenChange: (open: boolean) => void + title: ReactNode + fetchData: (query: string) => Promise // API ํ˜ธ์ถœ ์œ„์ž„ + keyExtractor: (item: T) => string + renderItem: (item: T, isSelected: boolean) => ReactNode + mode: 'single' | 'multiple' + onSelect: single โ†’ (item: T) | multiple โ†’ (items: T[]) + + // ๊ฒ€์ƒ‰ ์„ค์ • + searchPlaceholder?: string + searchMode?: 'debounce' | 'enter' // ๊ธฐ๋ณธ: debounce + validateSearch?: (q: string) => boolean // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + loadOnOpen?: boolean // ์—ด๋ฆด ๋•Œ ์ž๋™ ๋กœ๋“œ + + // ๋ฉ”์‹œ์ง€ + emptyQueryMessage?: string + invalidSearchMessage?: string + noResultMessage?: string + loadingMessage?: string + + // ๋ ˆ์ด์•„์›ƒ + dialogClassName?: string + listContainerClassName?: string + listWrapper?: (children, selectState?) => ReactNode // Table ๋“ฑ ์ปค์Šคํ…€ ๊ตฌ์กฐ + infoText?: (items, isLoading) => ReactNode + + // ๋‹ค์ค‘์„ ํƒ ์ „์šฉ + confirmLabel?: string + allowSelectAll?: boolean +``` + +### ํŒจํ„ด๋ณ„ ์˜ˆ์ œ + +#### A. ๋‹จ์ผ์„ ํƒ + ๋””๋ฐ”์šด์Šค ๊ฒ€์ƒ‰ (๊ฐ€์žฅ ์ผ๋ฐ˜์ ) + +```tsx +// ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰, ๊ฑฐ๋ž˜์ฒ˜ ๊ฒ€์ƒ‰ ๋“ฑ + + open={open} + onOpenChange={setOpen} + title="ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰" + searchPlaceholder="ํ’ˆ๋ชฉ์ฝ”๋“œ ๋˜๋Š” ํ’ˆ๋ชฉ๋ช… ๊ฒ€์ƒ‰..." + fetchData={async (q) => fetchItems({ search: q, per_page: 50 })} + keyExtractor={(item) => item.id} + validateSearch={(q) => /[a-zA-Z๊ฐ€-ํžฃ0-9]/.test(q)} + emptyQueryMessage="๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”" + dialogClassName="sm:max-w-[500px]" + mode="single" + onSelect={(item) => { /* ์„ ํƒ ์ฒ˜๋ฆฌ */ }} + renderItem={(item) => ( +
+ {item.code} + {item.name} +
+ )} +/> +``` + +#### B. ๋‹จ์ผ์„ ํƒ + ์นด๋“œ UI + ์—ด๋ฆด ๋•Œ ์ž๋™ ๋กœ๋“œ + +```tsx +// ์ˆ˜์ฃผ ์„ ํƒ, ๊ฒฌ์  ์„ ํƒ ๋“ฑ + + open={open} + onOpenChange={setOpen} + title="์ˆ˜์ฃผ ์„ ํƒ" + fetchData={async (q) => { /* API ํ˜ธ์ถœ + toast ์—๋Ÿฌ ์ฒ˜๋ฆฌ */ }} + keyExtractor={(order) => order.id} + loadOnOpen // โ† ์—ด๋ฆด ๋•Œ ์ „์ฒด ๋กœ๋“œ + dialogClassName="sm:max-w-lg" + listContainerClassName="max-h-[400px] overflow-y-auto space-y-2" + mode="single" + onSelect={onSelect} + renderItem={(order) => ( +
+ {/* ์นด๋“œํ˜• UI */} +
+ )} +/> +``` + +#### C. ๋‹ค์ค‘์„ ํƒ + Enter ๊ฒ€์ƒ‰ + ํ…Œ์ด๋ธ” + +```tsx +// ์ˆ˜์ฃผ ๋‹ค์ค‘์„ ํƒ (์ฒดํฌ๋ฐ•์Šค ํ…Œ์ด๋ธ”) + + open={open} + onOpenChange={setOpen} + title="์ˆ˜์ฃผ ์„ ํƒ" + fetchData={handleFetchData} + keyExtractor={(item) => item.id} + searchMode="enter" // โ† ์ˆ˜๋™ ๊ฒ€์ƒ‰ + loadOnOpen + dialogClassName="sm:max-w-2xl" + listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md" + mode="multiple" + onSelect={onSelect} + confirmLabel="์„ ํƒ" + allowSelectAll + listWrapper={(children, selectState) => ( // โ† Table ๊ตฌ์กฐ ๋ž˜ํ•‘ + + + + + {selectState && ( + + )} + + ์ˆ˜์ฃผ๋ฒˆํ˜ธ + ํ˜„์žฅ๋ช… + + + {children} +
+ )} + renderItem={(item, isSelected) => ( + + e.stopPropagation()}> + + + {item.orderNumber} + {item.siteName} + + )} +/> +``` + +### ๊ธฐ์กด ๋ชจ๋‹ฌ โ†’ ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๋งคํ•‘ + +| ๊ธฐ์กด ๋ชจ๋‹ฌ | ์œ„์น˜ | ํŒจํ„ด | +|-----------|------|------| +| `ItemSearchModal` | quotes/ | A (๋‹จ์ผ + ๋””๋ฐ”์šด์Šค) | +| `SupplierSearchModal` | material/ReceivingManagement/ | A (๋‹จ์ผ + ๋””๋ฐ”์šด์Šค) | +| `SalesOrderSelectModal` | production/WorkOrders/ | B (๋‹จ์ผ + ์นด๋“œ + loadOnOpen) | +| `QuotationSelectDialog` | orders/ | B (๋‹จ์ผ + ์นด๋“œ + loadOnOpen) | +| `OrderSelectModal` | quality/InspectionManagement/ | C (๋‹ค์ค‘ + Enter + ํ…Œ์ด๋ธ”) | + +--- + +## 3. ๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง€ + +### ๋ฐฉ๋ฒ• 1: UniversalListPage ํ…œํ”Œ๋ฆฟ (๊ถŒ์žฅ) + +`src/components/templates/UniversalListPage`์— config ๊ฐ์ฒด๋ฅผ ์ „๋‹ฌํ•˜๋Š” ์˜ฌ์ธ์› ๋ฐฉ์‹. + +```tsx +'use client'; + +import { UniversalListPage } from '@/components/templates/UniversalListPage'; +import type { UniversalListPageConfig } from '@/components/templates/UniversalListPage'; + +export default function MyListPage() { + const config: UniversalListPageConfig = { + title: '๋ชฉ๋ก ์ œ๋ชฉ', + description: '์„ค๋ช…', + icon: ListIcon, + basePath: '/path/to/list', + idField: 'id', + + // ํ†ต๊ณ„ + stats: [ + { label: '์ „์ฒด', value: totalCount, icon: Users }, + ], + + // ํƒญ + tabs: [ + { value: 'all', label: '์ „์ฒด', count: totalCount }, + { value: 'active', label: 'ํ™œ์„ฑ', count: activeCount }, + ], + + // ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ + columns: [ + { key: 'name', label: '์ด๋ฆ„' }, + { key: 'status', label: '์ƒํƒœ' }, + ], + + // ๊ฒ€์ƒ‰ + searchPlaceholder: '์ด๋ฆ„, ์ฝ”๋“œ ๊ฒ€์ƒ‰...', + searchFilter: (item, q) => item.name.includes(q), + tabFilter: (item, tab) => tab === 'all' || item.status === tab, + + // ๋ Œ๋”๋ง + renderTableRow, + renderMobileCard, + headerActions: () => , + }; + + return ; +} +``` + +### ๋ฐฉ๋ฒ• 2: Organisms ์ง์ ‘ ์กฐํ•ฉ + +UniversalListPage๊ฐ€ ๋งž์ง€ ์•Š๋Š” ๊ฒฝ์šฐ organisms๋ฅผ ์ง์ ‘ ์กฐํ•ฉ. + +```tsx +'use client'; + +import { PageLayout, PageHeader, StatCards, SearchFilter, DataTable, EmptyState } from '@/components/organisms'; + +export function MyList() { + return ( + + ์‹ ๊ทœ} /> + + + {data.length > 0 ? ( + + ) : ( + + )} + + ); +} +``` + +### ๋ฆฌ์ŠคํŠธ ํŽ˜์ด์ง€ ๊ณตํ†ต ๊ทœ์น™ + +- **๊ฒ€์ƒ‰ ๋””๋ฐ”์šด์Šค**: 300ms +- **ํ…Œ์ด๋ธ” ์ปฌ๋Ÿผ ์ˆœ์„œ**: ์ฒดํฌ๋ฐ•์Šค โ†’ ๋ฒˆํ˜ธ โ†’ ๋ฐ์ดํ„ฐ ์ปฌ๋Ÿผ โ†’ ์ž‘์—… +- **๋ฒˆํ˜ธ ๊ณ„์‚ฐ**: `(currentPage - 1) * pageSize + index + 1` +- **๋ชจ๋ฐ”์ผ**: `ListMobileCard` ๋˜๋Š” `MobileCard` ์‚ฌ์šฉ +- **๋นˆ ์ƒํƒœ**: `EmptyState` ์‚ฌ์šฉ (๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—†์Œ vs ๋ฐ์ดํ„ฐ ์—†์Œ ๊ตฌ๋ถ„) +- **์‚ญ์ œ ํ™•์ธ**: `ConfirmDialog` ์‚ฌ์šฉ (alert ๊ธˆ์ง€) + +--- + +## 4. ์ƒ์„ธ/ํผ ํŽ˜์ด์ง€ + +### ํ‘œ์ค€ ๊ตฌ์กฐ + +```tsx +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; + +interface DetailProps { + id?: string; + mode: 'view' | 'edit' | 'new'; +} + +export function MyDetail({ id, mode }: DetailProps) { + const isViewMode = mode === 'view'; + const isNewMode = mode === 'new'; + const router = useRouter(); + + // ์ƒํƒœ (๋ชจ๋“  hook์€ ์ตœ์ƒ๋‹จ์— โ€” ์กฐ๊ฑด๋ถ€ return ์ „์—) + const [isLoading, setIsLoading] = useState(!isNewMode); + const [isSaving, setIsSaving] = useState(false); + const [formData, setFormData] = useState({ name: '', code: '' }); + + // ๋ฐ์ดํ„ฐ ๋กœ๋“œ (view/edit) + useEffect(() => { + if (!id || isNewMode) { setIsLoading(false); return; } + getDetail(id).then(data => { + setFormData(data); + setIsLoading(false); + }); + }, [id, isNewMode]); + + // ์ €์žฅ + const handleSubmit = async () => { + setIsSaving(true); + try { + if (isNewMode) await create(formData); + else await update(id!, formData); + toast.success('์ €์žฅ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'); + router.back(); + } catch { + toast.error('์ €์žฅ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + } finally { + setIsSaving(false); + } + }; + + if (isLoading) return ; + + return ( +
+ {/* ํ—ค๋” */} +
+

+ {isNewMode ? '์‹ ๊ทœ ๋“ฑ๋ก' : isViewMode ? '์ƒ์„ธ ๋ณด๊ธฐ' : '์ˆ˜์ •'} +

+
+ + {/* ์„น์…˜ 1 */} + + ๊ธฐ๋ณธ ์ •๋ณด + +
+
+ + setFormData(prev => ({ ...prev, name: e.target.value }))} + disabled={isViewMode} + /> +
+
+
+
+ + {/* ํ•˜๋‹จ ๋ฒ„ํŠผ */} +
+ + {isViewMode ? ( + + ) : ( + + )} +
+
+ ); +} +``` + +### ์ƒ์„ธ/ํผ ํŽ˜์ด์ง€ ๊ณตํ†ต ๊ทœ์น™ + +- **๋ชจ๋“œ**: `view` | `edit` | `new` 3๊ฐ€์ง€ +- **Hook ๊ทœ์น™**: ๋ชจ๋“  hook์€ ์ตœ์ƒ๋‹จ, ์กฐ๊ฑด๋ถ€ return์€ ๊ทธ ์•„๋ž˜ +- **๋ ˆ์ด์•„์›ƒ**: `Card > CardHeader + CardContent` ์„น์…˜ ๋‹จ์œ„ +- **ํ•„๋“œ ๊ทธ๋ฆฌ๋“œ**: `grid grid-cols-1 md:grid-cols-2 gap-4` +- **disabled**: view ๋ชจ๋“œ์—์„œ ๋ชจ๋“  ์ž…๋ ฅ ๋น„ํ™œ์„ฑํ™” +- **์•Œ๋ฆผ**: `toast.success()` / `toast.error()` (sonner) +- **๋„ค๋น„๊ฒŒ์ด์…˜**: `router.back()` ๋˜๋Š” `router.push()` +- **๋กœ๋”ฉ**: Skeleton ์ปดํฌ๋„ŒํŠธ ์‚ฌ์šฉ +- **Select ๋ฒ„๊ทธ ๋Œ€์‘**: ` setSearchQuery(e.target.value)} - className="pl-10 pr-10" - /> - {searchQuery && ( - - )} - - - {/* ๊ฑฐ๋ž˜์ฒ˜ ๋ชฉ๋ก */} -
- {isLoading ? ( -
- - ๊ฑฐ๋ž˜์ฒ˜ ๊ฒ€์ƒ‰ ์ค‘... -
- ) : error ? ( -
- {error} -
- ) : suppliers.length === 0 ? ( -
- {!searchQuery - ? 'ํ•œ๊ธ€ 1์ž(์™„์„ฑํ˜•) ๋˜๋Š” ์˜๋ฌธ 2์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”' - : !isValidSearchQuery(searchQuery) - ? 'ํ•œ๊ธ€ 1์ž(์™„์„ฑํ˜•) ๋˜๋Š” ์˜๋ฌธ 2์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”' - : '๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค'} -
- ) : ( -
- {suppliers.map((supplier, index) => ( -
handleSelect(supplier)} - className="p-3 hover:bg-blue-50 cursor-pointer transition-colors" - > -
- {supplier.name} - {supplier.clientCode && ( - - {supplier.clientCode} - - )} -
- {supplier.contactPerson && ( -

๋‹ด๋‹น: {supplier.contactPerson}

- )} -
- ))} -
- )} -
- - {/* ๊ฑฐ๋ž˜์ฒ˜ ๊ฐœ์ˆ˜ ํ‘œ์‹œ */} - {!isLoading && !error && ( -
- ์ด {suppliers.length}๊ฐœ ๊ฑฐ๋ž˜์ฒ˜ + + open={open} + onOpenChange={onOpenChange} + title="๋ฐœ์ฃผ์ฒ˜ ๊ฒ€์ƒ‰" + searchPlaceholder="๊ฑฐ๋ž˜์ฒ˜๋ช… ๊ฒ€์ƒ‰..." + fetchData={handleFetchData} + keyExtractor={(s) => `${s.id}`} + validateSearch={isValidSearchQuery} + invalidSearchMessage="ํ•œ๊ธ€ 1์ž(์™„์„ฑํ˜•) ๋˜๋Š” ์˜๋ฌธ 2์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”" + emptyQueryMessage="ํ•œ๊ธ€ 1์ž(์™„์„ฑํ˜•) ๋˜๋Š” ์˜๋ฌธ 2์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”" + loadingMessage="๊ฑฐ๋ž˜์ฒ˜ ๊ฒ€์ƒ‰ ์ค‘..." + dialogClassName="sm:max-w-[500px]" + infoText={(items, isLoading) => + !isLoading ? ( + + ์ด {items.length}๊ฐœ ๊ฑฐ๋ž˜์ฒ˜ + + ) : null + } + mode="single" + onSelect={handleSelect} + renderItem={(supplier) => ( +
+
+ {supplier.name} + {supplier.clientCode && ( + + {supplier.clientCode} + + )}
- )} - - + {supplier.contactPerson && ( +

๋‹ด๋‹น: {supplier.contactPerson}

+ )} +
+ )} + /> ); -} \ No newline at end of file +} diff --git a/src/components/material/ReceivingManagement/actions.ts b/src/components/material/ReceivingManagement/actions.ts index bc74d8ac..5e17abbb 100644 --- a/src/components/material/ReceivingManagement/actions.ts +++ b/src/components/material/ReceivingManagement/actions.ts @@ -18,6 +18,7 @@ const USE_MOCK_DATA = false; import { serverFetch } from '@/lib/api/fetch-wrapper'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { executeServerAction } from '@/lib/api/execute-server-action'; @@ -360,13 +361,7 @@ interface ReceivingApiData { has_inspection_template?: boolean; } -interface ReceivingApiPaginatedResponse { - data: ReceivingApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type ReceivingApiPaginatedResponse = PaginatedApiResponse; interface ReceivingApiStatsResponse { receiving_pending_count: number; diff --git a/src/components/material/StockStatus/actions.ts b/src/components/material/StockStatus/actions.ts index 72d74926..ea912018 100644 --- a/src/components/material/StockStatus/actions.ts +++ b/src/components/material/StockStatus/actions.ts @@ -12,6 +12,7 @@ import { executeServerAction } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { StockItem, StockDetail, @@ -84,13 +85,7 @@ interface StockLotApiData { updated_at?: string; } -interface ItemApiPaginatedResponse { - data: ItemApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type ItemApiPaginatedResponse = PaginatedApiResponse; interface StockApiStatsResponse { total_items: number; diff --git a/src/components/orders/QuotationSelectDialog.tsx b/src/components/orders/QuotationSelectDialog.tsx index acb4c90c..55d07412 100644 --- a/src/components/orders/QuotationSelectDialog.tsx +++ b/src/components/orders/QuotationSelectDialog.tsx @@ -1,25 +1,19 @@ -"use client"; - /** * ๊ฒฌ์  ์„ ํƒ ํŒ์—… * - * ํ™•์ •๋œ ๊ฒฌ์  ๋ชฉ๋ก์—์„œ ์ˆ˜์ฃผ ์ „ํ™˜ํ•  ๊ฒฌ์ ์„ ์„ ํƒํ•˜๋Š” ๋‹ค์ด์–ผ๋กœ๊ทธ - * API ์—ฐ๋™: getQuotesForSelect (FINALIZED ์ƒํƒœ ๊ฒฌ์ ๋งŒ ์กฐํšŒ) + * SearchableSelectionModal ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๊ธฐ๋ฐ˜ + * ํ™•์ •๋œ ๊ฒฌ์  ๋ชฉ๋ก์—์„œ ์ˆ˜์ฃผ ์ „ํ™˜ํ•  ๊ฒฌ์ ์„ ์„ ํƒ */ -import { useState, useEffect, useCallback } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Search, FileText, Check, Loader2 } from "lucide-react"; -import { formatAmount } from "@/utils/formatAmount"; -import { cn } from "@/lib/utils"; -import { getQuotesForSelect, type QuotationForSelect } from "./actions"; +'use client'; + +import { useCallback } from 'react'; +import { FileText, Check } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { formatAmount } from '@/utils/formatAmount'; +import { cn } from '@/lib/utils'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; +import { getQuotesForSelect, type QuotationForSelect } from './actions'; interface QuotationSelectDialogProps { open: boolean; @@ -31,13 +25,13 @@ interface QuotationSelectDialogProps { // ๋“ฑ๊ธ‰ ๋ฐฐ์ง€ ์ปดํฌ๋„ŒํŠธ function GradeBadge({ grade }: { grade: string }) { const config: Record = { - A: { label: "A (์šฐ๋Ÿ‰)", className: "bg-green-100 text-green-700 border-green-200" }, - B: { label: "B (๊ด€๋ฆฌ)", className: "bg-yellow-100 text-yellow-700 border-yellow-200" }, - C: { label: "C (์ฃผ์˜)", className: "bg-red-100 text-red-700 border-red-200" }, + A: { label: 'A (์šฐ๋Ÿ‰)', className: 'bg-green-100 text-green-700 border-green-200' }, + B: { label: 'B (๊ด€๋ฆฌ)', className: 'bg-yellow-100 text-yellow-700 border-yellow-200' }, + C: { label: 'C (์ฃผ์˜)', className: 'bg-red-100 text-red-700 border-red-200' }, }; const cfg = config[grade] || config.B; return ( - + {cfg.label} ); @@ -49,148 +43,71 @@ export function QuotationSelectDialog({ onSelect, selectedId, }: QuotationSelectDialogProps) { - const [searchTerm, setSearchTerm] = useState(""); - const [quotations, setQuotations] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // ๊ฒฌ์  ๋ชฉ๋ก ์กฐํšŒ - const fetchQuotations = useCallback(async (query?: string) => { - setIsLoading(true); - setError(null); - try { - const result = await getQuotesForSelect({ q: query, size: 50 }); - if (result.success && result.data) { - setQuotations(result.data.items); - } else { - setError(result.error || "๊ฒฌ์  ๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); - setQuotations([]); - } - } catch { - setError("์„œ๋ฒ„ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค."); - setQuotations([]); - } finally { - setIsLoading(false); + const handleFetchData = useCallback(async (query: string) => { + const result = await getQuotesForSelect({ q: query || undefined, size: 50 }); + if (result.success && result.data) { + return result.data.items; } + throw new Error(result.error || '๊ฒฌ์  ๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); }, []); - // ๋‹ค์ด์–ผ๋กœ๊ทธ ์—ด๋ฆด ๋•Œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ + ๊ฒ€์ƒ‰์–ด ๋ณ€๊ฒฝ ์‹œ ๋””๋ฐ”์šด์Šค ์ ์šฉ - useEffect(() => { - if (!open) return; - - // ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋นˆ ๋ฌธ์ž์—ด์ด๋ฉด ์ฆ‰์‹œ ํ˜ธ์ถœ (๋‹ค์ด์–ผ๋กœ๊ทธ ์—ด๋ฆผ ์‹œ) - // ๊ฒ€์ƒ‰์–ด๊ฐ€ ์žˆ์œผ๋ฉด ๋””๋ฐ”์šด์Šค ์ ์šฉ - const delay = searchTerm === "" ? 0 : 300; - - const timer = setTimeout(() => { - fetchQuotations(searchTerm || undefined); - }, delay); - - return () => clearTimeout(timer); - }, [searchTerm, open, fetchQuotations]); - - const handleSelect = (quotation: QuotationForSelect) => { - onSelect(quotation); - onOpenChange(false); - }; - return ( - - - - - - ๊ฒฌ์  ์„ ํƒ - - - - {/* ๊ฒ€์ƒ‰์ฐฝ */} -
- - setSearchTerm(e.target.value)} - className="pl-9" - /> -
- - {/* ์•ˆ๋‚ด ๋ฌธ๊ตฌ */} -
- {isLoading ? ( - - - ๊ฒฌ์  ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘... - - ) : error ? ( - {error} - ) : ( - `์ „ํ™˜ ๊ฐ€๋Šฅํ•œ ๊ฒฌ์  ${quotations.length}๊ฑด (์ตœ์ข…ํ™•์ • ์ƒํƒœ)` + + open={open} + onOpenChange={onOpenChange} + title={ + + + ๊ฒฌ์  ์„ ํƒ + + } + searchPlaceholder="๊ฒฌ์ ๋ฒˆํ˜ธ, ๊ฑฐ๋ž˜์ฒ˜, ํ˜„์žฅ๋ช… ๊ฒ€์ƒ‰..." + fetchData={handleFetchData} + keyExtractor={(q) => q.id} + loadOnOpen + dialogClassName="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" + listContainerClassName="flex-1 overflow-y-auto space-y-3 pr-2" + infoText={(items, isLoading) => + isLoading + ? null + : `์ „ํ™˜ ๊ฐ€๋Šฅํ•œ ๊ฒฌ์  ${items.length}๊ฑด (์ตœ์ข…ํ™•์ • ์ƒํƒœ)` + } + mode="single" + onSelect={onSelect} + renderItem={(quotation) => ( +
- - {/* ๊ฒฌ์  ๋ชฉ๋ก */} -
- {isLoading ? ( -
- + > +
+
+ + {quotation.quoteNumber} + +
- ) : ( - <> - {quotations.map((quotation) => ( -
handleSelect(quotation)} - className={cn( - "p-4 border rounded-lg cursor-pointer transition-colors", - "hover:bg-muted/50 hover:border-primary/50", - selectedId === quotation.id && "border-primary bg-primary/5" - )} - > - {/* ์ƒ๋‹จ: ๊ฒฌ์ ๋ฒˆํ˜ธ + ๋“ฑ๊ธ‰ */} -
-
- - {quotation.quoteNumber} - - -
- {selectedId === quotation.id && ( - - )} -
- - {/* ๋ฐœ์ฃผ์ฒ˜ */} -
- {quotation.client} -
- - {/* ํ˜„์žฅ๋ช… + ๊ธˆ์•ก */} -
- - [{quotation.siteName}] - - - {formatAmount(quotation.amount)}์› - -
- - {/* ํ’ˆ๋ชฉ ์ˆ˜ */} -
- {quotation.itemCount}๊ฐœ ํ’ˆ๋ชฉ -
-
- ))} - - {quotations.length === 0 && !error && ( -
- ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. -
- )} - - )} + {selectedId === quotation.id && ( + + )} +
+
+ {quotation.client} +
+
+ + [{quotation.siteName}] + + + {formatAmount(quotation.amount)}์› + +
+
+ {quotation.itemCount}๊ฐœ ํ’ˆ๋ชฉ +
- -
+ )} + /> ); -} \ No newline at end of file +} diff --git a/src/components/orders/actions.ts b/src/components/orders/actions.ts index c09dbed9..1e005723 100644 --- a/src/components/orders/actions.ts +++ b/src/components/orders/actions.ts @@ -1,6 +1,7 @@ 'use server'; import { executeServerAction } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; // ============================================================================ // API ํƒ€์ž… ์ •์˜ @@ -219,14 +220,6 @@ interface ApiResponse { data: T; } -interface PaginatedResponse { - current_page: number; - data: T[]; - last_page: number; - per_page: number; - total: number; -} - // ============================================================================ // Frontend ํƒ€์ž… ์ •์˜ // ============================================================================ @@ -815,7 +808,7 @@ export async function getOrders(params?: { if (params?.date_from) searchParams.set('date_from', params.date_from); if (params?.date_to) searchParams.set('date_to', params.date_to); - const result = await executeServerAction>({ + const result = await executeServerAction>({ url: `${API_URL}/api/v1/orders?${searchParams.toString()}`, errorMessage: '๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', }); @@ -1179,7 +1172,7 @@ export async function getQuotesForSelect(params?: { if (params?.page) searchParams.set('page', String(params.page)); if (params?.size) searchParams.set('size', String(params.size || 50)); - const result = await executeServerAction>({ + const result = await executeServerAction>({ url: `${API_URL}/api/v1/quotes?${searchParams.toString()}`, errorMessage: '๊ฒฌ์  ๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', }); diff --git a/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx b/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx new file mode 100644 index 00000000..394625be --- /dev/null +++ b/src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx @@ -0,0 +1,252 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { Search, X, Loader2 } from 'lucide-react'; + +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; + +import { useSearchableData } from './useSearchableData'; +import type { SearchableSelectionModalProps } from './types'; + +export function SearchableSelectionModal(props: SearchableSelectionModalProps) { + const { + open, + onOpenChange, + title, + searchPlaceholder = '๊ฒ€์ƒ‰...', + fetchData, + keyExtractor, + renderItem, + searchMode = 'debounce', + debounceDelay = 300, + validateSearch, + invalidSearchMessage, + loadOnOpen = false, + emptyQueryMessage = '๊ฒ€์ƒ‰์–ด๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”', + noResultMessage = '๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.', + loadingMessage = '๊ฒ€์ƒ‰ ์ค‘...', + dialogClassName, + listContainerClassName = 'max-h-[400px] overflow-y-auto border rounded-lg', + listWrapper, + infoText, + mode, + } = props; + + const { + searchQuery, + setSearchQuery, + items, + isLoading, + error, + triggerSearch, + handleSearchKeyDown, + } = useSearchableData({ + open, + fetchData, + searchMode, + debounceDelay, + validateSearch, + loadOnOpen, + }); + + // ๋‹ค์ค‘์„ ํƒ ์ƒํƒœ + const [selectedIds, setSelectedIds] = useState>(new Set()); + + // ๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ์„ ํƒ ์ดˆ๊ธฐํ™” + useEffect(() => { + if (open) { + setSelectedIds(new Set()); + } + }, [open]); + + // ๋‹จ์ผ์„ ํƒ ํ•ธ๋“ค๋Ÿฌ + const handleSingleSelect = useCallback((item: T) => { + if (mode === 'single') { + (props as { onSelect: (item: T) => void }).onSelect(item); + onOpenChange(false); + } + }, [mode, props, onOpenChange]); + + // ๋‹ค์ค‘์„ ํƒ ํ† ๊ธ€ + const handleToggle = useCallback((id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + // ์ „์ฒด์„ ํƒ ํ† ๊ธ€ + const handleToggleAll = useCallback(() => { + setSelectedIds((prev) => { + if (prev.size === items.length) { + return new Set(); + } + return new Set(items.map((item) => keyExtractor(item))); + }); + }, [items, keyExtractor]); + + // ๋‹ค์ค‘์„ ํƒ ํ™•์ธ + const handleConfirm = useCallback(() => { + if (mode === 'multiple') { + const selectedItems = items.filter((item) => selectedIds.has(keyExtractor(item))); + (props as { onSelect: (items: T[]) => void }).onSelect(selectedItems); + onOpenChange(false); + } + }, [mode, items, selectedIds, keyExtractor, props, onOpenChange]); + + // ํด๋ฆญ ํ•ธ๋“ค๋Ÿฌ: ๋ชจ๋“œ์— ๋”ฐ๋ผ ๋ถ„๊ธฐ + const handleItemClick = useCallback((item: T) => { + if (mode === 'single') { + handleSingleSelect(item); + } else { + handleToggle(keyExtractor(item)); + } + }, [mode, handleSingleSelect, handleToggle, keyExtractor]); + + const isAllSelected = items.length > 0 && selectedIds.size === items.length; + const isSelected = (item: T) => selectedIds.has(keyExtractor(item)); + + // ๋นˆ ์ƒํƒœ ๋ฉ”์‹œ์ง€ ๊ฒฐ์ • + const getEmptyMessage = () => { + if (error) return null; // error๋Š” ๋ณ„๋„ ํ‘œ์‹œ + if (!searchQuery && !loadOnOpen) return emptyQueryMessage; + if (searchQuery && validateSearch && !validateSearch(searchQuery)) { + return invalidSearchMessage || emptyQueryMessage; + } + return noResultMessage; + }; + + // ๋ฆฌ์ŠคํŠธ ์ฝ˜ํ…์ธ  ๋ Œ๋”๋ง + const renderListContent = () => { + if (isLoading) { + return ( +
+ + {loadingMessage} +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (items.length === 0) { + return ( +
+ {getEmptyMessage()} +
+ ); + } + + const itemElements = items.map((item) => ( +
handleItemClick(item)} className="cursor-pointer"> + {renderItem(item, isSelected(item))} +
+ )); + + if (listWrapper) { + const selectState = mode === 'multiple' + ? { isAllSelected, onToggleAll: handleToggleAll } + : undefined; + return listWrapper(<>{itemElements}, selectState); + } + + return
{itemElements}
; + }; + + const multiProps = mode === 'multiple' ? props as Extract : null; + + return ( + + + + {title} + + + {/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ */} + {searchMode === 'enter' ? ( +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder={searchPlaceholder} + className="pl-9" + /> +
+ +
+ ) : ( +
+ + setSearchQuery(e.target.value)} + className="pl-10 pr-10" + /> + {searchQuery && ( + + )} +
+ )} + + {/* ์ •๋ณด ํ…์ŠคํŠธ */} + {infoText && ( +
+ {infoText(items, isLoading)} +
+ )} + + {/* ๋‹ค์ค‘์„ ํƒ ํ—ค๋” (์ „์ฒด์„ ํƒ ๋“ฑ) */} + {mode === 'multiple' && multiProps?.renderHeader && ( + multiProps.renderHeader({ isAllSelected, onToggleAll: handleToggleAll }) + )} + + {/* ๋ฆฌ์ŠคํŠธ */} +
+ {renderListContent()} +
+ + {/* ๋‹ค์ค‘์„ ํƒ ํ‘ธํ„ฐ */} + {mode === 'multiple' && ( + + + + + )} +
+
+ ); +} diff --git a/src/components/organisms/SearchableSelectionModal/index.ts b/src/components/organisms/SearchableSelectionModal/index.ts new file mode 100644 index 00000000..5caa3922 --- /dev/null +++ b/src/components/organisms/SearchableSelectionModal/index.ts @@ -0,0 +1,6 @@ +export { SearchableSelectionModal } from './SearchableSelectionModal'; +export type { + SearchableSelectionModalProps, + SingleSelectProps, + MultipleSelectProps, +} from './types'; diff --git a/src/components/organisms/SearchableSelectionModal/types.ts b/src/components/organisms/SearchableSelectionModal/types.ts new file mode 100644 index 00000000..8e42df8c --- /dev/null +++ b/src/components/organisms/SearchableSelectionModal/types.ts @@ -0,0 +1,84 @@ +import { ReactNode } from 'react'; + +// ============================================================================= +// ๊ณตํ†ต Props +// ============================================================================= + +interface BaseProps { + /** ๋ชจ๋‹ฌ ์—ด๋ฆผ ์ƒํƒœ */ + open: boolean; + /** ๋ชจ๋‹ฌ ์—ด๋ฆผ/๋‹ซํž˜ ์ œ์–ด */ + onOpenChange: (open: boolean) => void; + /** ๋ชจ๋‹ฌ ์ œ๋ชฉ */ + title: ReactNode; + /** ๊ฒ€์ƒ‰ placeholder */ + searchPlaceholder?: string; + /** ๋ฐ์ดํ„ฐ ์กฐํšŒ ํ•จ์ˆ˜ (๊ฒ€์ƒ‰์–ด โ†’ ๊ฒฐ๊ณผ ๋ฐฐ์—ด) */ + fetchData: (query: string) => Promise; + /** ๊ณ ์œ  ํ‚ค ์ถ”์ถœ */ + keyExtractor: (item: T) => string; + /** ์•„์ดํ…œ ๋ Œ๋”๋ง */ + renderItem: (item: T, isSelected: boolean) => ReactNode; + + // ๊ฒ€์ƒ‰ ์„ค์ • + /** ๊ฒ€์ƒ‰ ๋ชจ๋“œ: debounce(์ž๋™) vs enter(์ˆ˜๋™) */ + searchMode?: 'debounce' | 'enter'; + /** ๋””๋ฐ”์šด์Šค ๋”œ๋ ˆ์ด (ms) - searchMode='debounce'์ผ ๋•Œ */ + debounceDelay?: number; + /** ๊ฒ€์ƒ‰์–ด ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ (false๋ฉด ๊ฒ€์ƒ‰ ์•ˆ ํ•จ) */ + validateSearch?: (query: string) => boolean; + /** ์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฒ€์ƒ‰์–ด์ผ ๋•Œ ๋ฉ”์‹œ์ง€ */ + invalidSearchMessage?: string; + /** ๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ์ž๋™ ๋กœ๋“œ ์—ฌ๋ถ€ */ + loadOnOpen?: boolean; + /** ๊ฒ€์ƒ‰์–ด ์—†์„ ๋•Œ ์•ˆ๋‚ด ๋ฉ”์‹œ์ง€ */ + emptyQueryMessage?: string; + /** ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ์—†์„ ๋•Œ ๋ฉ”์‹œ์ง€ */ + noResultMessage?: string; + /** ๋กœ๋”ฉ ๋ฉ”์‹œ์ง€ */ + loadingMessage?: string; + + // ๋ ˆ์ด์•„์›ƒ + /** Dialog ์ตœ๋Œ€ ๋„ˆ๋น„ ํด๋ž˜์Šค */ + dialogClassName?: string; + /** ๋ฆฌ์ŠคํŠธ ์ปจํ…Œ์ด๋„ˆ ํด๋ž˜์Šค */ + listContainerClassName?: string; + /** ๋ฆฌ์ŠคํŠธ ๋ž˜ํผ (Table ํ—ค๋” ๋“ฑ ์ปค์Šคํ…€ ๊ตฌ์กฐ) */ + listWrapper?: (children: ReactNode, selectState?: { + isAllSelected: boolean; + onToggleAll: () => void; + }) => ReactNode; + /** ํ‘ธํ„ฐ ์ƒ๋‹จ ์ •๋ณด ์˜์—ญ (์˜ˆ: "์ด X๊ฑด") */ + infoText?: (items: T[], isLoading: boolean) => ReactNode; +} + +// ============================================================================= +// ๋‹จ์ผ ์„ ํƒ +// ============================================================================= + +export interface SingleSelectProps extends BaseProps { + mode: 'single'; + onSelect: (item: T) => void; +} + +// ============================================================================= +// ๋‹ค์ค‘ ์„ ํƒ +// ============================================================================= + +export interface MultipleSelectProps extends BaseProps { + mode: 'multiple'; + onSelect: (items: T[]) => void; + /** ํ™•์ธ ๋ฒ„ํŠผ ๋ผ๋ฒจ (๊ธฐ๋ณธ: "์„ ํƒ") */ + confirmLabel?: string; + /** ์ „์ฒด์„ ํƒ ํ—ˆ์šฉ */ + allowSelectAll?: boolean; + /** ํ—ค๋” ์˜์—ญ (์ „์ฒด์„ ํƒ ์ฒดํฌ๋ฐ•์Šค ๋“ฑ) */ + renderHeader?: (params: { + isAllSelected: boolean; + onToggleAll: () => void; + }) => ReactNode; +} + +export type SearchableSelectionModalProps = + | SingleSelectProps + | MultipleSelectProps; diff --git a/src/components/organisms/SearchableSelectionModal/useSearchableData.ts b/src/components/organisms/SearchableSelectionModal/useSearchableData.ts new file mode 100644 index 00000000..654670e2 --- /dev/null +++ b/src/components/organisms/SearchableSelectionModal/useSearchableData.ts @@ -0,0 +1,119 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +interface UseSearchableDataOptions { + open: boolean; + fetchData: (query: string) => Promise; + searchMode: 'debounce' | 'enter'; + debounceDelay: number; + validateSearch?: (query: string) => boolean; + loadOnOpen: boolean; +} + +interface UseSearchableDataReturn { + searchQuery: string; + setSearchQuery: (query: string) => void; + items: T[]; + isLoading: boolean; + error: string | null; + triggerSearch: () => void; + handleSearchKeyDown: (e: React.KeyboardEvent) => void; +} + +export function useSearchableData({ + open, + fetchData, + searchMode, + debounceDelay, + validateSearch, + loadOnOpen, +}: UseSearchableDataOptions): UseSearchableDataReturn { + const [searchQuery, setSearchQuery] = useState(''); + const [items, setItems] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const prevOpenRef = useRef(false); + + // ์‹ค์ œ API ํ˜ธ์ถœ + const doFetch = useCallback(async (query: string) => { + setIsLoading(true); + setError(null); + try { + const data = await fetchData(query); + setItems(data); + } catch (err) { + console.error('[useSearchableData] fetch error:', err); + setError('๋ฐ์ดํ„ฐ๋ฅผ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + setItems([]); + } finally { + setIsLoading(false); + } + }, [fetchData]); + + // ๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ์ดˆ๊ธฐํ™” + loadOnOpen + useEffect(() => { + if (open && !prevOpenRef.current) { + // ๋ฐฉ๊ธˆ ์—ด๋ฆผ + setSearchQuery(''); + setError(null); + if (loadOnOpen) { + doFetch(''); + } else { + setItems([]); + } + } + if (!open && prevOpenRef.current) { + // ๋ฐฉ๊ธˆ ๋‹ซํž˜ + setItems([]); + setSearchQuery(''); + setError(null); + } + prevOpenRef.current = open; + }, [open, loadOnOpen, doFetch]); + + // ๋””๋ฐ”์šด์Šค ๋ชจ๋“œ: ๊ฒ€์ƒ‰์–ด ๋ณ€๊ฒฝ ์‹œ ์ž๋™ ๊ฒ€์ƒ‰ + useEffect(() => { + if (!open || searchMode !== 'debounce') return; + + // ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋น„์–ด์žˆ๊ณ  loadOnOpen์ด๋ฉด ์ด๋ฏธ ๋กœ๋“œ๋จ โ†’ ์Šคํ‚ต + if (!searchQuery && loadOnOpen) return; + + // ๊ฒ€์ƒ‰์–ด๊ฐ€ ๋น„์–ด์žˆ๊ณ  loadOnOpen์ด ์•„๋‹ˆ๋ฉด โ†’ ๊ฒฐ๊ณผ ์ดˆ๊ธฐํ™” + if (!searchQuery && !loadOnOpen) { + setItems([]); + return; + } + + // ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ + if (validateSearch && !validateSearch(searchQuery)) { + setItems([]); + return; + } + + const timer = setTimeout(() => { + doFetch(searchQuery); + }, debounceDelay); + + return () => clearTimeout(timer); + }, [searchQuery, open, searchMode, debounceDelay, validateSearch, doFetch, loadOnOpen]); + + // ์ˆ˜๋™ ๊ฒ€์ƒ‰ ํŠธ๋ฆฌ๊ฑฐ (enter ๋ชจ๋“œ) + const triggerSearch = useCallback(() => { + doFetch(searchQuery); + }, [searchQuery, doFetch]); + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + triggerSearch(); + } + }, [triggerSearch]); + + return { + searchQuery, + setSearchQuery, + items, + isLoading, + error, + triggerSearch, + handleSearchKeyDown, + }; +} diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 4051ef19..19dd1193 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -8,3 +8,5 @@ export { MobileCard, ListMobileCard, InfoField } from "./MobileCard"; export type { MobileCardProps, InfoFieldProps } from "./MobileCard"; export { EmptyState } from "./EmptyState"; export { ScreenVersionHistory } from "./ScreenVersionHistory"; +export { SearchableSelectionModal } from "./SearchableSelectionModal"; +export type { SearchableSelectionModalProps, SingleSelectProps, MultipleSelectProps } from "./SearchableSelectionModal"; diff --git a/src/components/outbound/ShipmentManagement/actions.ts b/src/components/outbound/ShipmentManagement/actions.ts index 50b59976..9dc54c7a 100644 --- a/src/components/outbound/ShipmentManagement/actions.ts +++ b/src/components/outbound/ShipmentManagement/actions.ts @@ -19,6 +19,7 @@ import { executeServerAction } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { ShipmentItem, ShipmentDetail, @@ -111,13 +112,7 @@ interface ShipmentItemApiData { remarks?: string; } -interface ShipmentApiPaginatedResponse { - data: ShipmentApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type ShipmentApiPaginatedResponse = PaginatedApiResponse; interface ShipmentApiStatsResponse { today_shipment_count: number; diff --git a/src/components/process-management/actions.ts b/src/components/process-management/actions.ts index 14c1bcaa..bd32738c 100644 --- a/src/components/process-management/actions.ts +++ b/src/components/process-management/actions.ts @@ -2,6 +2,7 @@ import { executeServerAction } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { Process, ProcessFormData, ClassificationRule, IndividualItem, ProcessStep } from '@/types/process'; // ============================================================================ @@ -65,14 +66,6 @@ interface ApiResponse { data: T; } -interface PaginatedResponse { - current_page: number; - data: T[]; - last_page: number; - per_page: number; - total: number; -} - // ============================================================================ // ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ํ•จ์ˆ˜ // ============================================================================ @@ -228,7 +221,7 @@ export async function getProcessList(params?: { if (params?.status) searchParams.set('status', params.status); if (params?.process_type) searchParams.set('process_type', params.process_type); - const result = await executeServerAction>({ + const result = await executeServerAction>({ url: `${API_URL}/api/v1/processes?${searchParams.toString()}`, errorMessage: '๊ณต์ • ๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', }); diff --git a/src/components/production/WorkOrders/SalesOrderSelectModal.tsx b/src/components/production/WorkOrders/SalesOrderSelectModal.tsx index 3740ac11..8e69acdb 100644 --- a/src/components/production/WorkOrders/SalesOrderSelectModal.tsx +++ b/src/components/production/WorkOrders/SalesOrderSelectModal.tsx @@ -1,38 +1,19 @@ -'use client'; - /** * ์ˆ˜์ฃผ ์„ ํƒ ๋ชจ๋‹ฌ - * API ์—ฐ๋™ ์™„๋ฃŒ (2025-12-26) + * + * SearchableSelectionModal ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๊ธฐ๋ฐ˜ */ -import { useState, useEffect, useCallback } from 'react'; -import { Search, FileText } from 'lucide-react'; -import { ContentSkeleton } from '@/components/ui/skeleton'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { Input } from '@/components/ui/input'; +'use client'; + +import { useCallback } from 'react'; import { Badge } from '@/components/ui/badge'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; import { getSalesOrdersForWorkOrder } from './actions'; import type { SalesOrder } from './types'; -// Debounce ํ›… -function useDebounce(value: T, delay: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); - - useEffect(() => { - const timer = setTimeout(() => setDebouncedValue(value), delay); - return () => clearTimeout(timer); - }, [value, delay]); - - return debouncedValue; -} - interface SalesOrderSelectModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -44,23 +25,13 @@ export function SalesOrderSelectModal({ onOpenChange, onSelect, }: SalesOrderSelectModalProps) { - const [searchTerm, setSearchTerm] = useState(''); - const [salesOrders, setSalesOrders] = useState([]); - const [isLoading, setIsLoading] = useState(false); - - // ๋””๋ฐ”์šด์Šค๋œ ๊ฒ€์ƒ‰์–ด (300ms ๋”œ๋ ˆ์ด) - const debouncedSearchTerm = useDebounce(searchTerm, 300); - - // API๋กœ ์ˆ˜์ฃผ ๋ชฉ๋ก ๋กœ๋“œ - const loadSalesOrders = useCallback(async () => { - setIsLoading(true); + const handleFetchData = useCallback(async (query: string) => { try { const result = await getSalesOrdersForWorkOrder({ - q: debouncedSearchTerm || undefined, + q: query || undefined, }); if (result.success) { - // API ์‘๋‹ต์„ SalesOrder ํƒ€์ž…์œผ๋กœ ๋ณ€ํ™˜ - const orders: SalesOrder[] = result.data.map((item) => ({ + return result.data.map((item) => ({ id: String(item.id), orderNo: item.orderNo, client: item.client, @@ -70,94 +41,61 @@ export function SalesOrderSelectModal({ itemCount: item.itemCount, splitCount: item.splitCount, })); - setSalesOrders(orders); - } else { - toast.error(result.error || '์ˆ˜์ฃผ ๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); } + toast.error(result.error || '์ˆ˜์ฃผ ๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + return []; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[SalesOrderSelectModal] loadSalesOrders error:', error); toast.error('์ˆ˜์ฃผ ๋ชฉ๋ก ๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } finally { - setIsLoading(false); + return []; } - }, [debouncedSearchTerm]); - - // ๋ชจ๋‹ฌ์ด ์—ด๋ฆด ๋•Œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ - useEffect(() => { - if (open) { - loadSalesOrders(); - } - }, [open, loadSalesOrders]); - - const handleSelect = (order: SalesOrder) => { - onSelect(order); - onOpenChange(false); - }; + }, []); return ( - - - - ์ˆ˜์ฃผ ์„ ํƒ - - - {/* ๊ฒ€์ƒ‰ */} -
- - setSearchTerm(e.target.value)} - className="pl-9" - /> -
- - {/* ์•ˆ๋‚ด ๋ฌธ๊ตฌ */} -

- ์ž‘์—…์ง€์‹œ ๊ฐ€๋Šฅํ•œ ์ˆ˜์ฃผ {salesOrders.length}๊ฑด (๋“ฑ๋ก ์ƒํƒœ & ์ƒ์‚ฐ์ง€์‹œ ๋ฏธ์ƒ์„ฑ) -

- - {/* ์ˆ˜์ฃผ ๋ชฉ๋ก */} -
- {isLoading ? ( - - ) : salesOrders.map((order) => ( -
handleSelect(order)} - className="p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors" - > -
-
- {order.orderNo} - - {order.status} - -
-
- ๋‚ฉ๊ธฐ: - {order.dueDate} -
-
-
- {order.client} -
-
{order.projectName}
-
- {order.itemCount}๊ฐœ ํ’ˆ๋ชฉ - ๋ถ„ํ•  {order.splitCount}๊ฑด -
+ + open={open} + onOpenChange={onOpenChange} + title="์ˆ˜์ฃผ ์„ ํƒ" + searchPlaceholder="์ˆ˜์ฃผ๋ฒˆํ˜ธ, ๊ฑฐ๋ž˜์ฒ˜, ํ˜„์žฅ๋ช… ๊ฒ€์ƒ‰..." + fetchData={handleFetchData} + keyExtractor={(order) => order.id} + loadOnOpen + dialogClassName="sm:max-w-lg" + listContainerClassName="max-h-[400px] overflow-y-auto space-y-2" + noResultMessage="" + infoText={(items, isLoading) => + !isLoading ? ( + ์ž‘์—…์ง€์‹œ ๊ฐ€๋Šฅํ•œ ์ˆ˜์ฃผ {items.length}๊ฑด (๋“ฑ๋ก ์ƒํƒœ & ์ƒ์‚ฐ์ง€์‹œ ๋ฏธ์ƒ์„ฑ) + ) : null + } + mode="single" + onSelect={onSelect} + renderItem={(order) => ( +
+
+
+ {order.orderNo} + + {order.status} +
- ))} - {!isLoading && salesOrders.length === 0 && ( -
- -

๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.

+
+ ๋‚ฉ๊ธฐ: + {order.dueDate}
- )} +
+
+ {order.client} +
+
{order.projectName}
+
+ {order.itemCount}๊ฐœ ํ’ˆ๋ชฉ + ๋ถ„ํ•  {order.splitCount}๊ฑด +
- -
+ )} + listWrapper={undefined} + /> ); -} \ No newline at end of file +} diff --git a/src/components/quality/InspectionManagement/OrderSelectModal.tsx b/src/components/quality/InspectionManagement/OrderSelectModal.tsx index e82da4dd..4821eceb 100644 --- a/src/components/quality/InspectionManagement/OrderSelectModal.tsx +++ b/src/components/quality/InspectionManagement/OrderSelectModal.tsx @@ -1,26 +1,15 @@ -'use client'; - /** - * ์ˆ˜์ฃผ ์„ ํƒ ๋ชจ๋‹ฌ + * ์ˆ˜์ฃผ ์„ ํƒ ๋ชจ๋‹ฌ (๋‹ค์ค‘์„ ํƒ) * - * ๊ธฐํš์„œ ๊ธฐ๋ฐ˜ ์‹ ๊ทœ ์ƒ์„ฑ: - * - ๊ฒ€์ƒ‰ ์ž…๋ ฅ + * SearchableSelectionModal ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๊ธฐ๋ฐ˜ * - ์ฒดํฌ๋ฐ•์Šค ํ…Œ์ด๋ธ” (์ˆ˜์ฃผ๋ฒˆํ˜ธ, ํ˜„์žฅ๋ช…, ๋‚ฉํ’ˆ์ผ, ๊ฐœ์†Œ) - * - ์ทจ์†Œ/์„ ํƒ ๋ฒ„ํŠผ + * - ์ „์ฒด์„ ํƒ/๊ฐœ๋ณ„์„ ํƒ */ -import { useState, useCallback, useEffect } from 'react'; -import { Search, Loader2 } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; +'use client'; + +import { useCallback } from 'react'; import { Checkbox } from '@/components/ui/checkbox'; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; import { Table, TableBody, @@ -30,6 +19,7 @@ import { TableRow, } from '@/components/ui/table'; import { toast } from 'sonner'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; import { getOrderSelectList } from './actions'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import type { OrderSelectItem } from './types'; @@ -48,173 +38,73 @@ export function OrderSelectModal({ onSelect, excludeIds = [], }: OrderSelectModalProps) { - const [searchTerm, setSearchTerm] = useState(''); - const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); - - // ๋ฐ์ดํ„ฐ ๋กœ๋“œ - const loadItems = useCallback(async (q?: string) => { - setIsLoading(true); + const handleFetchData = useCallback(async (query: string) => { try { - const result = await getOrderSelectList({ q: q || undefined }); + const result = await getOrderSelectList({ q: query || undefined }); if (result.success) { - // ์ด๋ฏธ ์„ ํƒ๋œ ํ•ญ๋ชฉ ์ œ์™ธ - const filtered = result.data.filter((item) => !excludeIds.includes(item.id)); - setItems(filtered); - } else { - toast.error(result.error || '์ˆ˜์ฃผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + return result.data.filter((item) => !excludeIds.includes(item.id)); } + toast.error(result.error || '์ˆ˜์ฃผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.'); + return []; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[OrderSelectModal] loadItems error:', error); toast.error('์ˆ˜์ฃผ ๋ชฉ๋ก ๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'); - } finally { - setIsLoading(false); + return []; } }, [excludeIds]); - // ๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ๋ฐ์ดํ„ฐ ๋กœ๋“œ & ์ƒํƒœ ์ดˆ๊ธฐํ™” - useEffect(() => { - if (open) { - setSearchTerm(''); - setSelectedIds(new Set()); - loadItems(); - } - }, [open, loadItems]); - - // ๊ฒ€์ƒ‰ - const handleSearch = useCallback(() => { - loadItems(searchTerm); - }, [searchTerm, loadItems]); - - const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { - handleSearch(); - } - }, [handleSearch]); - - // ์ฒดํฌ๋ฐ•์Šค ํ† ๊ธ€ - const handleToggle = useCallback((id: string) => { - setSelectedIds((prev) => { - const next = new Set(prev); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - return next; - }); - }, []); - - // ์ „์ฒด ์„ ํƒ/ํ•ด์ œ - const handleToggleAll = useCallback(() => { - setSelectedIds((prev) => { - if (prev.size === items.length) { - return new Set(); - } - return new Set(items.map((item) => item.id)); - }); - }, [items]); - - // ์„ ํƒ ํ™•์ธ - const handleConfirm = useCallback(() => { - const selectedItems = items.filter((item) => selectedIds.has(item.id)); - onSelect(selectedItems); - onOpenChange(false); - }, [items, selectedIds, onSelect, onOpenChange]); - - const isAllSelected = items.length > 0 && selectedIds.size === items.length; - return ( - - - - ์ˆ˜์ฃผ ์„ ํƒ - - - {/* ๊ฒ€์ƒ‰ */} -
-
- - setSearchTerm(e.target.value)} - onKeyDown={handleSearchKeyDown} - placeholder="์ˆ˜์ฃผ๋ฒˆํ˜ธ, ํ˜„์žฅ๋ช… ๊ฒ€์ƒ‰..." - className="pl-9" - /> -
- -
- - {/* ํ…Œ์ด๋ธ” */} -
- {isLoading ? ( -
- -
- ) : ( - - - - - - - ์ˆ˜์ฃผ๋ฒˆํ˜ธ - ํ˜„์žฅ๋ช… - ๋‚ฉํ’ˆ์ผ - ๊ฐœ์†Œ - - - - {items.map((item) => ( - handleToggle(item.id)} - > - e.stopPropagation()}> - handleToggle(item.id)} - /> - - {item.orderNumber} - {item.siteName} - {item.deliveryDate} - {item.locationCount} - - ))} - {items.length === 0 && ( - - - {searchTerm ? '๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.' : '์ˆ˜์ฃผ ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.'} - - + + open={open} + onOpenChange={onOpenChange} + title="์ˆ˜์ฃผ ์„ ํƒ" + searchPlaceholder="์ˆ˜์ฃผ๋ฒˆํ˜ธ, ํ˜„์žฅ๋ช… ๊ฒ€์ƒ‰..." + fetchData={handleFetchData} + keyExtractor={(item) => item.id} + searchMode="enter" + loadOnOpen + dialogClassName="sm:max-w-2xl" + listContainerClassName="max-h-[400px] overflow-y-auto border rounded-md" + mode="multiple" + onSelect={onSelect} + confirmLabel="์„ ํƒ" + allowSelectAll + listWrapper={(children, selectState) => ( +
+ + + + {selectState && ( + )} - -
- )} -
- - - - - -
-
+ + ์ˆ˜์ฃผ๋ฒˆํ˜ธ + ํ˜„์žฅ๋ช… + ๋‚ฉํ’ˆ์ผ + ๊ฐœ์†Œ + + + + {children} + {/* ๋นˆ ์ƒํƒœ๋Š” ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ์—์„œ ์ฒ˜๋ฆฌ */} + + + )} + renderItem={(item, isSelected) => ( + + e.stopPropagation()}> + + + {item.orderNumber} + {item.siteName} + {item.deliveryDate} + {item.locationCount} + + )} + /> ); } diff --git a/src/components/quotes/ItemSearchModal.tsx b/src/components/quotes/ItemSearchModal.tsx index 1992b9f2..31d811bd 100644 --- a/src/components/quotes/ItemSearchModal.tsx +++ b/src/components/quotes/ItemSearchModal.tsx @@ -1,28 +1,18 @@ /** * ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰ ๋ชจ๋‹ฌ * - * - ํ’ˆ๋ชฉ ์ฝ”๋“œ/์ด๋ฆ„์œผ๋กœ ๊ฒ€์ƒ‰ - * - ํ’ˆ๋ชฉ ๋ชฉ๋ก์—์„œ ์„ ํƒ - * - API ์—ฐ๋™ + * SearchableSelectionModal ๊ณตํ†ต ์ปดํฌ๋„ŒํŠธ ๊ธฐ๋ฐ˜ */ -"use client"; +'use client'; -import { useState, useEffect, useMemo, useCallback } from "react"; -import { Search, X, Loader2 } from "lucide-react"; - -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "../ui/dialog"; -import { Input } from "../ui/input"; -import { fetchItems } from "@/lib/api/items"; -import type { ItemMaster, ItemType } from "@/types/item"; +import { useCallback } from 'react'; +import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal'; +import { fetchItems } from '@/lib/api/items'; +import type { ItemMaster, ItemType } from '@/types/item'; // ============================================================================= -// Props +// Props (๊ธฐ์กด๊ณผ ๋™์ผ โ€” ์‚ฌ์šฉ์ฒ˜ ๋ณ€๊ฒฝ ์—†์Œ) // ============================================================================= interface ItemSearchModalProps { @@ -34,6 +24,12 @@ interface ItemSearchModalProps { itemType?: string; } +// ๊ฒ€์ƒ‰์–ด ์œ ํšจ์„ฑ: ์˜๋ฌธ, ํ•œ๊ธ€, ์ˆซ์ž 1์ž ์ด์ƒ +const isValidSearchQuery = (query: string) => { + if (!query || !query.trim()) return false; + return /[a-zA-Z๊ฐ€-ํžฃใ„ฑ-ใ…Žใ…-ใ…ฃ0-9]/.test(query); +}; + // ============================================================================= // ์ปดํฌ๋„ŒํŠธ // ============================================================================= @@ -45,168 +41,73 @@ export function ItemSearchModal({ tabLabel, itemType, }: ItemSearchModalProps) { - const [searchQuery, setSearchQuery] = useState(""); - const [items, setItems] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - // ํ’ˆ๋ชฉ ๋ชฉ๋ก ์กฐํšŒ - const loadItems = useCallback(async (search?: string) => { - setIsLoading(true); - setError(null); - try { - const data = await fetchItems({ - search: search || undefined, - itemType: itemType as ItemType | undefined, - per_page: 50, - }); - setItems(data); - } catch (err) { - console.error("[ItemSearchModal] ํ’ˆ๋ชฉ ์กฐํšŒ ์˜ค๋ฅ˜:", err); - setError("ํ’ˆ๋ชฉ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š”๋ฐ ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."); - setItems([]); - } finally { - setIsLoading(false); - } + const handleFetchData = useCallback(async (query: string) => { + const data = await fetchItems({ + search: query || undefined, + itemType: itemType as ItemType | undefined, + per_page: 50, + }); + return data; }, [itemType]); - // ๊ฒ€์ƒ‰์–ด ์œ ํšจ์„ฑ ๊ฒ€์‚ฌ: ์˜๋ฌธ, ํ•œ๊ธ€, ์ˆซ์ž 1์ž ์ด์ƒ - const isValidSearchQuery = useCallback((query: string) => { - if (!query || !query.trim()) return false; - return /[a-zA-Z๊ฐ€-ํžฃใ„ฑ-ใ…Žใ…-ใ…ฃ0-9]/.test(query); - }, []); - - // ๋ชจ๋‹ฌ ์—ด๋ฆด ๋•Œ ์ดˆ๊ธฐํ™” (์ž๋™ ๋กœ๋“œ ์•ˆํ•จ) - useEffect(() => { - if (open) { - setItems([]); - setError(null); - } - }, [open]); - - // ๊ฒ€์ƒ‰์–ด ๋ณ€๊ฒฝ ์‹œ ๋””๋ฐ”์šด์Šค ๊ฒ€์ƒ‰ (์œ ํšจํ•œ ๊ฒ€์ƒ‰์–ด๋งŒ) - useEffect(() => { - if (!open) return; - - // ๊ฒ€์ƒ‰์–ด๊ฐ€ ์œ ํšจํ•˜์ง€ ์•Š์œผ๋ฉด ๊ฒฐ๊ณผ ์ดˆ๊ธฐํ™” - if (!isValidSearchQuery(searchQuery)) { - setItems([]); - return; - } - - const timer = setTimeout(() => { - loadItems(searchQuery); - }, 300); - - return () => clearTimeout(timer); - }, [searchQuery, open, loadItems, isValidSearchQuery]); - - // ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ ๊ทธ๋Œ€๋กœ ์‚ฌ์šฉ (์„œ๋ฒ„์—์„œ ์ด๋ฏธ ํ•„ํ„ฐ๋ง๋จ) - const filteredItems = items; - - const handleSelect = (item: ItemMaster) => { + const handleSelect = useCallback((item: ItemMaster) => { onSelectItem({ code: item.itemCode, name: item.itemName, specification: item.specification || undefined, }); - onOpenChange(false); - setSearchQuery(""); - }; - - const handleClose = () => { - onOpenChange(false); - setSearchQuery(""); - }; + }, [onSelectItem]); return ( - - - - - ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰ - {tabLabel && ({tabLabel})} - - - - {/* ๊ฒ€์ƒ‰ ์ž…๋ ฅ */} -
- - setSearchQuery(e.target.value)} - className="pl-10 pr-10" - /> - {searchQuery && ( - - )} -
- - {/* ํ’ˆ๋ชฉ ๋ชฉ๋ก */} -
- {isLoading ? ( -
- - ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰ ์ค‘... + + open={open} + onOpenChange={onOpenChange} + title={ + <> + ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰ + {tabLabel && ({tabLabel})} + + } + searchPlaceholder="ํ’ˆ๋ชฉ์ฝ”๋“œ ๋˜๋Š” ํ’ˆ๋ชฉ๋ช… ๊ฒ€์ƒ‰..." + fetchData={handleFetchData} + keyExtractor={(item) => item.id?.toString() ?? item.itemCode} + validateSearch={isValidSearchQuery} + invalidSearchMessage="์˜๋ฌธ, ํ•œ๊ธ€ ๋˜๋Š” ์ˆซ์ž 1์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”" + emptyQueryMessage="ํ’ˆ๋ชฉ์ฝ”๋“œ ๋˜๋Š” ํ’ˆ๋ชฉ๋ช…์„ ์ž…๋ ฅํ•˜์„ธ์š”" + loadingMessage="ํ’ˆ๋ชฉ ๊ฒ€์ƒ‰ ์ค‘..." + dialogClassName="sm:max-w-[500px]" + infoText={(items, isLoading) => + !isLoading ? ( + + ์ด {items.length}๊ฐœ ํ’ˆ๋ชฉ + + ) : null + } + mode="single" + onSelect={handleSelect} + renderItem={(item) => ( +
+
+
+ {item.itemCode} + {item.itemName} + {item.hasInspectionTemplate && ( + + ์ˆ˜์ž…๊ฒ€์‚ฌ + + )}
- ) : error ? ( -
- {error} -
- ) : filteredItems.length === 0 ? ( -
- {!searchQuery - ? "ํ’ˆ๋ชฉ์ฝ”๋“œ ๋˜๋Š” ํ’ˆ๋ชฉ๋ช…์„ ์ž…๋ ฅํ•˜์„ธ์š”" - : !isValidSearchQuery(searchQuery) - ? "์˜๋ฌธ, ํ•œ๊ธ€ ๋˜๋Š” ์ˆซ์ž 1์ž ์ด์ƒ ์ž…๋ ฅํ•˜์„ธ์š”" - : "๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค"} -
- ) : ( -
- {filteredItems.map((item, index) => ( -
handleSelect(item)} - className="p-3 hover:bg-blue-50 cursor-pointer transition-colors" - > -
-
- {item.itemCode} - {item.itemName} - {item.hasInspectionTemplate && ( - - ์ˆ˜์ž…๊ฒ€์‚ฌ - - )} -
- {item.unit && ( - - {item.unit} - - )} -
- {item.specification && ( -

{item.specification}

- )} -
- ))} -
- )} -
- - {/* ํ’ˆ๋ชฉ ๊ฐœ์ˆ˜ ํ‘œ์‹œ */} - {!isLoading && !error && ( -
- ์ด {filteredItems.length}๊ฐœ ํ’ˆ๋ชฉ + {item.unit && ( + + {item.unit} + + )}
- )} - -
+ {item.specification && ( +

{item.specification}

+ )} +
+ )} + /> ); -} \ No newline at end of file +} diff --git a/src/components/settings/AccountManagement/actions.ts b/src/components/settings/AccountManagement/actions.ts index f6111c6a..c72d5656 100644 --- a/src/components/settings/AccountManagement/actions.ts +++ b/src/components/settings/AccountManagement/actions.ts @@ -2,6 +2,7 @@ import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import type { Account, AccountFormData, AccountStatus } from './types'; import { BANK_LABELS } from './types'; @@ -23,13 +24,7 @@ interface BankAccountApiData { updated_at?: string; } -interface BankAccountPaginatedResponse { - data: BankAccountApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type BankAccountPaginatedResponse = PaginatedApiResponse; // ===== ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜ ===== function transformApiToFrontend(apiData: BankAccountApiData): Account { diff --git a/src/components/settings/PaymentHistoryManagement/actions.ts b/src/components/settings/PaymentHistoryManagement/actions.ts index 2c1de5d6..19686684 100644 --- a/src/components/settings/PaymentHistoryManagement/actions.ts +++ b/src/components/settings/PaymentHistoryManagement/actions.ts @@ -2,19 +2,14 @@ import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { PaymentApiData, PaymentHistory } from './types'; import { transformApiToFrontend } from './utils'; const API_URL = process.env.NEXT_PUBLIC_API_URL; // ===== API ์‘๋‹ต ํƒ€์ž… ===== -interface PaymentPaginatedResponse { - data: PaymentApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; -} +type PaymentPaginatedResponse = PaginatedApiResponse; interface PaymentStatementApiData { statement_no: string; diff --git a/src/components/settings/PopupManagement/actions.ts b/src/components/settings/PopupManagement/actions.ts index 194aa757..90c904a3 100644 --- a/src/components/settings/PopupManagement/actions.ts +++ b/src/components/settings/PopupManagement/actions.ts @@ -14,23 +14,12 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import type { PaginatedApiResponse } from '@/lib/api/types'; import type { Popup, PopupFormData } from './types'; import { transformApiToFrontend, transformFrontendToApi, type PopupApiData } from './utils'; const API_URL = process.env.NEXT_PUBLIC_API_URL; -// ============================================ -// API ์‘๋‹ต ํƒ€์ž… ์ •์˜ -// ============================================ - -interface PaginatedResponse { - current_page: number; - data: T[]; - total: number; - per_page: number; - last_page: number; -} - // ============================================ // API ํ•จ์ˆ˜ // ============================================ @@ -52,7 +41,7 @@ export async function getPopups(params?: { const result = await executeServerAction({ url: `${API_URL}/api/v1/popups?${searchParams.toString()}`, - transform: (data: PaginatedResponse) => data.data.map(transformApiToFrontend), + transform: (data: PaginatedApiResponse) => data.data.map(transformApiToFrontend), errorMessage: 'ํŒ์—… ๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', }); return result.data || []; diff --git a/src/components/settings/TitleManagement/actions.ts b/src/components/settings/TitleManagement/actions.ts index a1a2ce5d..2a01f020 100644 --- a/src/components/settings/TitleManagement/actions.ts +++ b/src/components/settings/TitleManagement/actions.ts @@ -1,11 +1,8 @@ 'use server'; - -import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action'; +import { createCrudService, type ActionResult } from '@/lib/api/create-crud-service'; import type { Title } from './types'; -const API_URL = process.env.NEXT_PUBLIC_API_URL; - // ===== API ์‘๋‹ต ํƒ€์ž… ===== interface PositionApiData { id: number; @@ -18,60 +15,43 @@ interface PositionApiData { updated_at?: string; } -// ===== ๋ฐ์ดํ„ฐ ๋ณ€ํ™˜: API โ†’ Frontend ===== -function transformApiToFrontend(apiData: PositionApiData): Title { - return { - id: apiData.id, - name: apiData.name, - order: apiData.sort_order, - isActive: apiData.is_active, - createdAt: apiData.created_at, - updatedAt: apiData.updated_at, - }; -} +// ===== CRUD ์„œ๋น„์Šค ์ƒ์„ฑ ===== +const titleService = createCrudService({ + basePath: '/api/v1/positions', + transform: (api) => ({ + id: api.id, + name: api.name, + order: api.sort_order, + isActive: api.is_active, + createdAt: api.created_at, + updatedAt: api.updated_at, + }), + entityName: '์ง์ฑ…', + defaultQueryParams: { type: 'title' }, + defaultCreateBody: { type: 'title' }, +}); + +// ===== Server Action ๋ž˜ํผ ===== -// ===== ์ง์ฑ… ๋ชฉ๋ก ์กฐํšŒ ===== export async function getTitles(params?: { is_active?: boolean; q?: string; }): Promise> { - const searchParams = new URLSearchParams(); - searchParams.set('type', 'title'); - if (params?.is_active !== undefined) { - searchParams.set('is_active', params.is_active.toString()); - } - if (params?.q) { - searchParams.set('q', params.q); - } - - return executeServerAction({ - url: `${API_URL}/api/v1/positions?${searchParams.toString()}`, - transform: (data: PositionApiData[]) => data.map(transformApiToFrontend), - errorMessage: '์ง์ฑ… ๋ชฉ๋ก ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', - }); + return titleService.getList(params); } -// ===== ์ง์ฑ… ์ƒ์„ฑ ===== export async function createTitle(data: { name: string; sort_order?: number; is_active?: boolean; }): Promise> { - return executeServerAction({ - url: `${API_URL}/api/v1/positions`, - method: 'POST', - body: { - type: 'title', - name: data.name, - sort_order: data.sort_order, - is_active: data.is_active ?? true, - }, - transform: transformApiToFrontend, - errorMessage: '์ง์ฑ… ์ƒ์„ฑ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + return titleService.create({ + name: data.name, + sort_order: data.sort_order, + is_active: data.is_active ?? true, }); } -// ===== ์ง์ฑ… ์ˆ˜์ • ===== export async function updateTitle( id: number, data: { @@ -80,32 +60,15 @@ export async function updateTitle( is_active?: boolean; } ): Promise> { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/${id}`, - method: 'PUT', - body: data, - transform: transformApiToFrontend, - errorMessage: '์ง์ฑ… ์ˆ˜์ •์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', - }); + return titleService.update(id, data); } -// ===== ์ง์ฑ… ์‚ญ์ œ ===== export async function deleteTitle(id: number): Promise { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/${id}`, - method: 'DELETE', - errorMessage: '์ง์ฑ… ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', - }); + return titleService.remove(id); } -// ===== ์ง์ฑ… ์ˆœ์„œ ๋ณ€๊ฒฝ ===== export async function reorderTitles( items: { id: number; sort_order: number }[] ): Promise { - return executeServerAction({ - url: `${API_URL}/api/v1/positions/reorder`, - method: 'PUT', - body: { items }, - errorMessage: '์ˆœ์„œ ๋ณ€๊ฒฝ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', - }); -} \ No newline at end of file + return titleService.reorder(items); +} diff --git a/src/lib/api/create-crud-service.ts b/src/lib/api/create-crud-service.ts index ba9aa440..07a2a8c9 100644 --- a/src/lib/api/create-crud-service.ts +++ b/src/lib/api/create-crud-service.ts @@ -3,7 +3,7 @@ * * ์ •ํ˜•์ ์ธ CRUD actions.ts ํŒŒ์ผ์˜ ๋ณด์ผ๋Ÿฌํ”Œ๋ ˆ์ดํŠธ๋ฅผ ์ œ๊ฑฐํ•ฉ๋‹ˆ๋‹ค. * executeServerAction ์œ„์— ํ•œ ๋‹จ๊ณ„ ๋” ์ถ”์ƒํ™”ํ•˜์—ฌ - * getList / create / update / remove / reorder ํ•จ์ˆ˜๋ฅผ ์ž๋™ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. + * getList / getById / create / update / remove / bulkDelete / reorder ํ•จ์ˆ˜๋ฅผ ์ž๋™ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. * * ์ฃผ์˜: ์ด ํŒŒ์ผ์€ 'use server'๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. * ๊ฐ ๋„๋ฉ”์ธ์˜ actions.ts ('use server')์—์„œ importํ•˜์—ฌ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. @@ -20,10 +20,12 @@ * defaultCreateBody: { type: 'rank' }, * }); * export async function getRanks(params?) { return service.getList(params); } + * export async function getRankById(id) { return service.getById(id); } * ``` */ import { executeServerAction, type ActionResult } from './execute-server-action'; +import { isNextRedirectError } from '@/lib/utils/redirect-error'; // ===== ์„ค์ • ํƒ€์ž… ===== export interface CrudServiceConfig { @@ -37,6 +39,8 @@ export interface CrudServiceConfig { defaultQueryParams?: Record; /** ์ƒ์„ฑ ์‹œ body์— ์ถ”๊ฐ€ํ•  ๊ธฐ๋ณธ๊ฐ’ (์˜ˆ: { type: 'rank' }) */ defaultCreateBody?: Record; + /** ์ˆ˜์ • ์‹œ HTTP ๋ฉ”์„œ๋“œ (๊ธฐ๋ณธ: 'PUT') */ + updateMethod?: 'PUT' | 'PATCH'; } // ===== ์„œ๋น„์Šค ๋ฐ˜ํ™˜ ํƒ€์ž… ===== @@ -46,14 +50,18 @@ export interface CrudService { q?: string; }): Promise>; + getById(id: number | string): Promise>; + create(body: Record): Promise>; update( - id: number, + id: number | string, body: Record ): Promise>; - remove(id: number): Promise; + remove(id: number | string): Promise; + + bulkDelete(ids: (number | string)[]): Promise; reorder( items: { id: number; sort_order: number }[] @@ -70,6 +78,7 @@ export function createCrudService( entityName, defaultQueryParams, defaultCreateBody, + updateMethod = 'PUT', } = config; // API URL์€ ํ˜ธ์ถœ ์‹œ์ ์— resolve (SSR ์•ˆ์ „) @@ -97,6 +106,14 @@ export function createCrudService( }); }, + async getById(id) { + return executeServerAction({ + url: `${getBaseUrl()}/${id}`, + transform, + errorMessage: `${entityName} ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.`, + }); + }, + async create(body) { return executeServerAction({ url: getBaseUrl(), @@ -110,7 +127,7 @@ export function createCrudService( async update(id, body) { return executeServerAction({ url: `${getBaseUrl()}/${id}`, - method: 'PUT', + method: updateMethod, body, transform, errorMessage: `${entityName} ์ˆ˜์ •์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.`, @@ -125,6 +142,26 @@ export function createCrudService( }); }, + async bulkDelete(ids) { + try { + const results = await Promise.all(ids.map((id) => + executeServerAction({ + url: `${getBaseUrl()}/${id}`, + method: 'DELETE', + errorMessage: `${entityName} ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.`, + }) + )); + const failed = results.filter((r) => !r.success); + if (failed.length > 0) { + return { success: false, error: `${failed.length}๊ฐœ ${entityName} ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.` }; + } + return { success: true }; + } catch (error) { + if (isNextRedirectError(error)) throw error; + return { success: false, error: `${entityName} ์ผ๊ด„ ์‚ญ์ œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.` }; + } + }, + async reorder(items) { return executeServerAction({ url: `${getBaseUrl()}/reorder`, diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts index 308d6941..d540f62c 100644 --- a/src/lib/api/index.ts +++ b/src/lib/api/index.ts @@ -5,6 +5,23 @@ export { ApiClient, withTokenRefresh } from './client'; export { serverFetch } from './fetch-wrapper'; export { AUTH_CONFIG } from './auth/auth-config'; +// ๊ณต์šฉ API ํƒ€์ž… ๋ฐ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์œ ํ‹ธ๋ฆฌํ‹ฐ +export { + toPaginationMeta, + type PaginatedApiResponse, + type PaginationMeta, + type PaginatedResult, + type SelectOption, +} from './types'; + +// ๊ณต์šฉ ๋ฃฉ์—… ํ—ฌํผ (๊ฑฐ๋ž˜์ฒ˜/๊ณ„์ขŒ ์กฐํšŒ) +export { + fetchVendorOptions, + fetchBankAccountOptions, + fetchBankAccountDetailOptions, + type BankAccountOption, +} from './shared-lookups'; + // ๊ณตํ†ต ์ฝ”๋“œ ํƒ€์ž… ๋ฐ ์œ ํ‹ธ๋ฆฌํ‹ฐ export { toCommonCodeOptions, diff --git a/src/lib/api/shared-lookups.ts b/src/lib/api/shared-lookups.ts new file mode 100644 index 00000000..b3aee95f --- /dev/null +++ b/src/lib/api/shared-lookups.ts @@ -0,0 +1,86 @@ +/** + * ๊ณต์šฉ ๋ฃฉ์—…(์…€๋ ‰ํŠธ ์˜ต์…˜) ์กฐํšŒ ํ—ฌํผ + * + * ์—ฌ๋Ÿฌ ๋„๋ฉ”์ธ(์ž…๊ธˆ/์ถœ๊ธˆ/๋งค์ž…/์˜ˆ์ƒ๋น„์šฉ)์—์„œ ๋™์ผํ•˜๊ฒŒ ์‚ฌ์šฉํ•˜๋Š” + * ๊ฑฐ๋ž˜์ฒ˜/๊ณ„์ขŒ ์กฐํšŒ ๋กœ์ง์„ ํ•˜๋‚˜๋กœ ํ†ตํ•ฉํ•ฉ๋‹ˆ๋‹ค. + * + * ์ฃผ์˜: ์ด ํŒŒ์ผ์€ 'use server'๊ฐ€ ์•„๋‹™๋‹ˆ๋‹ค. + * ๊ฐ ๋„๋ฉ”์ธ์˜ actions.ts ('use server')์—์„œ importํ•˜์—ฌ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. + */ + +import { executeServerAction, type ActionResult } from './execute-server-action'; +import type { SelectOption } from './types'; + +// ===== ๊ณ„์ขŒ ์ƒ์„ธ ์˜ต์…˜ ===== +export interface BankAccountOption { + id: string; + bankName: string; + accountName: string; + accountNumber: string; +} + +// ===== API ๋‚ด๋ถ€ ํƒ€์ž… ===== +interface ClientApiItem { + id: number; + name: string; +} + +interface BankAccountApiItem { + id: number; + bank_name: string; + account_name: string; + account_number: string; +} + +type PaginatedOrArray = { data?: T[] } | T[]; + +function extractArray(data: PaginatedOrArray): T[] { + return Array.isArray(data) ? data : (data as { data?: T[] })?.data || []; +} + +// ===== ๊ฑฐ๋ž˜์ฒ˜ ๋ชฉ๋ก ์กฐํšŒ ===== +export async function fetchVendorOptions(): Promise> { + const API_URL = process.env.NEXT_PUBLIC_API_URL; + const result = await executeServerAction({ + url: `${API_URL}/api/v1/clients?per_page=100`, + transform: (data: PaginatedOrArray) => { + const clients = extractArray(data); + return clients.map(c => ({ id: String(c.id), name: c.name })); + }, + errorMessage: '๊ฑฐ๋ž˜์ฒ˜ ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + return { success: result.success, data: result.data || [], error: result.error }; +} + +// ===== ๊ณ„์ขŒ ๋ชฉ๋ก ์กฐํšŒ (๊ฐ„๋‹จ: id + name) ===== +export async function fetchBankAccountOptions(): Promise> { + const API_URL = process.env.NEXT_PUBLIC_API_URL; + const result = await executeServerAction({ + url: `${API_URL}/api/v1/bank-accounts?per_page=100`, + transform: (data: PaginatedOrArray) => { + const accounts = extractArray(data); + return accounts.map(a => ({ id: String(a.id), name: `${a.bank_name} ${a.account_name}` })); + }, + errorMessage: '๊ณ„์ขŒ ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + return { success: result.success, data: result.data || [], error: result.error }; +} + +// ===== ๊ณ„์ขŒ ๋ชฉ๋ก ์กฐํšŒ (์ƒ์„ธ: bankName, accountName, accountNumber) ===== +export async function fetchBankAccountDetailOptions(): Promise> { + const API_URL = process.env.NEXT_PUBLIC_API_URL; + const result = await executeServerAction({ + url: `${API_URL}/api/v1/bank-accounts?per_page=100`, + transform: (data: PaginatedOrArray) => { + const accounts = extractArray(data); + return accounts.map(a => ({ + id: String(a.id), + bankName: a.bank_name, + accountName: a.account_name, + accountNumber: a.account_number, + })); + }, + errorMessage: '์€ํ–‰ ๊ณ„์ขŒ ์กฐํšŒ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', + }); + return { success: result.success, data: result.data || [], error: result.error }; +} diff --git a/src/lib/api/types.ts b/src/lib/api/types.ts new file mode 100644 index 00000000..16fc3a5a --- /dev/null +++ b/src/lib/api/types.ts @@ -0,0 +1,47 @@ +/** + * ๊ณต์šฉ API ํƒ€์ž… ๋ฐ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์œ ํ‹ธ๋ฆฌํ‹ฐ + * + * 25+ ๊ฐœ action ํŒŒ์ผ์—์„œ ๋ฐ˜๋ณต ์ •์˜๋˜๋˜ PaginatedResponse ํƒ€์ž…๊ณผ + * ํŽ˜์ด์ง€๋„ค์ด์…˜ ๋ณ€ํ™˜ ํ—ฌํผ๋ฅผ ํ•˜๋‚˜๋กœ ํ†ตํ•ฉํ•ฉ๋‹ˆ๋‹ค. + */ + +// ===== API ํŽ˜์ด์ง€๋„ค์ด์…˜ ์‘๋‹ต (Laravel ํ‘œ์ค€) ===== +export interface PaginatedApiResponse { + data: T[]; + current_page: number; + last_page: number; + per_page: number; + total: number; +} + +// ===== ํ”„๋ก ํŠธ์—”๋“œ ํŽ˜์ด์ง€๋„ค์ด์…˜ ๋ฉ”ํƒ€ (camelCase) ===== +export interface PaginationMeta { + currentPage: number; + lastPage: number; + perPage: number; + total: number; +} + +// ===== ํ”„๋ก ํŠธ์—”๋“œ ํŽ˜์ด์ง€๋„ค์ด์…˜ ๊ฒฐ๊ณผ ===== +export interface PaginatedResult { + items: T[]; + pagination: PaginationMeta; +} + +// ===== ํŽ˜์ด์ง€๋„ค์ด์…˜ ๋ณ€ํ™˜ ํ—ฌํผ ===== +export function toPaginationMeta( + data: { current_page?: number; last_page?: number; per_page?: number; total?: number } | undefined | null +): PaginationMeta { + return { + currentPage: data?.current_page || 1, + lastPage: data?.last_page || 1, + perPage: data?.per_page || 20, + total: data?.total || 0, + }; +} + +// ===== ์…€๋ ‰ํŠธ ์˜ต์…˜ (๊ณต์šฉ ๋ฃฉ์—…์šฉ) ===== +export interface SelectOption { + id: string; + name: string; +}