- eslint.config.mjs 규칙 강화 및 정리 - 전역 unused import/변수 제거 (312개 파일) - next.config.ts, middleware, proxy route 개선 - CopyableCell molecule 추가 - 회계/결재/HR/생산/건설/품질/영업 등 전 도메인 lint 정리 - IntegratedListTemplateV2, DataTable, MobileCard 등 공통 컴포넌트 개선 - execute-server-action 에러 핸들링 보강
537 lines
17 KiB
TypeScript
537 lines
17 KiB
TypeScript
/**
|
|
* 견적 관리 서버 액션
|
|
*
|
|
* API Endpoints:
|
|
* - GET /api/v1/quotes - 목록 조회
|
|
* - GET /api/v1/quotes/{id} - 상세 조회
|
|
* - POST /api/v1/quotes - 등록
|
|
* - PUT /api/v1/quotes/{id} - 수정
|
|
* - DELETE /api/v1/quotes/{id} - 삭제
|
|
* - DELETE /api/v1/quotes/bulk - 일괄 삭제
|
|
* - POST /api/v1/quotes/{id}/finalize - 최종 확정
|
|
* - POST /api/v1/quotes/{id}/cancel-finalize - 확정 취소
|
|
* - POST /api/v1/quotes/{id}/convert - 수주 전환
|
|
* - GET /api/v1/quotes/number/preview - 견적번호 미리보기
|
|
* - POST /api/v1/quotes/{id}/pdf - PDF 생성
|
|
* - POST /api/v1/quotes/{id}/send/email - 이메일 발송
|
|
* - POST /api/v1/quotes/{id}/send/kakao - 카카오 발송
|
|
*/
|
|
|
|
'use server';
|
|
|
|
|
|
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
|
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
|
import { buildApiUrl } from '@/lib/api/query-params';
|
|
import type {
|
|
Quote,
|
|
QuoteApiData,
|
|
QuoteListParams,
|
|
BomCalculationResult,
|
|
} from './types';
|
|
import { transformApiToFrontend } from './types';
|
|
// ===== 견적 목록 조회 =====
|
|
export async function getQuotes(params?: QuoteListParams) {
|
|
return executePaginatedAction<QuoteApiData, Quote>({
|
|
url: buildApiUrl('/api/v1/quotes', {
|
|
page: params?.page,
|
|
size: params?.perPage,
|
|
q: params?.search,
|
|
status: params?.status,
|
|
product_category: params?.productCategory,
|
|
client_id: params?.clientId,
|
|
date_from: params?.dateFrom,
|
|
date_to: params?.dateTo,
|
|
sort_by: params?.sortBy,
|
|
sort_order: params?.sortOrder,
|
|
}),
|
|
transform: transformApiToFrontend,
|
|
errorMessage: '견적 목록 조회에 실패했습니다.',
|
|
});
|
|
}
|
|
|
|
// ===== 견적 상세 조회 =====
|
|
export async function getQuoteById(id: string): Promise<{
|
|
success: boolean;
|
|
data?: Quote;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}`),
|
|
transform: (data: QuoteApiData) => transformApiToFrontend(data),
|
|
errorMessage: '견적 조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 견적 등록 =====
|
|
export async function createQuote(
|
|
data: Record<string, unknown>
|
|
): Promise<{ success: boolean; data?: Quote; error?: string; __authError?: boolean }> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl('/api/v1/quotes'),
|
|
method: 'POST',
|
|
body: data,
|
|
transform: (d: QuoteApiData) => transformApiToFrontend(d),
|
|
errorMessage: '견적 등록에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 견적 수정 =====
|
|
export async function updateQuote(
|
|
id: string,
|
|
data: Record<string, unknown>
|
|
): Promise<{ success: boolean; data?: Quote; error?: string; __authError?: boolean }> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}`),
|
|
method: 'PUT',
|
|
body: data,
|
|
transform: (d: QuoteApiData) => transformApiToFrontend(d),
|
|
errorMessage: '견적 수정에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 견적 삭제 =====
|
|
export async function deleteQuote(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}`),
|
|
method: 'DELETE',
|
|
errorMessage: '견적 삭제에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
// ===== 견적 일괄 삭제 =====
|
|
export async function bulkDeleteQuotes(ids: string[]): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl('/api/v1/quotes/bulk'),
|
|
method: 'DELETE',
|
|
body: { ids: ids.map(id => parseInt(id, 10)) },
|
|
errorMessage: '견적 일괄 삭제에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
// ===== 견적 최종 확정 =====
|
|
export async function finalizeQuote(id: string): Promise<{
|
|
success: boolean;
|
|
data?: Quote;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}/finalize`),
|
|
method: 'POST',
|
|
transform: (d: QuoteApiData) => transformApiToFrontend(d),
|
|
errorMessage: '견적 확정에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 견적 확정 취소 =====
|
|
export async function cancelFinalizeQuote(id: string): Promise<{
|
|
success: boolean;
|
|
data?: Quote;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}/cancel-finalize`),
|
|
method: 'POST',
|
|
transform: (d: QuoteApiData) => transformApiToFrontend(d),
|
|
errorMessage: '견적 확정 취소에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, data: result.data, error: result.error };
|
|
}
|
|
|
|
// ===== 견적 → 수주 전환 =====
|
|
export async function convertQuoteToOrder(id: string): Promise<{
|
|
success: boolean;
|
|
data?: Quote;
|
|
orderId?: string;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
interface ConvertResponse {
|
|
quote?: QuoteApiData;
|
|
order?: { id: number };
|
|
}
|
|
const result = await executeServerAction<ConvertResponse>({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}/convert`),
|
|
method: 'POST',
|
|
errorMessage: '수주 전환에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
|
|
return {
|
|
success: true,
|
|
data: result.data.quote ? transformApiToFrontend(result.data.quote) : undefined,
|
|
orderId: result.data.order?.id ? String(result.data.order.id) : undefined,
|
|
};
|
|
}
|
|
|
|
// ===== 견적번호 미리보기 =====
|
|
export async function getQuoteNumberPreview(): Promise<{
|
|
success: boolean;
|
|
data?: string;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
interface PreviewResponse { quote_number?: string }
|
|
const result = await executeServerAction<PreviewResponse | string>({
|
|
url: buildApiUrl('/api/v1/quotes/number/preview'),
|
|
errorMessage: '견적번호 미리보기에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, error: result.error };
|
|
|
|
const data = result.data;
|
|
const quoteNumber = typeof data === 'string' ? data : (data as PreviewResponse).quote_number || '';
|
|
return { success: true, data: quoteNumber };
|
|
}
|
|
|
|
// ===== PDF 생성 =====
|
|
export async function generateQuotePdf(id: string): Promise<{
|
|
success: boolean;
|
|
data?: Blob;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
try {
|
|
const url = buildApiUrl(`/api/v1/quotes/${id}/pdf`);
|
|
|
|
const { response, error } = await serverFetch(url, {
|
|
method: 'POST',
|
|
});
|
|
|
|
if (error) {
|
|
return {
|
|
success: false,
|
|
error: error.message,
|
|
__authError: error.code === 'UNAUTHORIZED',
|
|
};
|
|
}
|
|
|
|
if (!response) {
|
|
return {
|
|
success: false,
|
|
error: 'PDF 생성에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const result = await response.json().catch(() => ({}));
|
|
return {
|
|
success: false,
|
|
error: result.message || 'PDF 생성에 실패했습니다.',
|
|
};
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
return {
|
|
success: true,
|
|
data: blob,
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[QuoteActions] generateQuotePdf error:', error);
|
|
return {
|
|
success: false,
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== 이메일 발송 =====
|
|
export async function sendQuoteEmail(
|
|
id: string,
|
|
emailData: { email: string; subject?: string; message?: string }
|
|
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}/send/email`),
|
|
method: 'POST',
|
|
body: emailData,
|
|
errorMessage: '이메일 발송에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
// ===== 카카오 발송 =====
|
|
export async function sendQuoteKakao(
|
|
id: string,
|
|
kakaoData: { phone: string; templateId?: string }
|
|
): Promise<{ success: boolean; error?: string; __authError?: boolean }> {
|
|
const result = await executeServerAction({
|
|
url: buildApiUrl(`/api/v1/quotes/${id}/send/kakao`),
|
|
method: 'POST',
|
|
body: kakaoData,
|
|
errorMessage: '카카오 발송에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, __authError: true };
|
|
return { success: result.success, error: result.error };
|
|
}
|
|
|
|
// ===== 완제품(FG) 목록 조회 =====
|
|
export interface FinishedGoods {
|
|
id: number;
|
|
item_code: string;
|
|
item_name: string;
|
|
item_category: string;
|
|
specification?: string;
|
|
unit?: string;
|
|
has_bom?: boolean;
|
|
bom?: unknown[];
|
|
}
|
|
|
|
export async function getFinishedGoods(category?: string): Promise<{
|
|
success: boolean;
|
|
data: FinishedGoods[];
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
interface FGApiResponse { data?: Record<string, unknown>[] }
|
|
const result = await executeServerAction<FGApiResponse | Record<string, unknown>[]>({
|
|
url: buildApiUrl('/api/v1/items', {
|
|
item_type: 'FG',
|
|
item_category: category,
|
|
size: '5000',
|
|
}),
|
|
errorMessage: '완제품 목록 조회에 실패했습니다.',
|
|
});
|
|
|
|
if (result.__authError) return { success: false, data: [], __authError: true };
|
|
if (!result.success || !result.data) return { success: false, data: [], error: result.error };
|
|
|
|
const rawData = result.data;
|
|
const items: Record<string, unknown>[] = Array.isArray(rawData)
|
|
? rawData
|
|
: (rawData as FGApiResponse).data || [];
|
|
|
|
return {
|
|
success: true,
|
|
data: items.map((item) => ({
|
|
id: item.id as number,
|
|
item_code: (item.item_code || item.code) as string,
|
|
item_name: item.name as string,
|
|
item_category: (item.item_category as string) || '',
|
|
specification: item.specification as string | undefined,
|
|
unit: item.unit as string | undefined,
|
|
has_bom: item.has_bom as boolean | undefined,
|
|
bom: item.bom as unknown[] | undefined,
|
|
})),
|
|
};
|
|
}
|
|
|
|
// ===== BOM 기반 자동 견적 산출 (다건) =====
|
|
export interface BomCalculateItem {
|
|
finished_goods_code: string;
|
|
// React 필드명 (camelCase) - API가 내부에서 W0/H0 등으로 변환
|
|
openWidth: number;
|
|
openHeight: number;
|
|
quantity?: number;
|
|
guideRailType?: string;
|
|
motorPower?: string;
|
|
controller?: string;
|
|
wingSize?: number;
|
|
inspectionFee?: number;
|
|
}
|
|
|
|
// BomCalculationResult는 types.ts에서 직접 import하세요
|
|
// import type { BomCalculationResult } from './types';
|
|
|
|
// API 서버 응답 구조 (QuoteCalculationService::calculateBomBulk)
|
|
export interface BomBulkResponse {
|
|
success: boolean;
|
|
summary: {
|
|
total_count: number;
|
|
success_count: number;
|
|
fail_count: number;
|
|
grand_total: number;
|
|
};
|
|
items: Array<{
|
|
index: number;
|
|
finished_goods_code: string;
|
|
inputs: Record<string, unknown>;
|
|
result: BomCalculationResult;
|
|
}>;
|
|
}
|
|
|
|
export async function calculateBomBulk(items: BomCalculateItem[], debug: boolean = true): Promise<{
|
|
success: boolean;
|
|
data: BomBulkResponse | null;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction<BomBulkResponse>({
|
|
url: buildApiUrl('/api/v1/quotes/calculate/bom/bulk'),
|
|
method: 'POST',
|
|
body: { items, debug },
|
|
errorMessage: 'BOM 계산에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, data: null, __authError: true };
|
|
return { success: result.success, data: result.data || null, error: result.error };
|
|
}
|
|
|
|
// ===== 품목 단가 조회 =====
|
|
export interface ItemPriceResult {
|
|
item_code: string;
|
|
unit_price: number;
|
|
}
|
|
|
|
export async function getItemPrices(itemCodes: string[]): Promise<{
|
|
success: boolean;
|
|
data: Record<string, ItemPriceResult> | null;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction<Record<string, ItemPriceResult>>({
|
|
url: buildApiUrl('/api/v1/quotes/items/prices'),
|
|
method: 'POST',
|
|
body: { item_codes: itemCodes },
|
|
errorMessage: '단가 조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, data: null, __authError: true };
|
|
return { success: result.success, data: result.data || null, error: result.error };
|
|
}
|
|
|
|
// ===== 견적 요약 통계 =====
|
|
export async function getQuotesSummary(params?: {
|
|
dateFrom?: string;
|
|
dateTo?: string;
|
|
}): Promise<{
|
|
success: boolean;
|
|
data?: {
|
|
totalAmount: number;
|
|
totalCount: number;
|
|
draftCount: number;
|
|
draftAmount: number;
|
|
finalizedCount: number;
|
|
finalizedAmount: number;
|
|
convertedCount: number;
|
|
convertedAmount: number;
|
|
conversionRate: number;
|
|
};
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
try {
|
|
// 목록 조회를 통해 통계 계산 (별도 API 없는 경우)
|
|
const listResult = await getQuotes({
|
|
perPage: 1000, // 충분히 큰 수
|
|
dateFrom: params?.dateFrom,
|
|
dateTo: params?.dateTo,
|
|
});
|
|
|
|
if (!listResult.success) {
|
|
return {
|
|
success: false,
|
|
error: listResult.error,
|
|
__authError: listResult.__authError,
|
|
};
|
|
}
|
|
|
|
const quotes = listResult.data;
|
|
|
|
const draftQuotes = quotes.filter(q => q.status === 'draft');
|
|
const finalizedQuotes = quotes.filter(q => q.isFinal);
|
|
const convertedQuotes = quotes.filter(q => q.status === 'converted');
|
|
|
|
const totalAmount = quotes.reduce((sum, q) => sum + q.totalAmount, 0);
|
|
const draftAmount = draftQuotes.reduce((sum, q) => sum + q.totalAmount, 0);
|
|
const finalizedAmount = finalizedQuotes.reduce((sum, q) => sum + q.totalAmount, 0);
|
|
const convertedAmount = convertedQuotes.reduce((sum, q) => sum + q.totalAmount, 0);
|
|
|
|
const conversionRate = quotes.length > 0
|
|
? (convertedQuotes.length / quotes.length) * 100
|
|
: 0;
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
totalAmount,
|
|
totalCount: quotes.length,
|
|
draftCount: draftQuotes.length,
|
|
draftAmount,
|
|
finalizedCount: finalizedQuotes.length,
|
|
finalizedAmount,
|
|
convertedCount: convertedQuotes.length,
|
|
convertedAmount,
|
|
conversionRate,
|
|
},
|
|
};
|
|
} catch (error) {
|
|
if (isNextRedirectError(error)) throw error;
|
|
console.error('[QuoteActions] getQuotesSummary error:', error);
|
|
return {
|
|
success: false,
|
|
error: '서버 오류가 발생했습니다.',
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== 견적 참조 데이터 조회 (현장명, 부호 목록) =====
|
|
export interface QuoteReferenceData {
|
|
siteNames: string[];
|
|
locationCodes: string[];
|
|
}
|
|
|
|
export async function getQuoteReferenceData(): Promise<{
|
|
success: boolean;
|
|
data: QuoteReferenceData;
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
interface RefApiData { site_names?: string[]; location_codes?: string[] }
|
|
const result = await executeServerAction<RefApiData>({
|
|
url: buildApiUrl('/api/v1/quotes/reference-data'),
|
|
errorMessage: '참조 데이터 조회에 실패했습니다.',
|
|
});
|
|
const empty: QuoteReferenceData = { siteNames: [], locationCodes: [] };
|
|
if (result.__authError) return { success: false, data: empty, __authError: true };
|
|
if (!result.success || !result.data) return { success: false, data: empty, error: result.error };
|
|
return {
|
|
success: true,
|
|
data: {
|
|
siteNames: result.data.site_names || [],
|
|
locationCodes: result.data.location_codes || [],
|
|
},
|
|
};
|
|
}
|
|
|
|
// ===== 품목 카테고리 트리 조회 =====
|
|
export interface ItemCategoryNode {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
is_active: number;
|
|
sort_order: number;
|
|
children: ItemCategoryNode[];
|
|
}
|
|
|
|
export async function getItemCategoryTree(): Promise<{
|
|
success: boolean;
|
|
data: ItemCategoryNode[];
|
|
error?: string;
|
|
__authError?: boolean;
|
|
}> {
|
|
const result = await executeServerAction<ItemCategoryNode[]>({
|
|
url: buildApiUrl('/api/v1/categories/tree', { code_group: 'item_category', only_active: true }),
|
|
errorMessage: '카테고리 조회에 실패했습니다.',
|
|
});
|
|
if (result.__authError) return { success: false, data: [], __authError: true };
|
|
return { success: result.success, data: result.data || [], error: result.error };
|
|
} |