Merge remote-tracking branch 'origin/master'
This commit is contained in:
77
CLAUDE.md
77
CLAUDE.md
@@ -271,6 +271,83 @@ const [data, setData] = useState(() => {
|
||||
|
||||
---
|
||||
|
||||
## Zod 스키마 검증 (신규 코드 적용)
|
||||
**Priority**: 🟡
|
||||
|
||||
### 적용 범위
|
||||
- **신규 폼**: Zod 스키마 필수 적용
|
||||
- **기존 폼**: 건드리지 않음 (정상 작동 중이면 마이그레이션 불필요)
|
||||
- **API 응답**: 신규 서버 액션에서 선택적 적용
|
||||
|
||||
### 신규 폼 작성 패턴
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
// 1. 스키마 정의 (타입 + 검증 한 번에)
|
||||
const formSchema = z.object({
|
||||
itemName: z.string().min(1, '품목명을 입력하세요'),
|
||||
quantity: z.number().min(1, '1 이상 입력하세요'),
|
||||
status: z.enum(['active', 'inactive']),
|
||||
memo: z.string().optional(),
|
||||
});
|
||||
|
||||
// 2. 스키마에서 타입 추출 (별도 interface 정의 불필요)
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
// 3. useForm에 zodResolver 연결
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: { itemName: '', quantity: 1, status: 'active' },
|
||||
});
|
||||
```
|
||||
|
||||
### 규칙
|
||||
- **스키마 위치**: 컴포넌트 파일 상단 또는 같은 디렉토리의 `schema.ts`
|
||||
- **타입 추출**: `z.infer<typeof schema>` 사용, 별도 `interface` 중복 정의 금지
|
||||
- **에러 메시지**: 한글로 작성 (사용자에게 직접 표시됨)
|
||||
- **`as` 캐스트 지양**: Zod 스키마로 타입이 보장되므로 `as` 캐스트 불필요
|
||||
|
||||
### 사용하지 않는 경우
|
||||
- 기존 `rules={{ required: true }}` 패턴으로 작동 중인 폼
|
||||
- 단순 필드 1~2개짜리 인라인 폼 (오버엔지니어링)
|
||||
|
||||
---
|
||||
|
||||
## Server Action 공통 유틸리티 (신규 코드 적용)
|
||||
**Priority**: 🟡
|
||||
|
||||
### 신규 actions.ts 작성 시 필수 패턴:
|
||||
- `buildApiUrl()` 사용 (직접 URLSearchParams 조립 금지)
|
||||
- 페이지네이션 조회 → `executePaginatedAction()` 사용
|
||||
- 단건/목록 조회 → `executeServerAction()` 유지
|
||||
- `toPaginationMeta()` 직접 사용도 허용
|
||||
|
||||
```typescript
|
||||
// ✅ 신규 코드 패턴
|
||||
import { buildApiUrl, executePaginatedAction } from '@/lib/api';
|
||||
|
||||
export async function getItems(params: SearchParams) {
|
||||
return executePaginatedAction({
|
||||
url: buildApiUrl('/api/v1/items', {
|
||||
search: params.search,
|
||||
status: params.status !== 'all' ? params.status : undefined,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
}),
|
||||
transform: transformApiToFrontend,
|
||||
errorMessage: '목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 기존 코드: 마이그레이션 없음
|
||||
- 잘 동작하는 기존 actions.ts는 수정하지 않음
|
||||
- 해당 파일을 수정할 일이 생길 때만 선택적으로 적용
|
||||
|
||||
---
|
||||
|
||||
## Common Component Usage Rules
|
||||
**Priority**: 🔴
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
| 항목 | 상태 | 날짜 |
|
||||
|------|------|------|
|
||||
| Phase 1: 공통 훅 추출 (executeServerAction 등) | ✅ 완료 | 이전 세션 |
|
||||
| 중복 코드 공통화 (buildApiUrl + executePaginatedAction) | ✅ 완료 | 2026-02-11 |
|
||||
| Phase 3: 공용 유틸 추출 (PaginatedApiResponse 등) | ✅ 완료 | 이전 세션 |
|
||||
| Phase 4: SearchableSelectionModal 공통화 | ✅ 완료 | 이전 세션 |
|
||||
| Phase 5: any 21건 + memo 3개 정리 | ✅ 완료 | 이전 세션 |
|
||||
|
||||
@@ -149,6 +149,44 @@ export const remove = service.remove;
|
||||
|
||||
**미전환 사유**: 84개 중 전환 가능 15~20개, 작업 2~4시간 대비 기능 변화 없음. 시간 대비 효율 낮음
|
||||
|
||||
### Server Action 공통 유틸리티 — 신규 코드 적용 규칙 (2026-02-11)
|
||||
|
||||
**결정**: 기존 actions.ts 마이그레이션 없음. **신규 actions.ts에만 `buildApiUrl` + `executePaginatedAction` 적용**
|
||||
|
||||
**배경**:
|
||||
- 89개 actions.ts 중 43개에서 동일한 URLSearchParams 조건부 `.set()` 패턴 반복 (326+ 건)
|
||||
- 50+ 파일에서 `current_page → currentPage` 수동 변환 반복
|
||||
- `toPaginationMeta`가 `src/lib/api/types.ts`에 존재하나 import 0건
|
||||
|
||||
**생성된 유틸리티**:
|
||||
1. `src/lib/api/query-params.ts` — `buildQueryParams()`, `buildApiUrl()`: URLSearchParams 보일러플레이트 제거
|
||||
2. `src/lib/api/execute-paginated-action.ts` — `executePaginatedAction()`: 페이지네이션 조회 패턴 통합 (내부에서 `toPaginationMeta` 사용)
|
||||
|
||||
**효과**:
|
||||
- 페이지네이션 조회 코드: ~20줄 → ~5줄
|
||||
- `DEFAULT_PAGINATION` 중앙화 (`execute-paginated-action.ts` 내부)
|
||||
- `toPaginationMeta` 자동 활용 (직접 import 불필요)
|
||||
|
||||
**미적용 사유**: 기존 89개 actions.ts는 정상 동작 중. 전면 전환 비용 >> 이득
|
||||
|
||||
### Zod 스키마 검증 — 신규 폼 적용 규칙 (2026-02-11)
|
||||
|
||||
**결정**: 기존 폼은 건드리지 않음. **신규 폼에만 Zod + zodResolver 적용**
|
||||
|
||||
**설치 상태**: `zod@^4.1.12`, `@hookform/resolvers@^5.2.2` — 이미 설치됨
|
||||
|
||||
**효과**:
|
||||
1. 스키마 하나로 **타입 추론 + 런타임 검증** 동시 해결 (`z.infer<typeof schema>`)
|
||||
2. 별도 `interface` 중복 정의 불필요
|
||||
3. 신규 코드에서 `as` 캐스트 자연 감소 (D-2 개선 효과)
|
||||
|
||||
**규칙**:
|
||||
- 신규 폼 → `zodResolver(schema)` 사용 필수 (CLAUDE.md에 패턴 명시)
|
||||
- 기존 `rules={{ required: true }}` 패턴 폼 → 마이그레이션 불필요
|
||||
- 단순 1~2 필드 인라인 폼 → Zod 불필요 (오버엔지니어링)
|
||||
|
||||
**미적용 사유**: 기존 폼 수십 개를 전면 전환하는 비용 >> 이득. 신규 코드에서 점진적 확산
|
||||
|
||||
---
|
||||
|
||||
## 폴더 구조
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
||||
# Research: Next.js / React ERP & Admin Panel Architecture Patterns (2025-2026)
|
||||
|
||||
**Date**: 2026-02-11
|
||||
**Purpose**: Compare SAM ERP's current architecture against proven open-source patterns
|
||||
**Confidence**: High (0.85) - Based on 6 major open-source projects and established methodologies
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
After investigating 6 major open-source admin/ERP frameworks and 3 architectural methodologies, the dominant pattern emerging in 2025-2026 is a **hybrid approach**: domain/feature-based folder organization combined with headless CRUD hooks and a provider-based API abstraction layer. Pure Atomic Design is losing ground to Feature-Sliced Design (FSD) for application-level organization, though Atomic Design remains useful for the shared UI component layer.
|
||||
|
||||
### Key Findings
|
||||
|
||||
1. **Resource-based CRUD abstraction** (react-admin, Refine) is the most proven pattern for 50+ page admin apps
|
||||
2. **Feature/domain-based folder structure** is winning over layer-based (atoms/molecules/organisms) for application code
|
||||
3. **Provider pattern** (dataProvider, authProvider) decouples UI from API more effectively than scattered Server Actions
|
||||
4. **Config-driven UI generation** (Payload CMS) reduces code duplication for similar pages
|
||||
5. **Headless hooks** (useListController, useTable, useForm) separate business logic from UI completely
|
||||
|
||||
---
|
||||
|
||||
## 1. Project-by-Project Architecture Analysis
|
||||
|
||||
### 1.1 React-Admin (marmelab) -- 25K+ GitHub Stars
|
||||
|
||||
**Architecture**: Resource-based SPA with Provider pattern
|
||||
|
||||
**Key Concepts**:
|
||||
- **Resources**: The core abstraction. Each entity (posts, users, orders) is a "resource" with CRUD views
|
||||
- **Providers**: Adapter layer between UI and backend
|
||||
- `dataProvider` - abstracts all API calls (getList, getOne, create, update, delete)
|
||||
- `authProvider` - handles authentication flow
|
||||
- `i18nProvider` - internationalization
|
||||
- **Headless Core**: `ra-core` package contains all hooks, zero UI dependency
|
||||
- **Controller Hooks**: `useListController`, `useEditController`, `useCreateController`, `useShowController`
|
||||
|
||||
**Folder Pattern**:
|
||||
```
|
||||
src/
|
||||
resources/
|
||||
posts/
|
||||
PostList.tsx # <List> view
|
||||
PostEdit.tsx # <Edit> view
|
||||
PostCreate.tsx # <Create> view
|
||||
PostShow.tsx # <Show> view
|
||||
users/
|
||||
UserList.tsx
|
||||
UserEdit.tsx
|
||||
providers/
|
||||
dataProvider.ts # API abstraction
|
||||
authProvider.ts # Auth abstraction
|
||||
App.tsx # Resource registration
|
||||
```
|
||||
|
||||
**CRUD Registration Pattern**:
|
||||
```tsx
|
||||
<Admin dataProvider={dataProvider} authProvider={authProvider}>
|
||||
<Resource name="posts" list={PostList} edit={PostEdit} create={PostCreate} />
|
||||
<Resource name="users" list={UserList} edit={UserEdit} />
|
||||
</Admin>
|
||||
```
|
||||
|
||||
**SAM Comparison**:
|
||||
| Aspect | react-admin | SAM ERP |
|
||||
|--------|-------------|---------|
|
||||
| API Layer | Centralized dataProvider | 89 scattered actions.ts files |
|
||||
| CRUD Views | Resource-based registration | Manual page creation per domain |
|
||||
| State | React Query (built-in) | Zustand + manual fetching |
|
||||
| Form | react-hook-form (built-in) | Mixed (migrating to RHF+Zod) |
|
||||
|
||||
**Sources**:
|
||||
- [Architecture Docs](https://marmelab.com/react-admin/Architecture.html)
|
||||
- [Resource Component](https://marmelab.com/react-admin/Resource.html)
|
||||
- [CRUD Pages](https://marmelab.com/react-admin/CRUD.html)
|
||||
- [GitHub](https://github.com/marmelab/react-admin)
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Refine -- 30K+ GitHub Stars
|
||||
|
||||
**Architecture**: Headless meta-framework with resource-based CRUD
|
||||
|
||||
**Key Concepts**:
|
||||
- **Headless by design**: Zero UI opinion, works with Ant Design, Material UI, Shadcn, or custom
|
||||
- **Data Provider Interface**: Standardized CRUD methods (getList, getOne, create, update, deleteOne)
|
||||
- **Resource Hooks**: `useTable`, `useForm`, `useShow`, `useSelect` -- all headless
|
||||
- **Inferencer**: Auto-generates CRUD pages from API schema
|
||||
|
||||
**Data Provider Interface**:
|
||||
```typescript
|
||||
const dataProvider = {
|
||||
getList: ({ resource, pagination, sorters, filters }) => Promise,
|
||||
getOne: ({ resource, id }) => Promise,
|
||||
create: ({ resource, variables }) => Promise,
|
||||
update: ({ resource, id, variables }) => Promise,
|
||||
deleteOne: ({ resource, id }) => Promise,
|
||||
getMany: ({ resource, ids }) => Promise,
|
||||
custom: ({ url, method, payload }) => Promise,
|
||||
};
|
||||
```
|
||||
|
||||
**Headless Hook Pattern**:
|
||||
```tsx
|
||||
// useTable returns data + controls, you handle UI
|
||||
const { tableProps, sorters, filters } = useTable({ resource: "products" });
|
||||
|
||||
// useForm returns form state + submit, you handle UI
|
||||
const { formProps, saveButtonProps } = useForm({ resource: "products", action: "create" });
|
||||
```
|
||||
|
||||
**SAM Comparison**:
|
||||
| Aspect | Refine | SAM ERP |
|
||||
|--------|--------|---------|
|
||||
| API Abstraction | Single dataProvider | Per-domain actions.ts |
|
||||
| List Page | useTable hook | UniversalListPage template |
|
||||
| Form | useForm hook (headless) | Manual per-page forms |
|
||||
| Code Generation | Inferencer auto-gen | Manual creation |
|
||||
|
||||
**Sources**:
|
||||
- [Data Provider Docs](https://refine.dev/docs/data/data-provider/)
|
||||
- [useTable Hook](https://refine.dev/docs/data/hooks/use-table/)
|
||||
- [GitHub](https://github.com/refinedev/refine)
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Payload CMS 3.0 -- 30K+ GitHub Stars
|
||||
|
||||
**Architecture**: Config-driven, Next.js-native with auto-generated admin UI
|
||||
|
||||
**Key Concepts**:
|
||||
- **Collection Config**: Define schema once, get admin UI + API + types automatically
|
||||
- **Field System**: Rich field types auto-generate corresponding UI components
|
||||
- **Hooks**: beforeChange, afterRead, beforeValidate at collection and field level
|
||||
- **Access Control**: Document-level and field-level permissions in config
|
||||
- **Next.js Native**: Installs directly into /app folder, uses Server Components
|
||||
|
||||
**Config-Driven Pattern**:
|
||||
```typescript
|
||||
// collections/Products.ts
|
||||
export const Products: CollectionConfig = {
|
||||
slug: 'products',
|
||||
admin: {
|
||||
useAsTitle: 'name',
|
||||
defaultColumns: ['name', 'price', 'status'],
|
||||
},
|
||||
access: {
|
||||
read: () => true,
|
||||
create: isAdmin,
|
||||
update: isAdminOrSelf,
|
||||
},
|
||||
hooks: {
|
||||
beforeChange: [calculateTotal],
|
||||
afterRead: [formatCurrency],
|
||||
},
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'price', type: 'number', min: 0 },
|
||||
{ name: 'status', type: 'select', options: ['draft', 'published'] },
|
||||
{ name: 'category', type: 'relationship', relationTo: 'categories' },
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
**SAM Comparison**:
|
||||
| Aspect | Payload CMS | SAM ERP |
|
||||
|--------|-------------|---------|
|
||||
| Page Generation | Auto from config | Manual per page |
|
||||
| Field Definitions | Centralized schema | Inline JSX per form |
|
||||
| Access Control | Config-based per field | Manual per component |
|
||||
| Type Safety | Auto-generated from schema | Manual interface definitions |
|
||||
|
||||
**Sources**:
|
||||
- [Collection Configs](https://payloadcms.com/docs/configuration/collections)
|
||||
- [Fields Overview](https://payloadcms.com/docs/fields/overview)
|
||||
- [Collection Hooks](https://payloadcms.com/docs/hooks/collections)
|
||||
- [GitHub](https://github.com/payloadcms/payload)
|
||||
|
||||
---
|
||||
|
||||
### 1.4 Medusa Admin v2 -- 26K+ GitHub Stars
|
||||
|
||||
**Architecture**: Domain-based routes with widget injection system
|
||||
|
||||
**Key Concepts**:
|
||||
- **Domain Routes**: Routes organized by business domain (products, orders, customers)
|
||||
- **Widget System**: Inject custom React components into predetermined zones
|
||||
- **UI Routes**: File-based routing under src/admin/routes/
|
||||
- **Hook-based data fetching**: Domain-specific hooks for API integration
|
||||
- **Monorepo**: UI library (@medusajs/ui) separate from admin logic
|
||||
|
||||
**Folder Structure**:
|
||||
```
|
||||
packages/admin/dashboard/src/
|
||||
routes/
|
||||
products/
|
||||
product-list/
|
||||
components/
|
||||
hooks/
|
||||
page.tsx
|
||||
product-detail/
|
||||
components/
|
||||
hooks/
|
||||
page.tsx
|
||||
orders/
|
||||
order-list/
|
||||
order-detail/
|
||||
customers/
|
||||
hooks/ # Shared hooks
|
||||
components/ # Shared components
|
||||
lib/ # Utilities
|
||||
```
|
||||
|
||||
**SAM Comparison**:
|
||||
| Aspect | Medusa Admin | SAM ERP |
|
||||
|--------|-------------|---------|
|
||||
| Route Organization | Domain > Action > Components | Domain > page.tsx + actions.ts |
|
||||
| Shared Components | Separate UI package | organisms/molecules/atoms |
|
||||
| Hooks | Per-route + shared | Global + inline |
|
||||
| Extensibility | Widget injection zones | N/A |
|
||||
|
||||
**Sources**:
|
||||
- [Admin UI Routes](https://docs.medusajs.com/learn/fundamentals/admin/ui-routes)
|
||||
- [Admin Development](https://docs.medusajs.com/learn/fundamentals/admin)
|
||||
- [GitHub](https://github.com/medusajs/medusa)
|
||||
|
||||
---
|
||||
|
||||
### 1.5 AdminJS
|
||||
|
||||
**Architecture**: Auto-generated admin from resource configuration
|
||||
|
||||
**Key Concepts**:
|
||||
- **Resource Registration**: Register database models, get admin UI automatically
|
||||
- **Component Customization**: Override via ComponentLoader
|
||||
- **Dashboard Customization**: Custom React components for dashboard
|
||||
|
||||
**SAM Relevance**: Lower -- AdminJS is more backend-driven (Node.js ORM-based) and less applicable to a frontend-heavy ERP.
|
||||
|
||||
**Sources**:
|
||||
- [AdminJS Documentation](https://adminjs.co/)
|
||||
- [GitHub](https://github.com/SoftwareBrothers/adminjs)
|
||||
|
||||
---
|
||||
|
||||
### 1.6 Hoppscotch
|
||||
|
||||
**Architecture**: Monorepo with shared-library pattern
|
||||
|
||||
**Key Concepts**:
|
||||
- **@hoppscotch/common**: 90% of UI and business logic in shared package
|
||||
- **@hoppscotch/data**: Type safety across all layers
|
||||
- **Platform-specific code**: Thin wrapper handling native capabilities
|
||||
|
||||
**SAM Relevance**: The shared-library-as-core pattern is interesting for large codebases where most logic is platform-agnostic.
|
||||
|
||||
**Sources**:
|
||||
- [DeepWiki Analysis](https://deepwiki.com/hoppscotch/hoppscotch)
|
||||
|
||||
---
|
||||
|
||||
## 2. Architectural Methodologies Comparison
|
||||
|
||||
### 2.1 Feature-Sliced Design (FSD) -- Rising Standard
|
||||
|
||||
**7-Layer Architecture**:
|
||||
```
|
||||
app/ # App initialization, providers, routing
|
||||
processes/ # Complex cross-page business flows (deprecated in latest)
|
||||
pages/ # Full page compositions
|
||||
widgets/ # Self-contained UI blocks with business logic
|
||||
features/ # User-facing actions (login, add-to-cart)
|
||||
entities/ # Business entities (user, product, order)
|
||||
shared/ # Reusable utilities, UI kit, configs
|
||||
```
|
||||
|
||||
**Key Rules**:
|
||||
- Layers can ONLY import from layers below them
|
||||
- Each layer divided into **slices** (domain groupings)
|
||||
- Each slice divided into **segments** (ui/, model/, api/, lib/, config/)
|
||||
|
||||
**FSD Applied to ERP**:
|
||||
```
|
||||
src/
|
||||
app/ # App shell, providers
|
||||
pages/
|
||||
quality-qms/ # QMS page composition
|
||||
sales-quote/ # Quote page composition
|
||||
widgets/
|
||||
inspection-report/ # Self-contained inspection UI
|
||||
ui/
|
||||
model/
|
||||
api/
|
||||
quote-calculator/
|
||||
features/
|
||||
add-inspection-item/
|
||||
approve-quote/
|
||||
entities/
|
||||
inspection/
|
||||
ui/ (InspectionCard, InspectionRow)
|
||||
model/ (types, store)
|
||||
api/ (getInspection, updateInspection)
|
||||
quote/
|
||||
ui/
|
||||
model/
|
||||
api/
|
||||
shared/
|
||||
ui/ (Button, Table, Modal -- your atoms)
|
||||
lib/ (formatDate, exportUtils)
|
||||
api/ (httpClient, apiProxy)
|
||||
config/ (constants)
|
||||
```
|
||||
|
||||
**Sources**:
|
||||
- [Feature-Sliced Design](https://feature-sliced.design/)
|
||||
- [Layers Reference](https://feature-sliced.design/docs/reference/layers)
|
||||
- [Slices and Segments](https://feature-sliced.design/docs/reference/slices-segments)
|
||||
|
||||
---
|
||||
|
||||
### 2.2 Atomic Design -- Aging for App-Level Organization
|
||||
|
||||
**SAM's Current Approach**:
|
||||
```
|
||||
components/
|
||||
atoms/ # Basic UI elements
|
||||
molecules/ # (unused)
|
||||
organisms/ # Complex composed components
|
||||
templates/ # Page layout templates
|
||||
```
|
||||
|
||||
**Industry Assessment (2025-2026)**:
|
||||
- Atomic Design excels for **UI component libraries** (shared/ layer)
|
||||
- Struggles with **domain complexity** -- "UserCard" and "ProductCard" are both organisms but semantically distinct
|
||||
- Grouping by visual complexity (atom/molecule/organism) dilutes domain boundaries
|
||||
- Most large-scale projects have moved to **feature/domain organization** for application code
|
||||
- Atomic Design remains valuable for the **shared UI kit layer only**
|
||||
|
||||
**Sources**:
|
||||
- [Atomic Design Meets Feature-Based Architecture](https://medium.com/@buwanekasumanasekara/atomic-design-meets-feature-based-architecture-in-next-js-a-practical-guide-c06ea56cf5cc)
|
||||
- [From Components to Systems](https://www.codewithseb.com/blog/from-components-to-systems-scalable-frontend-with-atomiec-design)
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Modular Monolith (Frontend)
|
||||
|
||||
**Key Principles for ERP**:
|
||||
- Single deployment, but internally organized as independent modules
|
||||
- Each module = bounded context with clear API boundaries
|
||||
- Modules communicate through well-defined interfaces, not direct imports
|
||||
- Common concerns (auth, logging) handled at application level
|
||||
|
||||
**Applied to Next.js ERP**:
|
||||
```
|
||||
src/
|
||||
modules/
|
||||
quality/
|
||||
components/
|
||||
hooks/
|
||||
actions/
|
||||
types/
|
||||
index.ts # Public API -- only exports from here
|
||||
sales/
|
||||
components/
|
||||
hooks/
|
||||
actions/
|
||||
types/
|
||||
index.ts
|
||||
accounting/
|
||||
...
|
||||
shared/ # Cross-module utilities
|
||||
app/ # Next.js routing (thin layer)
|
||||
```
|
||||
|
||||
**Sources**:
|
||||
- [Modular Monolith Revolution](https://medium.com/@bhargavkoya56/the-modular-monolith-revolution-enterprise-grade-architecture-part-i-theory-b3705ca70a5f)
|
||||
- [Frontend at Scale](https://frontendatscale.com/issues/45/)
|
||||
|
||||
---
|
||||
|
||||
## 3. Server Actions Organization Patterns
|
||||
|
||||
### Pattern A: Colocated (SAM's Current -- 89 files)
|
||||
```
|
||||
app/[locale]/(protected)/quality/qms/
|
||||
page.tsx
|
||||
actions.ts # Server actions for this route
|
||||
```
|
||||
**Pros**: Easy to find, clear ownership
|
||||
**Cons**: Duplication across similar pages, no reuse
|
||||
|
||||
### Pattern B: Domain-Centralized (react-admin / Refine style)
|
||||
```
|
||||
src/
|
||||
actions/
|
||||
quality/
|
||||
inspection.ts # All inspection-related server actions
|
||||
qms.ts
|
||||
sales/
|
||||
quote.ts
|
||||
order.ts
|
||||
lib/
|
||||
api-client.ts # Shared fetch logic with auth
|
||||
```
|
||||
**Pros**: Reusable across pages, easier to maintain
|
||||
**Cons**: Indirection, harder to find for route-specific logic
|
||||
|
||||
### Pattern C: Hybrid (Recommended for large apps)
|
||||
```
|
||||
app/[locale]/(protected)/quality/qms/
|
||||
page.tsx
|
||||
_actions.ts # Route-specific actions only
|
||||
|
||||
src/
|
||||
domains/
|
||||
quality/
|
||||
actions/ # Shared domain actions
|
||||
inspection.ts
|
||||
qms.ts
|
||||
hooks/
|
||||
types/
|
||||
```
|
||||
**Pros**: Route-specific stays colocated, shared logic centralized
|
||||
**Cons**: Need clear rules on what goes where
|
||||
|
||||
### Industry Consensus
|
||||
For 100+ page apps, the **hybrid approach** (Pattern C) dominates. Route-specific logic stays colocated; shared domain logic is centralized. The key is having a clear **data provider / API client** layer that all server actions delegate to.
|
||||
|
||||
**Sources**:
|
||||
- [Next.js Colocation Template](https://next-colocation-template.vercel.app/)
|
||||
- [Inside the App Router (2025)](https://medium.com/better-dev-nextjs-react/inside-the-app-router-best-practices-for-next-js-file-and-directory-structure-2025-edition-ed6bc14a8da3)
|
||||
|
||||
---
|
||||
|
||||
## 4. CRUD Abstraction Patterns for 50+ Similar Pages
|
||||
|
||||
### Pattern 1: Resource Hooks (react-admin / Refine approach)
|
||||
```typescript
|
||||
// hooks/useResourceList.ts
|
||||
function useResourceList<T>(resource: string, options?: ListOptions) {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [pagination, setPagination] = useState({ page: 1, pageSize: 20 });
|
||||
const [filters, setFilters] = useState({});
|
||||
const [sorters, setSorters] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchList(resource, { pagination, filters, sorters })
|
||||
.then(result => setData(result.data));
|
||||
}, [resource, pagination, filters, sorters]);
|
||||
|
||||
return { data, pagination, setPagination, filters, setFilters, sorters, setSorters };
|
||||
}
|
||||
|
||||
// Usage in any list page
|
||||
function QualityInspectionList() {
|
||||
const { data, pagination, filters } = useResourceList<Inspection>('quality/inspections');
|
||||
return <UniversalListPage data={data} columns={inspectionColumns} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Config-Driven Pages (Payload CMS approach)
|
||||
```typescript
|
||||
// configs/quality-inspection.config.ts
|
||||
export const inspectionConfig: ResourceConfig = {
|
||||
resource: 'quality/inspections',
|
||||
list: {
|
||||
columns: [
|
||||
{ key: 'id', label: '번호' },
|
||||
{ key: 'name', label: '검사명' },
|
||||
{ key: 'status', label: '상태', render: StatusBadge },
|
||||
],
|
||||
filters: [
|
||||
{ key: 'status', type: 'select', options: statusOptions },
|
||||
{ key: 'dateRange', type: 'daterange' },
|
||||
],
|
||||
defaultSort: { key: 'createdAt', direction: 'desc' },
|
||||
},
|
||||
form: {
|
||||
fields: [
|
||||
{ name: 'name', type: 'text', required: true, label: '검사명' },
|
||||
{ name: 'type', type: 'select', options: typeOptions, label: '검사유형' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Generic page component
|
||||
function ResourceListPage({ config }: { config: ResourceConfig }) {
|
||||
const list = useResourceList(config.resource);
|
||||
return <UniversalListPage {...list} columns={config.list.columns} />;
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Template Composition (SAM's current direction, improved)
|
||||
```typescript
|
||||
// templates/UniversalCRUDPage.tsx -- enhanced version
|
||||
function UniversalCRUDPage<T>({
|
||||
resource,
|
||||
listConfig,
|
||||
detailConfig,
|
||||
formConfig,
|
||||
}: CRUDPageProps<T>) {
|
||||
// Handles list/detail/form modes based on URL
|
||||
// Integrates data fetching, pagination, filtering
|
||||
// Renders appropriate template based on mode
|
||||
}
|
||||
```
|
||||
|
||||
### Industry Assessment
|
||||
- **Pattern 1** (Resource Hooks) is the most widely adopted -- used by react-admin (25K stars) and Refine (30K stars)
|
||||
- **Pattern 2** (Config-Driven) reduces code the most but requires upfront investment in the config system
|
||||
- **Pattern 3** (Template Composition) is the middle ground -- SAM's `UniversalListPage` is already this direction
|
||||
|
||||
**Recommendation**: Evolve toward a **Provider + Resource Hooks** layer. Keep `UniversalListPage` and `IntegratedDetailTemplate` but back them with a standardized data provider.
|
||||
|
||||
---
|
||||
|
||||
## 5. Comparison Matrix: SAM ERP vs Industry Patterns
|
||||
|
||||
| Dimension | SAM ERP (Current) | react-admin | Refine | Payload CMS | FSD | Recommendation |
|
||||
|-----------|-------------------|-------------|--------|-------------|-----|----------------|
|
||||
| **Folder Structure** | Domain-based (app router) | Resource-based | Resource-based | Collection-based | Layer > Slice > Segment | Hybrid Domain + FSD shared layer |
|
||||
| **Component Org** | Atomic Design (partial) | Flat per resource | Flat per resource | Config-driven | Layer-based (entities/features) | FSD for app code, Atomic for shared UI |
|
||||
| **API Layer** | 89 colocated actions.ts | Centralized dataProvider | Centralized dataProvider | Built-in Local API | api/ segment per slice | Centralized API client + domain actions |
|
||||
| **CRUD Abstraction** | UniversalListPage template | Resource + Controller hooks | useTable/useForm hooks | Auto-generated from config | Manual per feature | Add resource hooks on top of templates |
|
||||
| **Form Handling** | Mixed (migrating to RHF+Zod) | react-hook-form (built-in) | react-hook-form (headless) | Auto from field config | Manual per feature | Complete RHF+Zod migration |
|
||||
| **State Management** | Zustand stores | React Query (built-in) | React Query (built-in) | Server-side | Per-slice model/ | Keep Zustand for UI state, add React Query for server state |
|
||||
| **Type Safety** | Manual interfaces | Built-in types | TypeScript throughout | Auto-generated from schema | Manual per segment | Consider schema-driven type generation |
|
||||
| **50+ Page Scale** | Manual duplication | Resource registration | Inferencer + hooks | Collection config | Slice per entity | Resource hooks + config-driven columns |
|
||||
|
||||
---
|
||||
|
||||
## 6. Actionable Recommendations for SAM ERP
|
||||
|
||||
### Priority 1: Introduce a Data Provider / API Client Layer
|
||||
**Why**: The biggest gap vs. industry standard. 89 scattered actions.ts files means duplicated fetch logic, inconsistent error handling, and no centralized caching.
|
||||
|
||||
**Action**: Create a `dataProvider` abstraction inspired by react-admin/Refine:
|
||||
```typescript
|
||||
// src/lib/data-provider.ts
|
||||
export const dataProvider = {
|
||||
getList: (resource, params) => proxyFetch(`/api/proxy/${resource}`, params),
|
||||
getOne: (resource, id) => proxyFetch(`/api/proxy/${resource}/${id}`),
|
||||
create: (resource, data) => proxyFetch(`/api/proxy/${resource}`, { method: 'POST', body: data }),
|
||||
update: (resource, id, data) => proxyFetch(`/api/proxy/${resource}/${id}`, { method: 'PUT', body: data }),
|
||||
delete: (resource, id) => proxyFetch(`/api/proxy/${resource}/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
```
|
||||
|
||||
### Priority 2: Create Resource Hooks
|
||||
**Why**: Reduce per-page boilerplate for list/detail/form patterns.
|
||||
|
||||
**Action**: Build `useResourceList`, `useResourceDetail`, `useResourceForm` hooks that wrap the data provider.
|
||||
|
||||
### Priority 3: Evolve Folder Structure Toward Hybrid FSD
|
||||
**Why**: Atomic Design for app-level code leads to unclear domain boundaries.
|
||||
|
||||
**Action**:
|
||||
- Keep `shared/ui/` (atoms/organisms) for reusable UI components
|
||||
- Add `domains/` or `entities/` for business-logic grouping
|
||||
- Keep `app/` routes thin -- delegate to domain components
|
||||
|
||||
### Priority 4: Complete Form Standardization
|
||||
**Why**: Mixed form patterns make maintenance harder and prevent reusable form configs.
|
||||
|
||||
**Action**: Complete the react-hook-form + Zod migration. Consider field-config-driven forms (Payload pattern) for highly repetitive forms.
|
||||
|
||||
### Priority 5: Consider Server State Management (React Query / TanStack Query)
|
||||
**Why**: react-admin and Refine both use React Query internally for caching, optimistic updates, and background refetching. Zustand is better suited for client UI state.
|
||||
|
||||
**Action**: Evaluate adding TanStack Query for server state alongside Zustand for UI state.
|
||||
|
||||
---
|
||||
|
||||
## 7. What SAM ERP Is Already Doing Well
|
||||
|
||||
1. **Domain-based routing** (`app/[locale]/(protected)/quality/...`) aligns with industry best practice
|
||||
2. **UniversalListPage + IntegratedDetailTemplate** is the right abstraction direction (similar to react-admin's List/Edit components)
|
||||
3. **SearchableSelectionModal** as a reusable pattern is good (similar to react-admin's ReferenceInput)
|
||||
4. **Server Actions in colocated files** follows Next.js official recommendation for route-specific logic
|
||||
5. **Zustand for global state** is a solid choice for UI state (sidebar state, theme, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Open-Source Projects
|
||||
- [react-admin - Architecture](https://marmelab.com/react-admin/Architecture.html)
|
||||
- [react-admin - GitHub](https://github.com/marmelab/react-admin)
|
||||
- [Refine - Data Provider](https://refine.dev/docs/data/data-provider/)
|
||||
- [Refine - GitHub](https://github.com/refinedev/refine)
|
||||
- [Payload CMS - Collections](https://payloadcms.com/docs/configuration/collections)
|
||||
- [Payload CMS - GitHub](https://github.com/payloadcms/payload)
|
||||
- [Medusa - Admin Development](https://docs.medusajs.com/learn/fundamentals/admin)
|
||||
- [Medusa - GitHub](https://github.com/medusajs/medusa)
|
||||
|
||||
### Architectural Methodologies
|
||||
- [Feature-Sliced Design](https://feature-sliced.design/)
|
||||
- [FSD - Layers Reference](https://feature-sliced.design/docs/reference/layers)
|
||||
- [Atomic Design + FSD Hybrid](https://medium.com/@buwanekasumanasekara/atomic-design-meets-feature-based-architecture-in-next-js-a-practical-guide-c06ea56cf5cc)
|
||||
- [Clean Architecture vs FSD in Next.js](https://medium.com/@metastability/clean-architecture-vs-feature-sliced-design-in-next-js-applications-04df25e62690)
|
||||
|
||||
### Folder Structure & Patterns
|
||||
- [Next.js App Router Best Practices (2025)](https://medium.com/better-dev-nextjs-react/inside-the-app-router-best-practices-for-next-js-file-and-directory-structure-2025-edition-ed6bc14a8da3)
|
||||
- [Scalable Next.js Folder Structure](https://techtales.vercel.app/read/thedon/building-a-scalable-folder-structure-for-large-next-js-projects)
|
||||
- [SaaS Architecture Patterns with Next.js](https://vladimirsiedykh.com/blog/saas-architecture-patterns-nextjs)
|
||||
- [Modular Monolith for Frontend](https://frontendatscale.com/issues/45/)
|
||||
@@ -6,7 +6,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
||||
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
||||
serverExternalPackages: ['puppeteer-core', '@sparticuz/chromium'], // Vercel 서버리스 PDF 생성용 - Webpack 번들 제외
|
||||
serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
10833
package-lock.json
generated
10833
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@
|
||||
"lint": "eslint",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed"
|
||||
"test:e2e:headed": "playwright test --h 1eaded"
|
||||
},
|
||||
"dependencies": {
|
||||
"@capacitor/app": "^8.0.0",
|
||||
@@ -35,7 +35,6 @@
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sparticuz/chromium": "^143.0.4",
|
||||
"@tiptap/extension-image": "^3.13.0",
|
||||
"@tiptap/extension-link": "^3.13.0",
|
||||
"@tiptap/extension-placeholder": "^3.13.0",
|
||||
@@ -57,7 +56,7 @@
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.9",
|
||||
"next-intl": "^4.4.0",
|
||||
"puppeteer-core": "^24.37.2",
|
||||
"puppeteer": "^24.37.2",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.3",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import chromium from '@sparticuz/chromium';
|
||||
import puppeteer from 'puppeteer';
|
||||
|
||||
/**
|
||||
* PDF 생성 API
|
||||
@@ -36,20 +35,17 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// 로컬 개발 vs Vercel 환경 분기
|
||||
const isVercel = process.env.VERCEL === '1';
|
||||
// Puppeteer 브라우저 실행 (Docker Alpine에서는 시스템 Chromium 사용)
|
||||
const browser = await puppeteer.launch({
|
||||
args: isVercel ? chromium.args : [
|
||||
headless: true,
|
||||
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined,
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--disable-software-rasterizer',
|
||||
],
|
||||
executablePath: isVercel
|
||||
? await chromium.executablePath()
|
||||
: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/google-chrome-stable',
|
||||
headless: true,
|
||||
});
|
||||
|
||||
const page = await browser.newPage();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRouter, usePathname, useParams } from 'next/navigation';
|
||||
import { ChevronDown, LayoutDashboard, LayoutGrid, Target, Activity, Columns3 } from 'lucide-react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
const dashboards = [
|
||||
{ path: '/dashboard', label: '보고서형', icon: LayoutDashboard },
|
||||
@@ -18,11 +18,21 @@ export function DashboardSwitcher() {
|
||||
const params = useParams();
|
||||
const locale = (params.locale as string) || 'ko';
|
||||
const [open, setOpen] = useState(false);
|
||||
const [alignRight, setAlignRight] = useState(true);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 현재 활성 대시보드 찾기
|
||||
const current = dashboards.find((d) => pathname.endsWith(d.path)) ?? dashboards[0];
|
||||
|
||||
// 드롭다운 열릴 때 버튼 위치 기반으로 정렬 방향 결정
|
||||
const updateAlignment = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
// 버튼 중심이 화면 오른쪽 절반이면 right 정렬, 아니면 left 정렬
|
||||
setAlignRight(rect.left + rect.width / 2 > viewportWidth / 2);
|
||||
}, []);
|
||||
|
||||
// 외부 클릭 시 닫기
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
@@ -32,10 +42,15 @@ export function DashboardSwitcher() {
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const handleToggle = () => {
|
||||
if (!open) updateAlignment();
|
||||
setOpen(!open);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
onClick={() => setOpen(!open)}
|
||||
onClick={handleToggle}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-background text-sm font-medium hover:bg-muted transition-colors"
|
||||
>
|
||||
<current.icon className="w-4 h-4 text-primary" />
|
||||
@@ -44,7 +59,7 @@ export function DashboardSwitcher() {
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 w-44 rounded-lg border bg-background shadow-lg z-50 py-1">
|
||||
<div className={`absolute top-full mt-1 w-44 rounded-lg border bg-background shadow-lg z-50 py-1 ${alignRight ? 'right-0' : 'left-0'}`}>
|
||||
{dashboards.map((d) => {
|
||||
const Icon = d.icon;
|
||||
const isActive = d.path === current.path;
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
/**
|
||||
* 견적 산출내역서 / 견적서 컴포넌트
|
||||
* - documentType="견적서": 간단한 견적서
|
||||
* - documentType="견적산출내역서": 상세 산출내역서 + 소요자재 내역
|
||||
*/
|
||||
|
||||
import { QuoteFormData } from "./types";
|
||||
import type { BomMaterial } from "./types";
|
||||
import type { CompanyFormData } from "@/components/settings/CompanyInfoManagement/types";
|
||||
|
||||
interface QuoteCalculationReportProps {
|
||||
quote: QuoteFormData;
|
||||
companyInfo?: CompanyFormData | null;
|
||||
documentType?: "견적산출내역서" | "견적서";
|
||||
showDetailedBreakdown?: boolean;
|
||||
showMaterialList?: boolean;
|
||||
}
|
||||
|
||||
export function QuoteCalculationReport({
|
||||
quote,
|
||||
companyInfo,
|
||||
documentType = "견적산출내역서",
|
||||
showDetailedBreakdown = true,
|
||||
showMaterialList = true
|
||||
}: QuoteCalculationReportProps) {
|
||||
const formatAmount = (amount: number | null | undefined) => {
|
||||
if (amount == null) return '0';
|
||||
return Number(amount).toLocaleString('ko-KR');
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}년 ${String(date.getMonth() + 1).padStart(2, '0')}월 ${String(date.getDate()).padStart(2, '0')}일`;
|
||||
};
|
||||
|
||||
// 총 금액 계산 (totalAmount > unitPrice * quantity > inspectionFee 우선순위)
|
||||
const totalAmount = quote.items?.reduce((sum, item) => {
|
||||
const itemTotal = item.totalAmount ||
|
||||
(item.unitPrice || 0) * (item.quantity || 1) ||
|
||||
(item.inspectionFee || 0) * (item.quantity || 1);
|
||||
return sum + itemTotal;
|
||||
}, 0) || 0;
|
||||
|
||||
// 소요자재 내역 - BOM 자재 목록 (quote.bomMaterials)에서 가져옴
|
||||
// bomMaterials가 없으면 빈 배열 (BOM 계산 데이터 없음)
|
||||
const materialItems = (quote.bomMaterials || []).map((material, index) => ({
|
||||
no: index + 1,
|
||||
itemCode: material.itemCode || '-',
|
||||
name: material.itemName || '-',
|
||||
spec: material.specification || '-',
|
||||
quantity: Math.floor(material.quantity || 1),
|
||||
unit: material.unit || 'EA',
|
||||
unitPrice: material.unitPrice || 0,
|
||||
totalPrice: material.totalPrice || 0,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 15mm;
|
||||
}
|
||||
|
||||
body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.print\\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
#quote-report-content {
|
||||
background: white !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.page-break-after {
|
||||
page-break-after: always;
|
||||
}
|
||||
}
|
||||
|
||||
/* 공문서 스타일 */
|
||||
.official-doc {
|
||||
font-family: 'Malgun Gothic', 'Apple SD Gothic Neo', sans-serif;
|
||||
background: white;
|
||||
color: #000;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.doc-header {
|
||||
text-align: center;
|
||||
border-bottom: 3px double #000;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.doc-title {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.doc-number {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
border: 2px solid #000;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-box-header {
|
||||
background: #f0f0f0;
|
||||
border-bottom: 2px solid #000;
|
||||
padding: 8px 12px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.info-box-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
background: #f8f8f8;
|
||||
border: 1px solid #999;
|
||||
padding: 8px 10px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.amount-box {
|
||||
border: 3px double #000;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.amount-label {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.amount-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #000;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.amount-note {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
background: #000;
|
||||
color: white;
|
||||
padding: 10px 15px;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
margin: 30px 0 15px 0;
|
||||
text-align: center;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.detail-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
}
|
||||
|
||||
.detail-table thead th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 10px 6px;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table tbody td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.detail-table tbody tr:hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
.detail-table tfoot td {
|
||||
background: #f0f0f0;
|
||||
border: 1px solid #666;
|
||||
padding: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.material-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
border: 2px solid #000;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.material-table th {
|
||||
background: #e8e8e8;
|
||||
border: 1px solid #666;
|
||||
padding: 8px;
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.material-table td {
|
||||
border: 1px solid #999;
|
||||
padding: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.stamp-area {
|
||||
border: 2px solid #000;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.stamp-text {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
text-align: center;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
margin-top: 40px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #ccc;
|
||||
font-size: 11px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.signature-section {
|
||||
margin-top: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 문서 컴포넌트 */}
|
||||
<div className="official-doc">
|
||||
{/* 문서 헤더 */}
|
||||
<div className="doc-header">
|
||||
<div className="doc-title">
|
||||
{documentType === "견적서" ? "견 적 서" : "견 적 산 출 내 역 서"}
|
||||
</div>
|
||||
<div className="doc-number">
|
||||
문서번호: {quote.id || '-'} | 작성일자: {formatDate(quote.registrationDate || '')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 수요자 정보 */}
|
||||
<div className="info-box">
|
||||
<div className="info-box-header">수 요 자</div>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>업체명</th>
|
||||
<td colSpan={3}>{quote.clientName || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>현장명</th>
|
||||
<td>{quote.siteName || '-'}</td>
|
||||
<th>담당자</th>
|
||||
<td>{quote.manager || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>제품명</th>
|
||||
<td>{quote.items?.[0]?.productName || '-'}</td>
|
||||
<th>연락처</th>
|
||||
<td>{quote.contact || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급자 정보 */}
|
||||
<div className="info-box">
|
||||
<div className="info-box-header">공 급 자</div>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>상호</th>
|
||||
<td>{companyInfo?.companyName || '-'}</td>
|
||||
<th>사업자등록번호</th>
|
||||
<td>{companyInfo?.businessNumber || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>대표자</th>
|
||||
<td>{companyInfo?.representativeName || '-'}</td>
|
||||
<th>업태</th>
|
||||
<td>{companyInfo?.businessType || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>종목</th>
|
||||
<td colSpan={3}>{companyInfo?.businessCategory || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>사업장주소</th>
|
||||
<td colSpan={3}>{companyInfo?.address || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>전화</th>
|
||||
<td>{companyInfo?.managerPhone || '-'}</td>
|
||||
<th>이메일</th>
|
||||
<td>{companyInfo?.email || '-'}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 견적금액 */}
|
||||
<div className="amount-box">
|
||||
<div className="amount-label">총 견적금액</div>
|
||||
<div className="amount-value">₩ {formatAmount(totalAmount)}</div>
|
||||
<div className="amount-note">※ 부가가치세 별도</div>
|
||||
</div>
|
||||
|
||||
{/* 세부 산출내역서 */}
|
||||
{showDetailedBreakdown && quote.items && quote.items.length > 0 && (
|
||||
<div className="page-break-after">
|
||||
<div className="section-title">세 부 산 출 내 역</div>
|
||||
|
||||
<table className="detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>No.</th>
|
||||
<th style={{ width: '200px' }}>품목명</th>
|
||||
<th style={{ width: '150px' }}>규격</th>
|
||||
<th style={{ width: '70px' }}>수량</th>
|
||||
<th style={{ width: '50px' }}>단위</th>
|
||||
<th style={{ width: '110px' }}>단가</th>
|
||||
<th style={{ width: '130px' }}>금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quote.items.map((item, index) => {
|
||||
// 단가: unitPrice > inspectionFee 우선순위
|
||||
const unitPrice = item.unitPrice || item.inspectionFee || 0;
|
||||
// 금액: totalAmount > unitPrice * quantity 우선순위
|
||||
const itemTotal = item.totalAmount || unitPrice * (item.quantity || 1);
|
||||
return (
|
||||
<tr key={item.id || `item-${index}`}>
|
||||
<td style={{ textAlign: 'center' }}>{index + 1}</td>
|
||||
<td>{item.productName}</td>
|
||||
<td style={{ fontSize: '11px' }}>{`${item.openWidth}×${item.openHeight}mm`}</td>
|
||||
<td style={{ textAlign: 'right' }}>{Math.floor(item.quantity || 0)}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.unit || 'SET'}</td>
|
||||
<td style={{ textAlign: 'right' }}>{formatAmount(unitPrice)}</td>
|
||||
<td style={{ textAlign: 'right', fontWeight: '600' }}>{formatAmount(itemTotal)}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={6} style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontWeight: '700' }}>공급가액 합계</td>
|
||||
<td style={{ textAlign: 'right', padding: '12px', background: '#e0e0e0', fontSize: '15px', fontWeight: '700' }}>{formatAmount(totalAmount)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 소요자재 내역 */}
|
||||
{showMaterialList && documentType !== "견적서" && (
|
||||
<div>
|
||||
<div className="section-title">소 요 자 재 내 역</div>
|
||||
|
||||
{/* 제품 정보 */}
|
||||
<div className="info-box" style={{ marginTop: '15px' }}>
|
||||
<div className="info-box-content">
|
||||
<table className="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>제품구분</th>
|
||||
<td>{quote.items?.[0]?.productCategory === 'steel' ? '철재' : '스크린'}</td>
|
||||
<th>부호</th>
|
||||
<td>{quote.items?.[0]?.code || '-'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>오픈사이즈</th>
|
||||
<td>W {quote.items?.[0]?.openWidth || '-'} × H {quote.items?.[0]?.openHeight || '-'} (mm)</td>
|
||||
<th>제작사이즈</th>
|
||||
<td>W {Number(quote.items?.[0]?.openWidth || 0) + 100} × H {Number(quote.items?.[0]?.openHeight || 0) + 100} (mm)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>수량</th>
|
||||
<td>{Math.floor(quote.items?.[0]?.quantity || 1)} {quote.items?.[0]?.unit || 'SET'}</td>
|
||||
<th>케이스</th>
|
||||
<td>2438 × 550 (mm)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 자재 목록 테이블 */}
|
||||
{materialItems.length > 0 ? (
|
||||
<table className="material-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '40px' }}>No.</th>
|
||||
<th style={{ width: '100px' }}>품목코드</th>
|
||||
<th>자재명</th>
|
||||
<th style={{ width: '200px' }}>규격</th>
|
||||
<th style={{ width: '80px' }}>수량</th>
|
||||
<th style={{ width: '60px' }}>단위</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{materialItems.map((item, index) => (
|
||||
<tr key={index}>
|
||||
<td style={{ textAlign: 'center' }}>{index + 1}</td>
|
||||
<td style={{ textAlign: 'center', fontSize: '11px' }}>{item.itemCode}</td>
|
||||
<td>{item.name}</td>
|
||||
<td style={{ fontSize: '11px' }}>{item.spec}</td>
|
||||
<td style={{ textAlign: 'center', fontWeight: '600' }}>{item.quantity}</td>
|
||||
<td style={{ textAlign: 'center' }}>{item.unit}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<div style={{
|
||||
border: '2px solid #000',
|
||||
padding: '30px',
|
||||
textAlign: 'center',
|
||||
marginTop: '15px',
|
||||
color: '#666'
|
||||
}}>
|
||||
소요자재 정보가 없습니다. (BOM 계산 데이터가 필요합니다)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 비고사항 */}
|
||||
{quote.remarks && (
|
||||
<div style={{ marginTop: '30px' }}>
|
||||
<div className="section-title">비 고 사 항</div>
|
||||
<div style={{
|
||||
border: '2px solid #000',
|
||||
padding: '15px',
|
||||
minHeight: '100px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.8',
|
||||
marginTop: '15px'
|
||||
}}>
|
||||
{quote.remarks}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 서명란 */}
|
||||
<div className="signature-section">
|
||||
<div style={{ display: 'inline-block', textAlign: 'left' }}>
|
||||
<div style={{ marginBottom: '15px', fontSize: '14px' }}>
|
||||
상기와 같이 견적합니다.
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '13px', marginBottom: '5px' }}>{formatDate(quote.registrationDate || '')}</div>
|
||||
<div style={{ fontSize: '15px', fontWeight: '600' }}>
|
||||
공급자: {companyInfo?.companyName || '-'} (인)
|
||||
</div>
|
||||
</div>
|
||||
<div className="stamp-area">
|
||||
<div className="stamp-text">
|
||||
(인감<br/>날인)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 안내사항 */}
|
||||
<div className="footer-note">
|
||||
<p style={{ fontWeight: '600', marginBottom: '8px' }}>【 유의사항 】</p>
|
||||
<p>1. 본 견적서는 {formatDate(quote.registrationDate || '')} 기준으로 작성되었으며, 자재 가격 변동 시 조정될 수 있습니다.</p>
|
||||
<p>2. 견적 유효기간은 발행일로부터 30일이며, 기간 경과 시 재견적이 필요합니다.</p>
|
||||
<p>3. 제작 사양 및 수량 변경 시 견적 금액이 변동될 수 있습니다.</p>
|
||||
<p>4. 현장 여건에 따라 추가 비용이 발생할 수 있습니다.</p>
|
||||
<p style={{ marginTop: '12px', textAlign: 'center', fontWeight: '600' }}>
|
||||
문의: {companyInfo?.managerName || quote.manager || '담당자'} | {companyInfo?.managerPhone || '-'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export { QuoteManagementClient } from './QuoteManagementClient';
|
||||
// 컴포넌트
|
||||
export { QuoteDocument } from './QuoteDocument';
|
||||
export { QuoteRegistration } from './QuoteRegistration';
|
||||
export { QuoteCalculationReport } from './QuoteCalculationReport';
|
||||
export { PurchaseOrderDocument } from './PurchaseOrderDocument';
|
||||
|
||||
// 타입
|
||||
|
||||
90
src/lib/api/execute-paginated-action.ts
Normal file
90
src/lib/api/execute-paginated-action.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* 페이지네이션 조회 전용 Server Action 래퍼
|
||||
*
|
||||
* executeServerAction + toPaginationMeta 조합을 통합하여
|
||||
* 50+ 파일에서 반복되는 15~25줄 패턴을 5~8줄로 줄입니다.
|
||||
*
|
||||
* 적용 범위: 신규 코드만 (기존 코드 마이그레이션 없음)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Before: ~20줄
|
||||
* const result = await executeServerAction({
|
||||
* url: `${API_URL}/api/v1/bills?${queryString}`,
|
||||
* transform: (data: BillPaginatedResponse) => ({
|
||||
* items: (data?.data || []).map(transformApiToFrontend),
|
||||
* pagination: { currentPage: data?.current_page || 1, ... },
|
||||
* }),
|
||||
* errorMessage: '어음 목록 조회에 실패했습니다.',
|
||||
* });
|
||||
* return { success: result.success, data: result.data?.items || [], ... };
|
||||
*
|
||||
* // After: ~5줄
|
||||
* return executePaginatedAction({
|
||||
* url: buildApiUrl('/api/v1/bills', params),
|
||||
* transform: transformApiToFrontend,
|
||||
* errorMessage: '어음 목록 조회에 실패했습니다.',
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { executeServerAction } from './execute-server-action';
|
||||
import { toPaginationMeta, type PaginatedApiResponse, type PaginationMeta } from './types';
|
||||
|
||||
// ===== 반환 타입 =====
|
||||
export interface PaginatedActionResult<T> {
|
||||
success: boolean;
|
||||
data: T[];
|
||||
pagination: PaginationMeta;
|
||||
error?: string;
|
||||
__authError?: boolean;
|
||||
}
|
||||
|
||||
// ===== 옵션 타입 =====
|
||||
interface PaginatedActionOptions<TApi, TResult> {
|
||||
/** API URL (전체 경로) */
|
||||
url: string;
|
||||
/** 개별 아이템 변환 함수 (API 응답 아이템 → 프론트엔드 타입) */
|
||||
transform: (item: TApi) => TResult;
|
||||
/** 실패 시 기본 에러 메시지 */
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGINATION: PaginationMeta = {
|
||||
currentPage: 1,
|
||||
lastPage: 1,
|
||||
perPage: 20,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 페이지네이션 조회 Server Action 실행
|
||||
*
|
||||
* executeServerAction으로 API 호출 → data 배열에 transform 적용 → toPaginationMeta 변환
|
||||
*/
|
||||
export async function executePaginatedAction<TApi, TResult>(
|
||||
options: PaginatedActionOptions<TApi, TResult>
|
||||
): Promise<PaginatedActionResult<TResult>> {
|
||||
const { url, transform, errorMessage } = options;
|
||||
|
||||
const result = await executeServerAction<PaginatedApiResponse<TApi>>({
|
||||
url,
|
||||
errorMessage,
|
||||
});
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
success: result.success,
|
||||
data: [],
|
||||
pagination: DEFAULT_PAGINATION,
|
||||
error: result.error,
|
||||
__authError: result.__authError,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: (result.data.data || []).map(transform),
|
||||
pagination: toPaginationMeta(result.data),
|
||||
};
|
||||
}
|
||||
@@ -14,6 +14,12 @@ export {
|
||||
type SelectOption,
|
||||
} from './types';
|
||||
|
||||
// 쿼리 파라미터 빌더 (신규 코드용)
|
||||
export { buildQueryParams, buildApiUrl } from './query-params';
|
||||
|
||||
// 페이지네이션 조회 래퍼 (신규 코드용)
|
||||
export { executePaginatedAction, type PaginatedActionResult } from './execute-paginated-action';
|
||||
|
||||
// 공용 룩업 헬퍼 (거래처/계좌 조회)
|
||||
export {
|
||||
fetchVendorOptions,
|
||||
|
||||
48
src/lib/api/query-params.ts
Normal file
48
src/lib/api/query-params.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 조건부 쿼리 파라미터 빌더
|
||||
*
|
||||
* 43개 actions.ts에서 반복되는 URLSearchParams 보일러플레이트를 제거합니다.
|
||||
* - undefined/null/'' 자동 필터링
|
||||
* - boolean/number 자동 String 변환
|
||||
*
|
||||
* 적용 범위: 신규 코드만 (기존 코드 마이그레이션 없음)
|
||||
*/
|
||||
|
||||
type ParamValue = string | number | boolean | undefined | null;
|
||||
|
||||
/**
|
||||
* 조건부 쿼리 파라미터를 URLSearchParams로 변환
|
||||
* undefined/null/'' 값은 자동으로 제외됩니다.
|
||||
*/
|
||||
export function buildQueryParams(
|
||||
params: Record<string, ParamValue>
|
||||
): URLSearchParams {
|
||||
const searchParams = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* API URL + 조건부 쿼리 파라미터를 결합한 전체 URL 생성
|
||||
*
|
||||
* @example
|
||||
* buildApiUrl('/api/v1/bills', {
|
||||
* search: params.search,
|
||||
* bill_type: params.billType !== 'all' ? params.billType : undefined,
|
||||
* page: params.page,
|
||||
* per_page: params.perPage,
|
||||
* })
|
||||
* // → "https://api.example.com/api/v1/bills?search=test&page=1&per_page=20"
|
||||
*/
|
||||
export function buildApiUrl(
|
||||
path: string,
|
||||
params?: Record<string, ParamValue>
|
||||
): string {
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
if (!params) return `${API_URL}${path}`;
|
||||
const qs = buildQueryParams(params).toString();
|
||||
return qs ? `${API_URL}${path}?${qs}` : `${API_URL}${path}`;
|
||||
}
|
||||
@@ -23,6 +23,8 @@ export interface PaginationMeta {
|
||||
}
|
||||
|
||||
// ===== 프론트엔드 페이지네이션 결과 =====
|
||||
// 신규 코드용: executePaginatedAction 외부에서 transform 결과를 직접 조합할 때 사용
|
||||
// (현재 직접 사용처 0건, 삭제 금지)
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
pagination: PaginationMeta;
|
||||
|
||||
@@ -61,47 +61,3 @@ export function generateExportFilename(
|
||||
return `${prefix}_${dateStr}_${timeStr}.${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 내보내기 API 호출을 위한 fetch 옵션 생성
|
||||
*
|
||||
* @param token - 인증 토큰
|
||||
* @param params - 쿼리 파라미터 (선택)
|
||||
* @returns fetch 옵션 객체
|
||||
*/
|
||||
export function createExportFetchOptions(
|
||||
token: string,
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
): RequestInit {
|
||||
return {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
cache: 'no-store',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 파라미터 문자열 생성
|
||||
*
|
||||
* @param params - 쿼리 파라미터 객체
|
||||
* @returns URL 쿼리 문자열 (예: '?year=2025&month=1')
|
||||
*/
|
||||
export function buildExportQueryString(
|
||||
params?: Record<string, string | number | boolean | undefined>
|
||||
): string {
|
||||
if (!params) return '';
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
searchParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
return queryString ? `?${queryString}` : '';
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type UserRole = 'SystemAdmin' | 'Manager' | 'User' | 'Guest';
|
||||
|
||||
interface DemoState {
|
||||
userRole: UserRole;
|
||||
companyName: string;
|
||||
userName: string;
|
||||
setUserRole: (role: UserRole) => void;
|
||||
setCompanyName: (name: string) => void;
|
||||
setUserName: (name: string) => void;
|
||||
resetDemo: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
userRole: 'Manager' as UserRole,
|
||||
companyName: 'SAM 데모 회사',
|
||||
userName: '홍길동',
|
||||
};
|
||||
|
||||
export const useDemoStore = create<DemoState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...DEFAULT_STATE,
|
||||
|
||||
setUserRole: (role: UserRole) => set({ userRole: role }),
|
||||
|
||||
setCompanyName: (name: string) => set({ companyName: name }),
|
||||
|
||||
setUserName: (name: string) => set({ userName: name }),
|
||||
|
||||
resetDemo: () => set(DEFAULT_STATE),
|
||||
}),
|
||||
{
|
||||
name: 'sam-demo',
|
||||
}
|
||||
)
|
||||
);
|
||||
Reference in New Issue
Block a user