You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
autoflow-web-console/src/views/Select.vue

549 lines
15 KiB

<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, computed } from "vue";
import { useRouter } from "vue-router";
import { useAutoflowStore } from "@/stores/autoflowStore";
import type {
UiProject,
Permission,
} from "@/components/models/project/Project";
import { ProjectService } from "@/components/service/project/projectService";
import { UserManagerService } from "@/components/service/management/userManagerService";
import { storage } from "@/utils/storage.js";
/** ===== 상수 & 기본 권한 ===== */
const DEFAULT_PERMISSIONS: Permission[] = [
"CREATE",
"READ",
"UPDATE",
"DELETE",
];
/** ===== 라우터 & 스토어 ===== */
const router = useRouter();
const autoflowStore = useAutoflowStore();
/** ===== 상태 ===== */
const dialog = ref(false);
const contextMenu = ref(false);
const menuX = ref(0);
const menuY = ref(0);
const selectedIndex = ref<number | null>(null);
// id별 원본 등록자(reg) 보관 (값 그대로, 가공/문자열화 X)
const projectRegById = ref<Record<number, { regId?: string; regNm?: string }>>(
{},
);
const projects = ref<UiProject[]>([]);
type UserOption = { id: number | string; username: string };
const userOptions = ref<UserOption[]>([]);
const modalMode = ref<"create" | "edit">("create");
const editingProjectId = ref<number | null>(null);
const form = ref({
prjCd: "",
prjNm: "",
prjDesc: "",
selectedUsers: [] as string[],
});
/** ===== 롤 ===== */
const roles = ref<string[]>([]);
const refreshRoles = () => {
const auth = storage.getAuth?.() ?? storage.get?.("vpp-Auth") ?? null;
const r = auth?.userInfo?.roles ?? auth?.roles ?? [];
roles.value = Array.isArray(r) ? r : [];
};
const isAdmin = computed(() => roles.value.includes("ROLE_ADMIN"));
/** ===== 페이지네이션 상태 ===== */
const pager = ref({ pageNum: 1, pageSize: 8, total: 0, pageLength: 1 });
const pagedProjects = computed(() => {
const total = projects.value.length;
pager.value.total = total;
pager.value.pageLength = Math.max(1, Math.ceil(total / pager.value.pageSize));
if (pager.value.pageNum > pager.value.pageLength)
pager.value.pageNum = pager.value.pageLength;
if (pager.value.pageNum < 1) pager.value.pageNum = 1;
const start = (pager.value.pageNum - 1) * pager.value.pageSize;
return projects.value.slice(start, start + pager.value.pageSize);
});
const changePageNum = (page: number) => {
pager.value.pageNum = page;
};
const changePageSize = (size: number) => {
pager.value.pageSize = size;
pager.value.pageNum = 1;
};
/** ===== 서버 응답 타입 ===== */
interface ProjectSearchResponseItem {
id: number;
prjNm: string;
prjDesc: string;
prjStartDt?: string;
regUserId?: string;
regUserNm?: string;
modUserId?: string;
modUserNm?: string;
}
interface UserResponseItem {
id: number | string;
username: string;
}
/** ===== 페이로드 타입(서비스 시그니처를 못 바꾼다는 가정에서 지역 타입 분리) ===== */
// 생성 시 mod* 안 보냄
type NewProjectPayload = {
id: null;
prjCd: string;
prjNm: string;
prjDesc: string;
prjStartDt: string;
prjEndDt: string;
delYn: string;
regDate: string;
regUserId?: string;
regUserNm?: string;
};
// 수정 시 reg*는 기존 그대로 유지, mod*만 채움
type UpdateProjectPayload = {
id: number;
prjCd: string;
prjNm: string;
prjDesc: string;
prjStartDt: string;
prjEndDt: string;
delYn: string;
regDate: string;
regUserId?: string;
regUserNm?: string;
modDate: string;
modUserId?: string;
modUserNm?: string;
};
/** ===== 유틸 ===== */
function buildCreatePayload(): NewProjectPayload {
const today = new Date().toISOString().slice(0, 10);
const nowIso = new Date().toISOString();
const names = form.value.selectedUsers;
const namesCsv = names.join(",");
const idsCsv = names
.map((name) => userOptions.value.find((u) => u.username === name)?.id)
.filter(
(v): v is number | string => v !== undefined && v !== null && v !== "",
)
.map(String)
.join(","); // "6,5"
// mod* 없음 (생성)
return {
id: null,
prjCd: form.value.prjCd,
prjNm: form.value.prjNm,
prjDesc: form.value.prjDesc,
prjStartDt: today,
prjEndDt: today,
delYn: "N",
regDate: nowIso,
regUserId: idsCsv,
regUserNm: namesCsv,
};
}
function buildUpdatePayload(): UpdateProjectPayload {
const today = new Date().toISOString().slice(0, 10);
const nowIso = new Date().toISOString();
const names = form.value.selectedUsers;
const namesCsv = names.join(",");
const idsCsv = names
.map((name) => userOptions.value.find((u) => u.username === name)?.id)
.filter(
(v): v is number | string => v !== undefined && v !== null && v !== "",
)
.map(String)
.join(",");
const id = editingProjectId.value!;
const kept = projectRegById.value[id] || {};
// reg*는 DB 원본 유지, mod*만 현재 선택으로 반영
return {
id,
prjCd: form.value.prjCd,
prjNm: form.value.prjNm,
prjDesc: form.value.prjDesc,
prjStartDt: today,
prjEndDt: today,
delYn: "N",
regDate: nowIso,
regUserId: kept.regId,
regUserNm: kept.regNm,
modDate: nowIso,
modUserId: idsCsv,
modUserNm: namesCsv,
};
}
const fillerCount = computed(() =>
Math.max(0, pager.value.pageSize - pagedProjects.value.length),
);
const fillers = computed(() => Array.from({ length: fillerCount.value }));
const resetForm = () => {
form.value.prjCd = `PRJ${Date.now()}`;
form.value.prjNm = "";
form.value.prjDesc = "";
form.value.selectedUsers = [];
};
const loadProjects = async () => {
try {
const { data } = await ProjectService.search();
const rawList = data as ProjectSearchResponseItem[];
const sorted = [...rawList].sort((a, b) => (b.id ?? 0) - (a.id ?? 0));
projectRegById.value = {};
projects.value = sorted.map((p) => {
// 원본 reg 값은 따로 보관(수정 시 reg* 유지용)
projectRegById.value[p.id] = { regId: p.regUserId, regNm: p.regUserNm };
// ★ 화면/모달에 보여줄 선택값은 mod_user_nm이 있으면 그걸로, 없으면 reg_user_nm
const displayNm =
p.modUserNm && p.modUserNm.length > 0 ? p.modUserNm : p.regUserNm || "";
return {
id: p.id,
title: p.prjNm,
creator: displayNm, // ← 이 값이 모달 v-select v-model에 들어감
date: p.prjStartDt, // fallback 없이 그대로
description: p.prjDesc,
};
});
if (
pager.value.pageNum >
Math.max(1, Math.ceil(projects.value.length / pager.value.pageSize))
) {
pager.value.pageNum = 1;
}
} catch (e) {
console.error("프로젝트 조회 실패:", e);
}
};
const loadUsers = async () => {
try {
const { data } = await UserManagerService.getAll();
const raw = data as UserResponseItem[];
userOptions.value = raw.map((u) => ({ id: u.id, username: u.username }));
} catch (e) {
console.error("사용자 조회 실패:", e);
}
};
const openContextMenu = (event: MouseEvent, index: number) => {
event.preventDefault();
selectedIndex.value = index;
menuX.value = event.pageX;
menuY.value = event.pageY;
contextMenu.value = true;
};
const closeDialog = () => {
dialog.value = false;
contextMenu.value = false;
selectedIndex.value = null;
};
const selectProject = (index: number) => {
const selected = pagedProjects.value[index];
autoflowStore.setProjectId(selected.id);
autoflowStore.setProjectName(selected.title);
router.push("/home");
};
const grantDefaultPermissions = async (
projectId: number,
usernames: string[],
) => {
if (!usernames?.length) return;
const nameSet = new Set(usernames);
const numericIds = userOptions.value
.filter((u) => nameSet.has(u.username))
.map((u) => Number(u.id))
.filter((n) => Number.isFinite(n));
await Promise.all(
numericIds.map((uid) =>
ProjectService.projectAuthority(projectId, {
projectId,
userId: uid,
permissions: DEFAULT_PERMISSIONS,
}),
),
);
};
const saveProject = async () => {
if (!isAdmin.value) {
alert("권한이 없습니다. (ROLE_ADMIN 전용)");
return;
}
try {
let projectId: number;
if (modalMode.value === "create") {
const createPayload = buildCreatePayload(); // mod* 없음
const createRes = await ProjectService.add(createPayload); // ← 오타 수정
projectId = createRes.data.id;
} else {
const updatePayload = buildUpdatePayload(); // reg* 유지, mod* 반영
await ProjectService.update(editingProjectId.value!, updatePayload); // ← non-null 보장
projectId = editingProjectId.value!;
}
await grantDefaultPermissions(projectId, form.value.selectedUsers);
await loadProjects();
closeDialog();
} catch (error: any) {
console.error(`${modalMode.value} 실패:`, error?.response?.data || error);
alert(error?.response?.data?.message || "저장 실패");
}
};
const deleteProject = async () => {
try {
if (selectedIndex.value === null) return;
const target = pagedProjects.value[selectedIndex.value];
await ProjectService.delete(target.id);
await loadProjects();
} catch (e: any) {
console.error("삭제 실패:", e?.response?.status, e?.response?.data || e);
alert("삭제 실패: " + (e?.response?.data?.message || e.message || ""));
} finally {
contextMenu.value = false;
}
};
const onAddProject = () => {
if (!isAdmin.value) {
alert("권한이 없습니다. (ROLE_ADMIN 전용)");
return;
}
modalMode.value = "create";
editingProjectId.value = null;
resetForm();
dialog.value = true;
};
const modifyProject = () => {
contextMenu.value = false;
if (selectedIndex.value === null) return;
const selected = pagedProjects.value[selectedIndex.value];
modalMode.value = "edit";
editingProjectId.value = selected.id;
form.value.prjCd = selected.title;
form.value.prjNm = selected.title;
form.value.prjDesc = selected.description;
// creator는 reg_user_nm 그대로 들어오므로 그대로 분해. 값 없으면 []
form.value.selectedUsers =
typeof selected.creator === "string" && selected.creator.length
? selected.creator.split(",").map((s) => s.trim())
: [];
dialog.value = true;
};
/** ===== 라이프사이클 ===== */
const onStorage = (e: StorageEvent) => {
if (!e.key || /auth|vpp-Auth/i.test(e.key)) refreshRoles();
};
onMounted(async () => {
refreshRoles();
await Promise.all([loadProjects(), loadUsers()]);
window.addEventListener("storage", onStorage);
});
onBeforeUnmount(() => {
window.removeEventListener("storage", onStorage);
});
</script>
<template>
<v-container class="mt-12" style="max-width: 1600px">
<v-row class="mb-6" align="center" justify="space-between">
<v-col cols="auto">
<h2 class="font-weight-bold text-h5">Project Selection</h2>
</v-col>
<v-col cols="auto">
<v-btn
v-show="isAdmin"
color="secondary"
variant="flat"
class="text-white font-weight-bold"
@click="onAddProject"
>
<v-icon left icon="mdi-plus" size="20" />
Create Project
</v-btn>
</v-col>
</v-row>
<!-- 카드 그리드: 현재 페이지 데이터만 렌더 -->
<v-row dense>
<!-- 실제 카드 -->
<v-col
v-for="(project, index) in pagedProjects"
:key="project.id"
cols="12"
sm="6"
md="6"
lg="6"
class="d-flex"
>
<v-card
class="project-card pa-4 flex-grow-1 d-flex flex-column"
color="primary"
variant="elevated"
elevation="6"
rounded="lg"
@click="selectProject(index)"
@contextmenu.prevent="(e) => openContextMenu(e, index)"
>
<v-card-title class="d-flex align-center">
<v-icon color="#6EC1E4" icon="mdi-file" start size="18" />
<h4>{{ project.title }}</h4>
</v-card-title>
<v-card-subtitle
class="text-white text-caption d-flex justify-space-between"
>
<span>Select Users: {{ project.creator }}</span>
<span>등록일: {{ project.date }}</span>
</v-card-subtitle>
<v-card-text
class="text-white mt-3 text-body-2 flex-grow-1"
style="white-space: normal"
>
{{ project.description }}
</v-card-text>
</v-card>
</v-col>
<!-- 플레이스홀더(보이지 않지만 자리 차지) -->
<v-col
v-for="(_, i) in fillers"
:key="'filler-' + i"
cols="12"
sm="6"
md="6"
lg="6"
class="d-flex"
>
<v-card
class="project-card pa-4 flex-grow-1"
style="visibility: hidden; pointer-events: none"
rounded="lg"
>
&nbsp;
</v-card>
</v-col>
</v-row>
<!-- 하단 페이지네이션 영역 -->
<v-row class="mt-6" align="center" justify="center">
<v-col
cols="12"
class="d-flex justify-center align-center"
style="gap: 16px"
>
<v-pagination
v-model="pager.pageNum"
:length="pager.pageLength"
:total-visible="7"
color="primary"
rounded="circle"
@update:model-value="changePageNum"
/>
</v-col>
</v-row>
<!-- 컨텍스트 메뉴/다이얼로그 기존 그대로 -->
<v-menu
v-model="contextMenu"
absolute
:style="{ top: menuY + 'px', left: menuX + 'px' }"
max-width="180"
width="130"
>
<v-list>
<v-list-item @click="modifyProject">
<v-list-item-icon
><v-icon>mdi-square-edit-outline</v-icon></v-list-item-icon
>
<v-list-item-title>Modify</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteProject">
<v-list-item-icon><v-icon>mdi-delete</v-icon></v-list-item-icon>
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-dialog v-model="dialog" max-width="500">
<v-card>
<v-card-title class="headline">
{{ modalMode === "create" ? "Create Project" : "Modify Project" }}
</v-card-title>
<v-card-text>
<v-form>
<v-text-field label="Project Name" v-model="form.prjNm" required />
<v-textarea
label="Description"
v-model="form.prjDesc"
rows="3"
required
/>
<v-select
label="Select Users"
v-model="form.selectedUsers"
:items="userOptions"
item-title="username"
item-value="username"
multiple
chips
closable-chips
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" @click="saveProject">
{{ modalMode === "create" ? "Create" : "Save" }}
</v-btn>
<v-btn text @click="closeDialog">Cancel</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.project-card {
min-height: 140px;
}
</style>