'use server'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; import { serverFetch } from '@/lib/api/fetch-wrapper'; import type { Process, ProcessFormData, ClassificationRule, IndividualItem, ProcessStep } from '@/types/process'; // ============================================================================ // API 타입 정의 // ============================================================================ interface ApiProcess { id: number; tenant_id: number; process_code: string; process_name: string; description: string | null; process_type: string; department: string | null; work_log_template: string | null; required_workers: number; equipment_info: string | null; work_steps: string[] | null; note: string | null; is_active: boolean; created_at: string; updated_at: string; classification_rules?: ApiClassificationRule[]; process_items?: ApiProcessItem[]; steps?: ApiProcessStep[]; } interface ApiClassificationRule { id: number; process_id: number; registration_type: string; rule_type: string; matching_type: string; condition_value: string; priority: number; description: string | null; is_active: boolean; created_at: string; updated_at: string; } interface ApiProcessItem { id: number; process_id: number; item_id: number; priority: number; is_active: boolean; item?: { id: number; code: string; name: string; }; } interface ApiResponse { success: boolean; message: string; data: T; } interface PaginatedResponse { current_page: number; data: T[]; last_page: number; per_page: number; total: number; } // ============================================================================ // 데이터 변환 함수 // ============================================================================ function transformApiToFrontend(apiData: ApiProcess): Process { // Pattern 규칙 변환 const patternRules = (apiData.classification_rules ?? []).map(transformRuleApiToFrontend); // 개별 품목 → individual 분류 규칙으로 변환 const individualRules = transformProcessItemsToRules(apiData.process_items ?? []); return { id: String(apiData.id), processCode: apiData.process_code, processName: apiData.process_name, description: apiData.description ?? undefined, processType: apiData.process_type as Process['processType'], department: apiData.department ?? '', workLogTemplate: apiData.work_log_template ?? undefined, classificationRules: [...patternRules, ...individualRules], requiredWorkers: apiData.required_workers, equipmentInfo: apiData.equipment_info ?? undefined, workSteps: apiData.work_steps ?? [], steps: (apiData.steps ?? []).map(transformStepApiToFrontend), note: apiData.note ?? undefined, status: apiData.is_active ? '사용중' : '미사용', createdAt: apiData.created_at, updatedAt: apiData.updated_at, }; } /** * process_items 배열을 individual 분류 규칙으로 변환 * 모든 개별 품목을 하나의 규칙으로 통합 */ function transformProcessItemsToRules(processItems: ApiProcessItem[]): ClassificationRule[] { if (processItems.length === 0) return []; const activeItems = processItems.filter(pi => pi.is_active); if (activeItems.length === 0) return []; // 모든 품목 ID를 쉼표로 구분하여 하나의 규칙으로 통합 const itemIds = activeItems .map(pi => String(pi.item_id)) .join(','); // 품목 상세 정보 추출 (code, name 포함) const items: IndividualItem[] = activeItems .filter(pi => pi.item) // item 정보가 있는 것만 .map(pi => ({ id: String(pi.item!.id), code: pi.item!.code, name: pi.item!.name, })); return [{ id: `individual-${Date.now()}`, registrationType: 'individual', ruleType: '품목코드', matchingType: 'equals', conditionValue: itemIds, priority: 0, description: `개별 품목 ${activeItems.length}개`, isActive: true, createdAt: new Date().toISOString(), items, // 품목 상세 정보 추가 }]; } function transformRuleApiToFrontend(apiRule: ApiClassificationRule): ClassificationRule { return { id: String(apiRule.id), registrationType: apiRule.registration_type as ClassificationRule['registrationType'], ruleType: apiRule.rule_type as ClassificationRule['ruleType'], matchingType: apiRule.matching_type as ClassificationRule['matchingType'], conditionValue: apiRule.condition_value, priority: apiRule.priority, description: apiRule.description ?? undefined, isActive: apiRule.is_active, createdAt: apiRule.created_at, }; } function transformFrontendToApi(data: ProcessFormData): Record { // 패턴 규칙만 분리 (individual 제외) const patternRules = data.classificationRules.filter( (rule) => rule.registrationType === 'pattern' ); // 개별 품목 규칙에서 item_ids 추출 const individualRules = data.classificationRules.filter( (rule) => rule.registrationType === 'individual' ); // 개별 품목의 conditionValue에서 ID 배열 추출 (쉼표 구분) const itemIds: number[] = individualRules.flatMap((rule) => rule.conditionValue .split(',') .map((id) => parseInt(id.trim(), 10)) .filter((n) => !isNaN(n) && n > 0) ); return { process_name: data.processName, process_type: data.processType, department: data.department || null, work_log_template: data.workLogTemplate || null, required_workers: data.requiredWorkers, equipment_info: data.equipmentInfo || null, work_steps: data.workSteps ? data.workSteps.split(',').map((s) => s.trim()).filter(Boolean) : [], note: data.note || null, is_active: data.isActive, // 패턴 규칙만 전송 (registration_type 제외) classification_rules: patternRules.map((rule) => ({ rule_type: rule.ruleType, matching_type: rule.matchingType, condition_value: rule.conditionValue, priority: rule.priority, description: rule.description || null, is_active: rule.isActive, })), // 개별 품목 ID 배열 전송 item_ids: itemIds, }; } // ============================================================================ // API 함수 // ============================================================================ /** * 공정 목록 조회 */ export async function getProcessList(params?: { page?: number; size?: number; q?: string; status?: string; process_type?: string; }): Promise<{ success: boolean; data?: { items: Process[]; total: number; page: number; totalPages: number }; error?: string; __authError?: boolean }> { try { const searchParams = new URLSearchParams(); if (params?.page) searchParams.set('page', String(params.page)); if (params?.size) searchParams.set('size', String(params.size)); if (params?.q) searchParams.set('q', params.q); if (params?.status) searchParams.set('status', params.status); if (params?.process_type) searchParams.set('process_type', params.process_type); const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes?${searchParams.toString()}`, { method: 'GET', cache: 'no-store' } ); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '목록 조회에 실패했습니다.' }; } const result: ApiResponse> = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '목록 조회에 실패했습니다.' }; } const transformed = result.data.data.map(transformApiToFrontend); return { success: true, data: { items: transformed, total: result.data.total, page: result.data.current_page, totalPages: result.data.last_page, }, }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getProcessList] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 상세 조회 */ export async function getProcessById(id: string): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${id}`, { method: 'GET', cache: 'no-store', }); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '조회에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '조회에 실패했습니다.' }; } return { success: true, data: transformApiToFrontend(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getProcessById] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 생성 */ export async function createProcess(data: ProcessFormData): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> { try { const apiData = transformFrontendToApi(data); const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes`, { method: 'POST', body: JSON.stringify(apiData), }); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '등록에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '등록에 실패했습니다.' }; } return { success: true, data: transformApiToFrontend(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[createProcess] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 수정 */ export async function updateProcess(id: string, data: ProcessFormData): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> { try { const apiData = transformFrontendToApi(data); const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${id}`, { method: 'PUT', body: JSON.stringify(apiData), }); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '수정에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '수정에 실패했습니다.' }; } return { success: true, data: transformApiToFrontend(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[updateProcess] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 삭제 */ export async function deleteProcess(id: string): Promise<{ success: boolean; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${id}`, { method: 'DELETE', }); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '삭제에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '삭제에 실패했습니다.' }; } return { success: true }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[deleteProcess] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 일괄 삭제 */ export async function deleteProcesses(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes`, { method: 'DELETE', body: JSON.stringify({ ids: ids.map((id) => parseInt(id, 10)) }), }); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '일괄 삭제에 실패했습니다.' }; } const result: ApiResponse<{ deleted_count: number }> = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '일괄 삭제에 실패했습니다.' }; } return { success: true, deletedCount: result.data.deleted_count }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[deleteProcesses] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 상태 토글 */ export async function toggleProcessActive(id: string): Promise<{ success: boolean; data?: Process; error?: string; __authError?: boolean }> { try { const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${id}/toggle`, { method: 'PATCH', }); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '상태 변경에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '상태 변경에 실패했습니다.' }; } return { success: true, data: transformApiToFrontend(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[toggleProcessActive] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 옵션 목록 (드롭다운용) */ export async function getProcessOptions(): Promise<{ success: boolean; data?: Array<{ id: string; processCode: string; processName: string; processType: string; department: string }>; error?: string; __authError?: boolean; }> { try { const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/options`, { method: 'GET', cache: 'no-store', }); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '옵션 조회에 실패했습니다.' }; } const result: ApiResponse> = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '옵션 조회에 실패했습니다.' }; } return { success: true, data: result.data.map((item) => ({ id: String(item.id), processCode: item.process_code, processName: item.process_name, processType: item.process_type, department: item.department ?? '', })), }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getProcessOptions] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 통계 */ export async function getProcessStats(): Promise<{ success: boolean; data?: { total: number; active: number; inactive: number; byType: Record }; error?: string; __authError?: boolean; }> { try { const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/stats`, { method: 'GET', cache: 'no-store', }); if (error) { return { success: false, error: error.message, __authError: error.code === 'UNAUTHORIZED' }; } if (!response) { return { success: false, error: '통계 조회에 실패했습니다.' }; } const result: ApiResponse<{ total: number; active: number; inactive: number; by_type: Record }> = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '통계 조회에 실패했습니다.' }; } return { success: true, data: { total: result.data.total, active: result.data.active, inactive: result.data.inactive, byType: result.data.by_type, }, }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getProcessStats] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } // ============================================================================ // 부서 옵션 타입 및 함수 // ============================================================================ export interface DepartmentOption { id: string; value: string; label: string; } /** * 부서 목록 조회 */ export async function getDepartmentOptions(): Promise { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/departments`, { method: 'GET', cache: 'no-store', } ); if (error || !response?.ok) { // 기본 부서 옵션 반환 return [ { id: 'default-1', value: '생산부', label: '생산부' }, { id: 'default-2', value: '품질관리부', label: '품질관리부' }, { id: 'default-3', value: '물류부', label: '물류부' }, { id: 'default-4', value: '영업부', label: '영업부' }, ]; } const result = await response.json(); if (result.success && result.data?.data) { // 중복 부서명 제거 (같은 이름이 여러 개일 경우 첫 번째만 사용) const seenNames = new Set(); return result.data.data .filter((dept: { id: number; name: string }) => { if (seenNames.has(dept.name)) { return false; } seenNames.add(dept.name); return true; }) .map((dept: { id: number; name: string }) => ({ id: String(dept.id), value: dept.name, label: dept.name, })); } return []; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getDepartmentOptions] Error:', error); return []; } } // ============================================================================ // 품목 옵션 타입 및 함수 // ============================================================================ export interface ItemOption { value: string; label: string; code: string; id: string; fullName: string; type: string; } interface GetItemListParams { q?: string; itemType?: string; size?: number; } /** * 품목 목록 조회 (분류 규칙용) */ export async function getItemList(params?: GetItemListParams): Promise { try { const searchParams = new URLSearchParams(); searchParams.set('size', String(params?.size || 1000)); if (params?.q) searchParams.set('q', params.q); if (params?.itemType) searchParams.set('item_type', params.itemType); const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?${searchParams.toString()}`, { method: 'GET', cache: 'no-store', } ); if (error || !response?.ok) { return []; } const result = await response.json(); if (result.success && result.data?.data) { return result.data.data.map((item: { id: number; name: string; item_code?: string; item_type?: string; item_type_name?: string }) => ({ value: String(item.id), label: item.name, code: item.item_code || '', id: String(item.id), fullName: item.name, type: item.item_type_name || item.item_type || '', })); } return []; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getItemList] Error:', error); return []; } } /** * 품목 유형 옵션 조회 (common_codes에서 동적 조회) */ export async function getItemTypeOptions(): Promise> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/settings/common/item_type`, { method: 'GET', cache: 'no-store', } ); if (error || !response?.ok) { return []; } const result = await response.json(); if (result.success && Array.isArray(result.data)) { return result.data.map((item: { code: string; name: string }) => ({ value: item.code, label: item.name, })); } return []; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getItemTypeOptions] Error:', error); return []; } } // ============================================================================ // 공정 단계 (Process Step) API // ============================================================================ interface ApiProcessStep { id: number; process_id: number; step_code: string; step_name: string; is_required: boolean; needs_approval: boolean; needs_inspection: boolean; is_active: boolean; sort_order: number; connection_type: string | null; connection_target: string | null; completion_type: string | null; created_at: string; updated_at: string; } function transformStepApiToFrontend(apiStep: ApiProcessStep): ProcessStep { return { id: String(apiStep.id), stepCode: apiStep.step_code, stepName: apiStep.step_name, isRequired: apiStep.is_required, needsApproval: apiStep.needs_approval, needsInspection: apiStep.needs_inspection, isActive: apiStep.is_active, order: apiStep.sort_order, connectionType: (apiStep.connection_type as ProcessStep['connectionType']) || '없음', connectionTarget: apiStep.connection_target ?? undefined, completionType: (apiStep.completion_type as ProcessStep['completionType']) || '클릭 시 완료', }; } /** * 공정 단계 목록 조회 */ export async function getProcessSteps(processId: string): Promise<{ success: boolean; data?: ProcessStep[]; error?: string; }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${processId}/steps`, { method: 'GET', cache: 'no-store' } ); if (error) { return { success: false, error: error.message }; } if (!response) { return { success: false, error: '단계 목록 조회에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '단계 목록 조회에 실패했습니다.' }; } return { success: true, data: result.data.map(transformStepApiToFrontend) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getProcessSteps] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 단계 상세 조회 */ export async function getProcessStepById(processId: string, stepId: string): Promise<{ success: boolean; data?: ProcessStep; error?: string; }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${processId}/steps/${stepId}`, { method: 'GET', cache: 'no-store' } ); if (error) { return { success: false, error: error.message }; } if (!response) { return { success: false, error: '단계 조회에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '단계 조회에 실패했습니다.' }; } return { success: true, data: transformStepApiToFrontend(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[getProcessStepById] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 단계 생성 */ export async function createProcessStep( processId: string, data: Omit ): Promise<{ success: boolean; data?: ProcessStep; error?: string }> { try { const apiData = { step_name: data.stepName, is_required: data.isRequired, needs_approval: data.needsApproval, needs_inspection: data.needsInspection, is_active: data.isActive, connection_type: data.connectionType || null, connection_target: data.connectionTarget || null, completion_type: data.completionType || null, }; const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${processId}/steps`, { method: 'POST', body: JSON.stringify(apiData) } ); if (error) { return { success: false, error: error.message }; } if (!response) { return { success: false, error: '단계 등록에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '단계 등록에 실패했습니다.' }; } return { success: true, data: transformStepApiToFrontend(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[createProcessStep] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 단계 수정 */ export async function updateProcessStep( processId: string, stepId: string, data: Partial ): Promise<{ success: boolean; data?: ProcessStep; error?: string }> { try { const apiData: Record = {}; if (data.stepName !== undefined) apiData.step_name = data.stepName; if (data.isRequired !== undefined) apiData.is_required = data.isRequired; if (data.needsApproval !== undefined) apiData.needs_approval = data.needsApproval; if (data.needsInspection !== undefined) apiData.needs_inspection = data.needsInspection; if (data.isActive !== undefined) apiData.is_active = data.isActive; if (data.connectionType !== undefined) apiData.connection_type = data.connectionType || null; if (data.connectionTarget !== undefined) apiData.connection_target = data.connectionTarget || null; if (data.completionType !== undefined) apiData.completion_type = data.completionType || null; const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${processId}/steps/${stepId}`, { method: 'PUT', body: JSON.stringify(apiData) } ); if (error) { return { success: false, error: error.message }; } if (!response) { return { success: false, error: '단계 수정에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '단계 수정에 실패했습니다.' }; } return { success: true, data: transformStepApiToFrontend(result.data) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[updateProcessStep] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 단계 삭제 */ export async function deleteProcessStep( processId: string, stepId: string ): Promise<{ success: boolean; error?: string }> { try { const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${processId}/steps/${stepId}`, { method: 'DELETE' } ); if (error) { return { success: false, error: error.message }; } if (!response) { return { success: false, error: '단계 삭제에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '단계 삭제에 실패했습니다.' }; } return { success: true }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[deleteProcessStep] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } } /** * 공정 단계 순서 변경 */ export async function reorderProcessSteps( processId: string, steps: { id: string; order: number }[] ): Promise<{ success: boolean; data?: ProcessStep[]; error?: string }> { try { const apiData = { items: steps.map((s) => ({ id: parseInt(s.id, 10), sort_order: s.order, })), }; const { response, error } = await serverFetch( `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/${processId}/steps/reorder`, { method: 'PATCH', body: JSON.stringify(apiData) } ); if (error) { return { success: false, error: error.message }; } if (!response) { return { success: false, error: '순서 변경에 실패했습니다.' }; } const result: ApiResponse = await response.json(); if (!response.ok || !result.success) { return { success: false, error: result.message || '순서 변경에 실패했습니다.' }; } return { success: true, data: result.data.map(transformStepApiToFrontend) }; } catch (error) { if (isNextRedirectError(error)) throw error; console.error('[reorderProcessSteps] Error:', error); return { success: false, error: '서버 오류가 발생했습니다.' }; } }