refactor(WEB): API 유틸 분리, 불필요 코드 정리 및 프로젝트 규칙 업데이트

- executePaginatedAction, buildApiUrl 유틸 모듈 분리
- QuoteCalculationReport, demoStore, export.ts 불필요 코드 삭제
- CLAUDE.md에 Zod 스키마 검증 및 Server Action 공통 유틸 규칙 추가
- package.json 의존성 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-11 17:32:19 +09:00
parent be32d224b7
commit ec0d97867f
12 changed files with 263 additions and 624 deletions

View File

@@ -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**: 🔴

View File

@@ -53,6 +53,7 @@
| 항목 | 상태 | 날짜 |
|------|------|------|
| Phase 1: 공통 훅 추출 (executeServerAction 등) | ✅ 완료 | 이전 세션 |
| 중복 코드 공통화 (buildApiUrl + executePaginatedAction) | ✅ 완료 | 2026-02-11 |
| Phase 3: 공용 유틸 추출 (PaginatedApiResponse 등) | ✅ 완료 | 이전 세션 |
| Phase 4: SearchableSelectionModal 공통화 | ✅ 완료 | 이전 세션 |
| Phase 5: any 21건 + memo 3개 정리 | ✅ 완료 | 이전 세션 |

View File

@@ -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 불필요 (오버엔지니어링)
**미적용 사유**: 기존 폼 수십 개를 전면 전환하는 비용 >> 이득. 신규 코드에서 점진적 확산
---
## 폴더 구조

View File

@@ -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",

View File

@@ -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>
</>
);
}

View File

@@ -8,7 +8,6 @@ export { QuoteManagementClient } from './QuoteManagementClient';
// 컴포넌트
export { QuoteDocument } from './QuoteDocument';
export { QuoteRegistration } from './QuoteRegistration';
export { QuoteCalculationReport } from './QuoteCalculationReport';
export { PurchaseOrderDocument } from './PurchaseOrderDocument';
// 타입

View 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),
};
}

View File

@@ -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,

View 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}`;
}

View File

@@ -23,6 +23,8 @@ export interface PaginationMeta {
}
// ===== 프론트엔드 페이지네이션 결과 =====
// 신규 코드용: executePaginatedAction 외부에서 transform 결과를 직접 조합할 때 사용
// (현재 직접 사용처 0건, 삭제 금지)
export interface PaginatedResult<T> {
items: T[];
pagination: PaginationMeta;

View File

@@ -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}` : '';
}

View File

@@ -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',
}
)
);