feat: React 18 프론트엔드 추가 및 프로젝트 구조 정리

- wtm-frontend → wtm-frontend-vue 이름 변경
- wtm-frontend-react 추가 (React 18 + PrimeReact + Zustand)
  - 동일한 모듈 구조 및 API 연동 (Vue 버전과 기능 동일)
  - Vue:5173 / React:5174 포트 분리
- 개발자 가이드에 React 프론트엔드 안내 추가
- .gitignore: Claude/OMC, 문서 생성 스크립트, package-lock 제외
- 불필요 파일 git 추적 제거 (.omc, generate_*.py, regenerate_*.py)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
이 Commit은 다음에 포함되어 있습니다:
2026-03-30 20:50:23 +09:00
부모 dd263a6e46
커밋 cda5f9591e
212개의 변경된 파일3633개의 추가작업 그리고 5244개의 파일을 삭제

파일 보기

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import InputText from 'primevue/inputtext';
import Select from 'primevue/select';
import DatePicker from 'primevue/datepicker';
import BaseFormDialog from '@/core/components/BaseFormDialog.vue';
import type { Project } from '../project.types';
const props = defineProps<{
visible: boolean;
project: Partial<Project> | null;
loading?: boolean;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
save: [data: Partial<Project>];
}>();
const form = ref<Partial<Project>>({});
const typeOptions = [
{ label: 'EPC', value: 'EPC' },
{ label: 'PMC', value: 'PMC' },
{ label: 'FEED', value: 'FEED' },
{ label: 'Other', value: 'OTHER' },
];
const statusOptions = [
{ label: '진행중', value: 'ACTIVE' },
{ label: '종료', value: 'CLOSED' },
{ label: '보류', value: 'HOLD' },
];
watch(
() => props.visible,
(v) => {
if (v) {
form.value = props.project ? { ...props.project } : { status: 'ACTIVE' };
}
},
);
function onSubmit() {
emit('save', { ...form.value });
}
</script>
<template>
<BaseFormDialog
:visible="visible"
:title="project?.id ? '프로젝트 수정' : '프로젝트 등록'"
width="680px"
:loading="loading"
@update:visible="emit('update:visible', $event)"
@submit="onSubmit"
>
<div class="form-grid">
<div class="col-4">
<div class="form-field">
<label class="form-field__label form-field__label--required">프로젝트 코드</label>
<InputText v-model="form.projectCode" placeholder="EPU-2025-001" fluid />
</div>
</div>
<div class="col-8">
<div class="form-field">
<label class="form-field__label form-field__label--required">프로젝트명</label>
<InputText v-model="form.name" placeholder="프로젝트명 입력" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label form-field__label--required">유형</label>
<Select v-model="form.projectType" :options="typeOptions" optionLabel="label" optionValue="value" placeholder="선택" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label form-field__label--required">상태</label>
<Select v-model="form.status" :options="statusOptions" optionLabel="label" optionValue="value" placeholder="선택" fluid />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label">시작일</label>
<DatePicker :modelValue="form.startDate ? new Date(form.startDate) : null" dateFormat="yy-mm-dd" placeholder="YYYY-MM-DD" fluid @update:modelValue="form.startDate = $event ? ($event as Date).toISOString().slice(0, 10) : undefined" />
</div>
</div>
<div class="col-6">
<div class="form-field">
<label class="form-field__label">종료일</label>
<DatePicker :modelValue="form.endDate ? new Date(form.endDate) : null" dateFormat="yy-mm-dd" placeholder="YYYY-MM-DD" fluid @update:modelValue="form.endDate = $event ? ($event as Date).toISOString().slice(0, 10) : undefined" />
</div>
</div>
</div>
</BaseFormDialog>
</template>

파일 보기

@@ -0,0 +1,5 @@
import type { RouteRecordRaw } from 'vue-router';
export const projectRoutes: RouteRecordRaw[] = [
{ path: '/projects', name: 'project-list', component: () => import('./views/ProjectListView.vue'), meta: { title: '프로젝트' } },
{ path: '/projects/:id', name: 'project-detail', component: () => import('./views/ProjectDetailView.vue'), meta: { title: '프로젝트 상세' } },
];

파일 보기

@@ -0,0 +1,16 @@
import api from '@/core/api/axios';
const BASE = '/api/wtm/projects';
export const projectService = {
getAll: (params?: Record<string, unknown>) => api.get(BASE, { params }),
create: (data: unknown) => api.post(BASE, data),
getById: (id: number) => api.get(`${BASE}/${id}`),
update: (id: number, data: unknown) => api.put(`${BASE}/${id}`, data),
getMy: () => api.get(`${BASE}/my`),
getMembers: (id: number) => api.get(`${BASE}/${id}/members`),
addMember: (id: number, data: unknown) => api.post(`${BASE}/${id}/members`, data),
getAssignments: (id: number) => api.get(`${BASE}/${id}/assignments`),
createAssignment: (id: number, data: unknown) => api.post(`${BASE}/${id}/assignments`, data),
updateAssignment: (id: number, aid: number, data: unknown) => api.put(`${BASE}/${id}/assignments/${aid}`, data),
deleteAssignment: (id: number, aid: number) => api.delete(`${BASE}/${id}/assignments/${aid}`),
getAvailable: (id: number) => api.get(`${BASE}/${id}/assignments/available`),
};

파일 보기

@@ -0,0 +1,7 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
export const useProjectStore = defineStore('project', () => {
const projects = ref<unknown[]>([]);
const loading = ref(false);
return { projects, loading };
});

파일 보기

@@ -0,0 +1 @@
export interface Project { id: number; projectCode: string; name: string; projectType: string; status: string; startDate?: string; endDate?: string; pmUserId?: number; }

파일 보기

@@ -0,0 +1 @@
<template><div class="card"><h1>프로젝트 상세</h1></div></template>

파일 보기

@@ -0,0 +1,125 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import Column from 'primevue/column';
import Button from 'primevue/button';
import Tag from 'primevue/tag';
import { useToast } from 'primevue/usetoast';
import BasePageHeader from '@/core/components/BasePageHeader.vue';
import BaseCrudTable from '@/core/components/BaseCrudTable.vue';
import ProjectFormDialog from '../components/ProjectFormDialog.vue';
import { projectService } from '../project.service';
import { PROJECT_STATUS } from '@/core/constants/app.constants';
import type { Project } from '../project.types';
const toast = useToast();
const projects = ref<Project[]>([]);
const loading = ref(false);
const dialogVisible = ref(false);
const dialogLoading = ref(false);
const selectedProject = ref<Partial<Project> | null>(null);
async function loadProjects() {
loading.value = true;
try {
const { data } = await projectService.getAll();
projects.value = (data as any).items ?? data;
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '프로젝트 목록 로드 실패', life: 5000 });
} finally {
loading.value = false;
}
}
function openCreate() {
selectedProject.value = null;
dialogVisible.value = true;
}
function openEdit(project: Project) {
selectedProject.value = { ...project };
dialogVisible.value = true;
}
async function onSave(data: Partial<Project>) {
dialogLoading.value = true;
try {
if (data.id) {
await projectService.update(data.id, data);
toast.add({ severity: 'success', summary: '성공', detail: '프로젝트가 수정되었습니다.', life: 3000 });
} else {
await projectService.create(data);
toast.add({ severity: 'success', summary: '성공', detail: '프로젝트가 등록되었습니다.', life: 3000 });
}
dialogVisible.value = false;
await loadProjects();
} catch {
toast.add({ severity: 'error', summary: '오류', detail: '저장 실패', life: 5000 });
} finally {
dialogLoading.value = false;
}
}
function statusSeverity(status: string) {
return (PROJECT_STATUS as Record<string, any>)[status]?.severity ?? 'secondary';
}
function statusLabel(status: string) {
return (PROJECT_STATUS as Record<string, any>)[status]?.label ?? status;
}
function typeTag(type: string) {
const map: Record<string, string> = { EPC: 'info', PMC: 'warn', FEED: 'success', OTHER: 'secondary' };
return map[type] ?? 'secondary';
}
onMounted(loadProjects);
</script>
<template>
<div class="project-list-view">
<BasePageHeader title="프로젝트 목록" subtitle="프로젝트 조회 및 관리">
<template #actions>
<Button label="등록" icon="pi pi-plus" @click="openCreate" />
</template>
</BasePageHeader>
<BaseCrudTable
:value="projects"
:loading="loading"
:globalFilterFields="['projectCode', 'name', 'projectType', 'status']"
@row-select="openEdit"
>
<Column field="projectCode" header="프로젝트코드" sortable style="min-width: 140px" />
<Column field="name" header="이름" sortable style="min-width: 180px" />
<Column field="projectType" header="유형" sortable style="min-width: 90px">
<template #body="{ data }">
<Tag :value="data.projectType" :severity="typeTag(data.projectType)" />
</template>
</Column>
<Column field="status" header="상태" sortable style="min-width: 90px">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column field="pmUserId" header="PM" sortable style="min-width: 100px" />
<Column header="기간" style="min-width: 180px">
<template #body="{ data }">
{{ data.startDate ?? '-' }} ~ {{ data.endDate ?? '-' }}
</template>
</Column>
<Column header="" style="width: 80px">
<template #body="{ data }">
<Button icon="pi pi-pencil" text rounded severity="info" @click="openEdit(data)" />
</template>
</Column>
</BaseCrudTable>
<ProjectFormDialog
:visible="dialogVisible"
:project="selectedProject"
:loading="dialogLoading"
@update:visible="dialogVisible = $event"
@save="onSave"
/>
</div>
</template>