org-network / frontend /src /services /projectService.ts
5minbetter's picture
deploy: initial clean workspace without lfs history
33d9e63
Raw
History Blame Contribute Delete
23.3 kB
/**
* @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;
},
};