Spaces:
Running
Running
| /** | |
| * @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<Employee[]> { | |
| try { | |
| return await apiClient.get<Employee[]>('/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<void> { | |
| try { | |
| const diagnoses = await apiClient.get<any[]>('/ops/projects'); | |
| const projects: Project[] = []; | |
| let allResponses: SurveyResponse[] = []; | |
| for (const diag of diagnoses) { | |
| // 상세 데이터 조회 (사원 및 질문 매칭 포함) | |
| const detail = await apiClient.get<any>(`/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<any[]>(`/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<void> { | |
| 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<Project, 'created_at' | 'updated_at'>): Promise<Project> { | |
| 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<any[]>('/ops/companies'); | |
| const existingCompany = companies.find((c) => c.name === newProject.company_info.name); | |
| if (existingCompany) { | |
| companyId = existingCompany.id; | |
| } else { | |
| const newCo = await apiClient.post<any>('/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<Project | null> { | |
| 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<void> { | |
| 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<string>( | |
| 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<SurveyResponse> { | |
| 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; | |
| }, | |
| }; | |