fix: TypeScript 타입 오류 수정 및 설정 페이지 추가

- BOMItem Omit 타입 시그니처 통일 (useTemplateManagement, SectionsTab, ItemMasterContext)
- HeadersInit → Record<string, string> 타입 변경
- Zustand useShallow 마이그레이션 (zustand/react/shallow)
- DataTable, ListPageTemplate 제네릭 타입 제약 추가
- 설정 관리 페이지 추가 (직급, 직책, 휴가정책, 근무일정, 권한)
- HR 관리 페이지 추가 (급여, 휴가)
- 단가관리 페이지 리팩토링

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-09 18:07:47 +09:00
parent 48dbba0e5f
commit ded0bc2439
98 changed files with 10608 additions and 1204 deletions

View File

@@ -65,6 +65,7 @@ interface PricingFormClientProps {
itemInfo?: ItemInfo;
initialData?: PricingData;
onSave?: (data: PricingData, isRevision?: boolean, revisionReason?: string) => Promise<void>;
onFinalize?: (id: string) => Promise<void>;
}
export function PricingFormClient({
@@ -72,6 +73,7 @@ export function PricingFormClient({
itemInfo,
initialData,
onSave,
onFinalize,
}: PricingFormClientProps) {
const router = useRouter();
const isEditMode = mode === 'edit';
@@ -264,30 +266,9 @@ export function PricingFormClient({
setIsSaving(true);
try {
const finalizedData: PricingData = {
...initialData,
effectiveDate,
receiveDate: receiveDate || undefined,
author: author || undefined,
purchasePrice: purchasePrice || undefined,
processingCost: processingCost || undefined,
loss: loss || undefined,
roundingRule: roundingRule || undefined,
roundingUnit: roundingUnit || undefined,
marginRate: marginRate || undefined,
salesPrice: salesPrice || undefined,
supplier: supplier || undefined,
note: note || undefined,
isFinal: true,
finalizedDate: new Date().toISOString(),
finalizedBy: '관리자',
status: 'finalized',
updatedAt: new Date().toISOString(),
updatedBy: '관리자',
};
if (onSave) {
await onSave(finalizedData);
if (onFinalize) {
// 서버 액션으로 확정 처리
await onFinalize(initialData.id);
}
toast.success('단가가 최종 확정되었습니다.');
@@ -295,6 +276,7 @@ export function PricingFormClient({
router.push('/sales/pricing-management');
} catch (error) {
toast.error('확정 중 오류가 발생했습니다.');
console.error(error);
} finally {
setIsSaving(false);
}

View File

@@ -153,7 +153,9 @@ export function PricingListClient({
// 네비게이션 핸들러
const handleRegister = (item: PricingListItem) => {
router.push(`/sales/pricing-management/create?itemId=${item.itemId}&itemCode=${item.itemCode}`);
// itemTypeCode를 URL 파라미터에 포함 (PRODUCT 또는 MATERIAL)
const itemTypeCode = item.itemTypeCode || 'MATERIAL';
router.push(`/sales/pricing-management/create?itemId=${item.itemId}&itemCode=${item.itemCode}&itemTypeCode=${itemTypeCode}`);
};
const handleEdit = (item: PricingListItem) => {
@@ -414,7 +416,7 @@ export function PricingListClient({
return (
<IntegratedListTemplateV2<PricingListItem>
title="단가 관리"
title="단가 목록"
description="품목별 매입단가, 판매단가 및 마진을 관리합니다"
icon={DollarSign}
headerActions={headerActions}

View File

@@ -0,0 +1,511 @@
/**
* 단가관리 서버 액션
*
* API Endpoints:
* - GET /api/v1/pricing - 목록 조회
* - GET /api/v1/pricing/{id} - 상세 조회
* - POST /api/v1/pricing - 등록
* - PUT /api/v1/pricing/{id} - 수정
* - DELETE /api/v1/pricing/{id} - 삭제
* - POST /api/v1/pricing/{id}/finalize - 확정
* - GET /api/v1/pricing/{id}/revisions - 이력 조회
*/
'use server';
import { cookies } from 'next/headers';
import type { PricingData, ItemInfo } from './types';
// API 응답 타입
interface ApiResponse<T> {
success: boolean;
data: T;
message: string;
}
// 단가 API 응답 데이터 타입
interface PriceApiData {
id: number;
tenant_id: number;
item_type_code: 'PRODUCT' | 'MATERIAL';
item_id: number;
client_group_id: number | null;
purchase_price: string | null;
processing_cost: string | null;
loss_rate: string | null;
margin_rate: string | null;
sales_price: string | null;
rounding_rule: 'round' | 'ceil' | 'floor';
rounding_unit: number;
supplier: string | null;
effective_from: string;
effective_to: string | null;
status: 'draft' | 'active' | 'finalized';
is_final: boolean;
finalized_at: string | null;
finalized_by: number | null;
note: string | null;
created_at: string;
updated_at: string;
deleted_at: string | null;
client_group?: {
id: number;
name: string;
};
product?: {
id: number;
product_code: string;
product_name: string;
specification: string | null;
unit: string;
product_type: string;
};
material?: {
id: number;
item_code: string;
item_name: string;
specification: string | null;
unit: string;
product_type: string;
};
revisions?: Array<{
id: number;
revision_number: number;
changed_at: string;
changed_by: number;
change_reason: string | null;
before_snapshot: Record<string, unknown> | null;
after_snapshot: Record<string, unknown>;
changed_by_user?: {
id: number;
name: string;
};
}>;
}
/**
* API 헤더 생성
*/
async function getApiHeaders(): Promise<HeadersInit> {
const cookieStore = await cookies();
const token = cookieStore.get('access_token')?.value;
return {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': token ? `Bearer ${token}` : '',
'X-API-KEY': process.env.NEXT_PUBLIC_API_KEY || '',
};
}
/**
* API 데이터 → 프론트엔드 타입 변환
*/
function transformApiToFrontend(apiData: PriceApiData): PricingData {
const product = apiData.product;
const material = apiData.material;
const itemCode = product?.product_code || material?.item_code || `ITEM-${apiData.item_id}`;
const itemName = product?.product_name || material?.item_name || '품목명 없음';
const specification = product?.specification || material?.specification || undefined;
const unit = product?.unit || material?.unit || 'EA';
const itemType = product?.product_type || material?.product_type || 'PT';
// 리비전 변환
const revisions = apiData.revisions?.map((rev) => ({
revisionNumber: rev.revision_number,
revisionDate: rev.changed_at,
revisionBy: rev.changed_by_user?.name || `User ${rev.changed_by}`,
revisionReason: rev.change_reason || undefined,
previousData: rev.before_snapshot as unknown as PricingData,
})) || [];
return {
id: String(apiData.id),
itemId: String(apiData.item_id),
itemCode,
itemName,
itemType,
specification,
unit,
effectiveDate: apiData.effective_from,
purchasePrice: apiData.purchase_price ? parseFloat(apiData.purchase_price) : undefined,
processingCost: apiData.processing_cost ? parseFloat(apiData.processing_cost) : undefined,
loss: apiData.loss_rate ? parseFloat(apiData.loss_rate) : undefined,
roundingRule: apiData.rounding_rule || 'round',
roundingUnit: apiData.rounding_unit || 1,
marginRate: apiData.margin_rate ? parseFloat(apiData.margin_rate) : undefined,
salesPrice: apiData.sales_price ? parseFloat(apiData.sales_price) : undefined,
supplier: apiData.supplier || undefined,
note: apiData.note || undefined,
currentRevision: revisions.length,
isFinal: apiData.is_final,
revisions,
finalizedDate: apiData.finalized_at || undefined,
status: apiData.status,
createdAt: apiData.created_at,
createdBy: '관리자',
updatedAt: apiData.updated_at,
updatedBy: '관리자',
};
}
/**
* 프론트엔드 데이터 → API 요청 형식 변환
*/
function transformFrontendToApi(data: PricingData, itemTypeCode: 'PRODUCT' | 'MATERIAL' = 'MATERIAL'): Record<string, unknown> {
return {
item_type_code: itemTypeCode,
item_id: parseInt(data.itemId),
purchase_price: data.purchasePrice || null,
processing_cost: data.processingCost || null,
loss_rate: data.loss || null,
margin_rate: data.marginRate || null,
sales_price: data.salesPrice || null,
rounding_rule: data.roundingRule || 'round',
rounding_unit: data.roundingUnit || 1,
supplier: data.supplier || null,
effective_from: data.effectiveDate,
effective_to: null,
note: data.note || null,
status: data.status || 'draft',
};
}
/**
* 단가 상세 조회
*/
export async function getPricingById(id: string): Promise<PricingData | null> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
if (!response.ok) {
console.error('[PricingActions] GET pricing error:', response.status);
return null;
}
const result: ApiResponse<PriceApiData> = await response.json();
console.log('[PricingActions] GET pricing response:', result);
if (!result.success || !result.data) {
return null;
}
return transformApiToFrontend(result.data);
} catch (error) {
console.error('[PricingActions] getPricingById error:', error);
return null;
}
}
/**
* 품목 정보 조회 (품목기준관리 API)
*/
export async function getItemInfo(itemId: string): Promise<ItemInfo | null> {
try {
const headers = await getApiHeaders();
// materials API로 자재 정보 조회
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/materials/${itemId}`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
if (!response.ok) {
// materials에서 못 찾으면 products에서 조회
const productResponse = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/products/${itemId}`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
if (!productResponse.ok) {
console.error('[PricingActions] Item not found:', itemId);
return null;
}
const productResult = await productResponse.json();
if (!productResult.success || !productResult.data) {
return null;
}
const product = productResult.data;
return {
id: String(product.id),
itemCode: product.product_code,
itemName: product.product_name,
itemType: product.product_type || 'FG',
specification: product.specification || undefined,
unit: product.unit || 'EA',
};
}
const result = await response.json();
if (!result.success || !result.data) {
return null;
}
const material = result.data;
return {
id: String(material.id),
itemCode: material.item_code,
itemName: material.item_name,
itemType: material.product_type || 'RM',
specification: material.specification || undefined,
unit: material.unit || 'EA',
};
} catch (error) {
console.error('[PricingActions] getItemInfo error:', error);
return null;
}
}
/**
* 단가 등록
*/
export async function createPricing(
data: PricingData,
itemTypeCode: 'PRODUCT' | 'MATERIAL' = 'MATERIAL'
): Promise<{ success: boolean; data?: PricingData; error?: string }> {
try {
const headers = await getApiHeaders();
const apiData = transformFrontendToApi(data, itemTypeCode);
console.log('[PricingActions] POST pricing request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing`,
{
method: 'POST',
headers,
body: JSON.stringify(apiData),
}
);
const result = await response.json();
console.log('[PricingActions] POST pricing response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '단가 등록에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[PricingActions] createPricing error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 단가 수정
*/
export async function updatePricing(
id: string,
data: PricingData,
changeReason?: string
): Promise<{ success: boolean; data?: PricingData; error?: string }> {
try {
const headers = await getApiHeaders();
const apiData = {
...transformFrontendToApi(data),
change_reason: changeReason || null,
};
console.log('[PricingActions] PUT pricing request:', apiData);
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`,
{
method: 'PUT',
headers,
body: JSON.stringify(apiData),
}
);
const result = await response.json();
console.log('[PricingActions] PUT pricing response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '단가 수정에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[PricingActions] updatePricing error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 단가 삭제
*/
export async function deletePricing(id: string): Promise<{ success: boolean; error?: string }> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}`,
{
method: 'DELETE',
headers,
}
);
const result = await response.json();
console.log('[PricingActions] DELETE pricing response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '단가 삭제에 실패했습니다.',
};
}
return { success: true };
} catch (error) {
console.error('[PricingActions] deletePricing error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 단가 확정
*/
export async function finalizePricing(id: string): Promise<{ success: boolean; data?: PricingData; error?: string }> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${id}/finalize`,
{
method: 'POST',
headers,
}
);
const result = await response.json();
console.log('[PricingActions] POST finalize response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '단가 확정에 실패했습니다.',
};
}
return {
success: true,
data: transformApiToFrontend(result.data),
};
} catch (error) {
console.error('[PricingActions] finalizePricing error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}
/**
* 단가 이력 조회
*/
export async function getPricingRevisions(priceId: string): Promise<{
success: boolean;
data?: Array<{
revisionNumber: number;
revisionDate: string;
revisionBy: string;
revisionReason?: string;
beforeSnapshot: Record<string, unknown> | null;
afterSnapshot: Record<string, unknown>;
}>;
error?: string;
}> {
try {
const headers = await getApiHeaders();
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing/${priceId}/revisions`,
{
method: 'GET',
headers,
cache: 'no-store',
}
);
const result = await response.json();
console.log('[PricingActions] GET revisions response:', result);
if (!response.ok || !result.success) {
return {
success: false,
error: result.message || '이력 조회에 실패했습니다.',
};
}
const revisions = result.data.data?.map((rev: {
revision_number: number;
changed_at: string;
changed_by: number;
change_reason: string | null;
before_snapshot: Record<string, unknown> | null;
after_snapshot: Record<string, unknown>;
changed_by_user?: { name: string };
}) => ({
revisionNumber: rev.revision_number,
revisionDate: rev.changed_at,
revisionBy: rev.changed_by_user?.name || `User ${rev.changed_by}`,
revisionReason: rev.change_reason || undefined,
beforeSnapshot: rev.before_snapshot,
afterSnapshot: rev.after_snapshot,
})) || [];
return {
success: true,
data: revisions,
};
} catch (error) {
console.error('[PricingActions] getPricingRevisions error:', error);
return {
success: false,
error: '서버 오류가 발생했습니다.',
};
}
}

View File

@@ -8,3 +8,14 @@ export { PricingFormClient } from './PricingFormClient';
export { PricingHistoryDialog } from './PricingHistoryDialog';
export { PricingRevisionDialog } from './PricingRevisionDialog';
export { PricingFinalizeDialog } from './PricingFinalizeDialog';
// Server Actions
export {
getPricingById,
getItemInfo,
createPricing,
updatePricing,
deletePricing,
finalizePricing,
getPricingRevisions,
} from './actions';

View File

@@ -122,6 +122,7 @@ export interface PricingListItem {
status: PricingStatus | 'not_registered';
currentRevision: number;
isFinal: boolean;
itemTypeCode?: 'PRODUCT' | 'MATERIAL'; // API 등록 시 필요 (PRODUCT 또는 MATERIAL)
}
// ===== 유틸리티 타입 =====