Files
sam-react-prod/src/components/quotes/actions.ts
유병철 81affdc441 feat: ESLint 정리 및 전체 코드 품질 개선
- eslint.config.mjs 규칙 강화 및 정리
- 전역 unused import/변수 제거 (312개 파일)
- next.config.ts, middleware, proxy route 개선
- CopyableCell molecule 추가
- 회계/결재/HR/생산/건설/품질/영업 등 전 도메인 lint 정리
- IntegratedListTemplateV2, DataTable, MobileCard 등 공통 컴포넌트 개선
- execute-server-action 에러 핸들링 보강
2026-03-11 10:27:10 +09:00

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