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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
511
src/components/pricing/actions.ts
Normal file
511
src/components/pricing/actions.ts
Normal 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: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface PricingListItem {
|
||||
status: PricingStatus | 'not_registered';
|
||||
currentRevision: number;
|
||||
isFinal: boolean;
|
||||
itemTypeCode?: 'PRODUCT' | 'MATERIAL'; // API 등록 시 필요 (PRODUCT 또는 MATERIAL)
|
||||
}
|
||||
|
||||
// ===== 유틸리티 타입 =====
|
||||
|
||||
Reference in New Issue
Block a user