/** * @license * SPDX-License-Identifier: Apache-2.0 */ import { Project, Employee, SurveyResponse, Question } from '../types'; import { apiClient } from './apiClient'; const PROJECTS_STORAGE_KEY = 'stateless_net_projects'; const RESPONSES_STORAGE_KEY = 'stateless_net_responses'; // Initialize storage if empty function initializeStorage(): Project[] { if (typeof window === 'undefined') { return []; } const stored = window.localStorage.getItem(PROJECTS_STORAGE_KEY); if (stored) { try { return JSON.parse(stored); } catch (e) { console.error('Failed to parse projects from localStorage', e); } } const initial: Project[] = []; window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(initial)); return initial; } export const projectService = { // Get sample employees from backend async getSampleEmployees(): Promise { try { return await apiClient.get('/ops/defaults/sample-employees'); } catch (e) { console.error('Failed to fetch sample employees', e); return []; } }, // Get all projects getProjects(): Project[] { return initializeStorage(); }, // Get project by ID getProjectById(id: string): Project | null { const projects = this.getProjects(); return projects.find((p) => p.id === id) || null; }, // Get project by common survey link code getProjectByCommonCode(code: string): Project | null { const projects = this.getProjects(); return projects.find((p) => p.common_survey_code === code) || null; }, // Sync state from backend to localStorage async syncFromBackend(): Promise { try { const diagnoses = await apiClient.get('/ops/projects'); const projects: Project[] = []; let allResponses: SurveyResponse[] = []; for (const diag of diagnoses) { // 상세 데이터 조회 (사원 및 질문 매칭 포함) const detail = await apiClient.get(`/ops/projects/${diag.id}`); const empList = Object.values(detail.employees || {}); const project: Project = { id: detail.id, company_info: { name: detail.company_info?.name || '회사명 없음', contact_name: detail.company_info?.contact_name || '', contact_email: detail.company_info?.contact_email || '', memo: detail.company_info?.memo || '', }, title: detail.title, status: detail.status, start_date: detail.start_date, end_date: detail.end_date, max_selections: detail.max_selections, anonymous_mode: detail.anonymous_mode || 'real_name', survey_login_id: detail.survey_login_id || '', survey_password_hash: detail.survey_password_hash || '', common_survey_code: detail.common_survey_code || detail.id, common_survey_url: `${window.location.origin}/survey?code=${detail.id}`, respondent_verification_method: 'employee_id_name', questions: (detail.questions || []).map((q: any) => ({ question_id: q.id || q.question_id || '', question_text: q.text || q.question_text || '', network_type: q.network_type || '', max_selections: q.max_selectors || q.max_selections || detail.max_selections || 5, is_required: q.is_required !== undefined ? q.is_required : true, order_no: q.order !== undefined ? q.order : (q.order_no || 0), strength_options: q.strength_options || [], })), employees: empList as Employee[], created_at: detail.created_at || new Date().toISOString(), updated_at: detail.updated_at || new Date().toISOString(), }; projects.push(project); // 해당 프로젝트 응답 조회 및 병합 try { const resList = await apiClient.get(`/ops/projects/${detail.id}/responses`); const mappedResponses: SurveyResponse[] = resList.map((res) => { const empDetail = detail.employees?.[res.respondent_id] || {}; return { schema_version: '1.0', company_name: detail.company_info?.name || '', project_id: detail.id, response_id: res.response_id || `resp_${detail.id}_${res.respondent_id}`, respondent: { employee_id: res.respondent_id, name: empDetail.name || '알 수 없음', email: empDetail.email || '', department: empDetail.department || '', position: empDetail.position || '', }, submitted_at: res.submitted_at || new Date().toISOString(), duration_seconds: res.duration_seconds || 0, answers: (res.answers || []).map((ans: any) => { const questDetail = (detail.questions || []).find((q: any) => q.question_id === ans.question_id) || {}; return { question_id: ans.question_id, network_type: questDetail.network_type || '', question_text: questDetail.question_text || '', selections: (ans.answers || []).map((sel: any) => { const targetEmpDetail = detail.employees?.[sel.target_id] || {}; return { target_employee_id: sel.target_id, target_name: targetEmpDetail.name || '알 수 없음', target_department: targetEmpDetail.department || '', target_position: targetEmpDetail.position || '', rank: sel.rank || 0, strength: String(sel.weight || ''), }; }), }; }), }; }); allResponses = [...allResponses, ...mappedResponses]; } catch (err) { console.error(`Failed to fetch responses for ${detail.id}`, err); } } window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); window.localStorage.setItem(RESPONSES_STORAGE_KEY, JSON.stringify(allResponses)); } catch (e) { console.error('Failed to sync projects from backend', e); } }, // Save/Update a single project async saveProject(updatedProject: Project): Promise { if (typeof window === 'undefined') { return; } // 백그라운드로 백엔드에 업데이트 전송 const updatePayload = { max_selectors: updatedProject.max_selections, survey_login_id: updatedProject.survey_login_id, survey_password_hash: updatedProject.survey_password_hash, common_survey_code: updatedProject.common_survey_code, respondent_verification_method: updatedProject.respondent_verification_method, questions: updatedProject.questions.map((q) => ({ id: q.question_id, text: q.question_text, network_type: q.network_type, max_selectors: q.max_selections || updatedProject.max_selections, order: q.order_no, strength_options: q.strength_options, })), title: updatedProject.title, start_date: updatedProject.start_date, end_date: updatedProject.end_date, status: updatedProject.status, company_info: { name: updatedProject.company_info.name, contact_name: updatedProject.company_info.contact_name, contact_email: updatedProject.company_info.contact_email, memo: updatedProject.company_info.memo, }, }; try { await apiClient.patch(`/ops/projects/${updatedProject.id}/survey`, updatePayload); const projects = this.getProjects(); const index = projects.findIndex((p) => p.id === updatedProject.id); if (index !== -1) { projects[index] = { ...updatedProject, updated_at: new Date().toISOString(), }; } else { projects.push(updatedProject); } window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); console.log('Successfully synced survey configuration to backend'); } catch (err) { console.error('Failed to sync survey configuration to backend', err); throw err; } }, // Add a brand new project async addProject(newProject: Omit): Promise { const project: Project = { ...newProject, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; if (typeof window === 'undefined') { return project; } try { let companyId = ''; const companies = await apiClient.get('/ops/companies'); const existingCompany = companies.find((c) => c.name === newProject.company_info.name); if (existingCompany) { companyId = existingCompany.id; } else { const newCo = await apiClient.post('/ops/companies', { name: newProject.company_info.name, manager_name: newProject.company_info.contact_name || '담당자', manager_email: newProject.company_info.contact_email || 'manager@company.com', }); companyId = newCo.id; } await apiClient.post(`/ops/companies/${companyId}/projects`, { id: newProject.id, title: newProject.title, start_date: newProject.start_date, end_date: newProject.end_date, survey_access_mode: 'both', survey_login_id: newProject.survey_login_id, survey_password_hash: newProject.survey_password_hash, max_selections: newProject.max_selections, }); if (newProject.questions && newProject.questions.length > 0) { const updatePayload = { max_selectors: newProject.max_selections, questions: newProject.questions.map((q) => ({ id: q.question_id, text: q.question_text, network_type: q.network_type, max_selectors: q.max_selections || newProject.max_selections, order: q.order_no, strength_options: q.strength_options, })), }; await apiClient.patch(`/ops/projects/${newProject.id}/survey`, updatePayload); } // API 연동 성공 시에만 localStorage 업데이트 const projects = this.getProjects(); projects.push(project); window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); return project; } catch (err) { console.error('Failed to sync new project to backend', err); throw err; } }, // Update project status (draft, collecting, closed) async updateProjectStatus(projectId: string, status: 'draft' | 'collecting' | 'closed'): Promise { const project = this.getProjectById(projectId); if (!project) return null; project.status = status; await this.saveProject(project); return project; }, // Delete project async deleteProject(id: string): Promise { if (typeof window === 'undefined') { return; } try { await apiClient.delete(`/ops/projects/${id}`); const projects = this.getProjects(); const remaining = projects.filter((p) => p.id !== id); window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(remaining)); // 관련 응답 데이터도 제거 const storedResponses = window.localStorage.getItem(RESPONSES_STORAGE_KEY); if (storedResponses) { try { const allResponses: SurveyResponse[] = JSON.parse(storedResponses); const remainingResponses = allResponses.filter((r) => r.project_id !== id); window.localStorage.setItem(RESPONSES_STORAGE_KEY, JSON.stringify(remainingResponses)); } catch (e) { console.error(e); } } console.log('Successfully deleted project from backend and local cache'); } catch (err) { console.error('Failed to delete project from backend', err); throw err; } }, // Upload organization and validate async uploadAndValidateOrganization( projectId: string, rawEmployees: Array<{ employee_id: string; name: string; department: string; position: string; email?: string; status?: string; }>, mode: 'append' | 'replace' = 'replace', ): Promise<{ total: number; validCount: number; errorCount: number; errors: Array<{ row: number; field: string; message: string; severity: 'error' | 'warning' }>; employees: Employee[]; }> { const project = this.getProjectById(projectId); if (!project) { throw new Error('Project not found'); } const errors: Array<{ row: number; field: string; message: string; severity: 'error' | 'warning' }> = []; const validatedEmployees: Employee[] = []; const seenIds = new Set( mode === 'append' ? project.employees.map((e) => e.employee_id) : [] ); rawEmployees.forEach((emp, index) => { const rowNum = index + 2; const id = String(emp.employee_id || '').trim(); const name = String(emp.name || '').trim(); const dept = String(emp.department || '').trim(); const pos = String(emp.position || '').trim(); const email = String(emp.email || dept).trim(); if (!id) { errors.push({ row: rowNum, field: 'employee_id', message: '사번(employee_id)은 필수 항목입니다.', severity: 'error' }); } if (!name) { errors.push({ row: rowNum, field: 'name', message: '이름(name)은 필수 항목입니다.', severity: 'error' }); } if (!dept) { errors.push({ row: rowNum, field: 'department', message: '부서(department)는 필수 항목입니다.', severity: 'error' }); } if (!pos) { errors.push({ row: rowNum, field: 'position', message: '직급(position)은 필수 항목입니다.', severity: 'error' }); } if (id) { if (seenIds.has(id)) { errors.push({ row: rowNum, field: 'employee_id', message: `중복된 사번이 감지되었습니다: ${id}`, severity: 'error' }); } else { seenIds.add(id); } } validatedEmployees.push({ employee_id: id || `TEMP_${rowNum}`, name: name || '이름 없음', email: email || dept, department: dept || '부서 없음', position: pos || '직급 없음', response_status: 'pending', submitted_at: null, sync_status: 'pending', }); }); const errorCount = errors.filter((e) => e.severity === 'error').length; if (errorCount === 0 && validatedEmployees.length > 0) { const backupEmployees = [...project.employees]; project.employees = mode === 'append' ? [...project.employees, ...validatedEmployees] : validatedEmployees; const importPayload = project.employees.map((e) => ({ employee_id: e.employee_id, name: e.name, department: e.department, position: e.position, email: e.email, })); try { await apiClient.post(`/ops/projects/${projectId}/organization/import-json`, importPayload); // API 연동 성공 시에만 localStorage 갱신 진행 await this.saveProject(project); console.log('Successfully synced organization import to backend'); } catch (err) { // API 연동 실패 시 원래의 사원 상태로 롤백 project.employees = backupEmployees; console.error('Failed to sync organization import to backend', err); throw err; } } return { total: rawEmployees.length, validCount: validatedEmployees.length - errorCount, errorCount, errors, employees: validatedEmployees, }; }, // Submit survey responses (stateless file simulation saved to responses localStorage) async submitSurveyResponse( projectId: string, employeeId: string, answers: Array<{ question_id: string; network_type: string; question_text: string; selections: Array<{ target_employee_id: string; target_name: string; target_department: string; rank: number; strength: string; }>; }>, durationSeconds: number ): Promise { const project = this.getProjectById(projectId); if (!project) { throw new Error('Project not found'); } const employee = project.employees.find((e) => e.employee_id === employeeId); if (!employee) { throw new Error('Employee not registered in this project'); } const responseId = `resp_${Date.now()}_${employeeId}`; const responsePayload: SurveyResponse = { schema_version: '1.0', company_name: project.company_info.name, project_id: projectId, response_id: responseId, respondent: { employee_id: employee.employee_id, name: employee.name, email: employee.email, department: employee.department, position: employee.position, }, submitted_at: new Date().toISOString(), duration_seconds: durationSeconds, answers: answers.map((ans) => ({ question_id: ans.question_id, network_type: ans.network_type, question_text: ans.question_text, selections: ans.selections, })), }; if (typeof window === 'undefined') { throw new Error('Survey responses can only be submitted in the browser'); } // 백엔드로 설문 제출 요청 전송 const submitPayload = { respondent_id: employeeId, answers: answers.map((ans) => ({ question_id: ans.question_id, answers: ans.selections.map((sel) => ({ target_id: sel.target_employee_id, weight: sel.strength, rank: sel.rank, })), })), }; try { await apiClient.post(`/survey/submit/${projectId}`, submitPayload); // API 연동 성공 시에만 localStorage 갱신 진행 const allResponsesStored = window.localStorage.getItem(RESPONSES_STORAGE_KEY); let allResponses: SurveyResponse[] = []; if (allResponsesStored) { try { allResponses = JSON.parse(allResponsesStored); } catch (e) { console.error(e); } } allResponses = allResponses.filter( (r) => !(r.project_id === projectId && r.respondent.employee_id === employeeId) ); allResponses.push(responsePayload); window.localStorage.setItem(RESPONSES_STORAGE_KEY, JSON.stringify(allResponses)); employee.response_status = 'submitted'; employee.submitted_at = responsePayload.submitted_at; // localStorage 내 프로젝트 캐시 업데이트 const projects = this.getProjects(); const pIdx = projects.findIndex(p => p.id === projectId); if (pIdx !== -1) { projects[pIdx] = project; window.localStorage.setItem(PROJECTS_STORAGE_KEY, JSON.stringify(projects)); } console.log('Successfully submitted survey response to backend'); return responsePayload; } catch (err) { console.error('Failed to submit survey response to backend', err); throw err; } }, // Get raw JSON answers for project getProjectResponses(projectId: string): SurveyResponse[] { if (typeof window === "undefined") { return []; } const stored = window.localStorage.getItem(RESPONSES_STORAGE_KEY); if (!stored) return []; try { const all: SurveyResponse[] = JSON.parse(stored); return all.filter((r) => r.project_id === projectId); } catch (e) { console.error(e); return []; } }, // CSV Generation for nodes exportCSVNodes(projectId: string): string { const project = this.getProjectById(projectId); if (!project) return ''; const headers = ['employee_id', 'name', 'department', 'position', 'email']; const rows = project.employees.map((e) => [ e.employee_id, e.name, e.department, e.position, e.email, ]); return [headers.join(','), ...rows.map((r) => r.map((cell) => `"${cell}"`).join(','))].join('\n'); }, // CSV Generation for edges (relationships) exportCSVEdges(projectId: string): string { const project = this.getProjectById(projectId); const responses = this.getProjectResponses(projectId); if (!project) return ''; const headers = [ 'source_id', 'source_name', 'source_department', 'target_id', 'target_name', 'target_department', 'network_type', 'question_id', 'rank', 'strength', 'submitted_at', ]; const rows: string[][] = []; responses.forEach((resp) => { resp.answers.forEach((ans) => { // network_type 누락 방지를 위해 project.questions 정보와 실시간 안전 매핑 const questDetail = (project.questions || []).find( (q) => q.question_id === ans.question_id ); const networkType = ans.network_type || questDetail?.network_type || ''; ans.selections.forEach((sel) => { rows.push([ resp.respondent.employee_id, resp.respondent.name, resp.respondent.department, sel.target_employee_id, sel.target_name, sel.target_department, networkType, ans.question_id, String(sel.rank), sel.strength, resp.submitted_at, ]); }); }); }); return [headers.join(','), ...rows.map((r) => r.map((cell) => `"${cell}"`).join(','))].join('\n'); }, // CSV Generation for responses exportCSVResponses(projectId: string): string { const responses = this.getProjectResponses(projectId); const headers = [ 'response_id', 'respondent_id', 'respondent_name', 'respondent_department', 'question_id', 'selected_id', 'selected_name', 'rank', 'strength', 'submitted_at', ]; const rows: string[][] = []; responses.forEach((resp) => { resp.answers.forEach((ans) => { ans.selections.forEach((sel) => { rows.push([ resp.response_id, resp.respondent.employee_id, resp.respondent.name, resp.respondent.department, ans.question_id, sel.target_employee_id, sel.target_name, String(sel.rank), sel.strength, resp.submitted_at, ]); }); }); }); return [headers.join(','), ...rows.map((r) => r.map((cell) => `"${cell}"`).join(','))].join('\n'); }, // Zip raw JSON generator mockup exportRawJsonZipText(projectId: string): string { const responses = this.getProjectResponses(projectId); return JSON.stringify(responses, null, 2); }, // Get response for a specific respondent in a project getSurveyResponse(projectId: string, employeeId: string): SurveyResponse | null { const responses = this.getProjectResponses(projectId); return responses.find((r) => r.respondent.employee_id === employeeId) || null; }, };