feat: user,project api 불러오기

main
jschoi 10 months ago
parent 8afa3673cb
commit 1c49ceb6f6

@ -21,8 +21,7 @@ const goMain = () => {
onMounted(() => { onMounted(() => {
isShowAuth.value = true; isShowAuth.value = true;
//storage.getAuth().auth === "ADMIN"; //storage.getAuth().auth === "ADMIN";
}); });
</script> </script>
@ -35,9 +34,10 @@ onMounted(() => {
@click="goMain" @click="goMain"
> >
<div class="d-flex flex-column align-center pt-6"> <div class="d-flex flex-column align-center pt-6">
<v-img :src="logo" width="auto" height="36" class="mb-3"/> <v-img :src="logo" width="auto" height="36" class="mb-3" />
<div class="text-subtitle-2 font-weight-medium text-primary">Autoflow Web Console</div> <div class="text-subtitle-2 font-weight-medium text-primary">
Autoflow Web Console
</div>
</div> </div>
</v-card> </v-card>
<v-list nav class="pa-5 pt-0"> <v-list nav class="pa-5 pt-0">
@ -137,6 +137,4 @@ onMounted(() => {
</v-card> </v-card>
</template> </template>
<style scoped lang="sass"> <style scoped lang="sass"></style>
</style>

@ -4,7 +4,7 @@ import { storage } from "@/utils/storage.js";
import DrawerComponent from "@/components/common/DrawerComponent.vue"; import DrawerComponent from "@/components/common/DrawerComponent.vue";
import { watchEffect } from "vue"; import { watchEffect } from "vue";
import Select from "@/views/Select.vue"; import Select from "@/views/Select.vue";
import { UserManagerService } from "@/components/service/management/userManagerService";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@ -62,10 +62,19 @@ const goSelect = () => {
}; };
const logOut = () => { const logOut = () => {
storage.clearAuth(); // 1) API
router.push("/signin"); UserManagerService.signOut()
.catch((err) => {
// 403 Forbidden ,
//
console.error("logout API failed:", err);
})
.finally(() => {
// 2) &
storage.clearAuth();
router.push("/login");
});
}; };
const goHome = () => { const goHome = () => {
router.push("/main"); router.push("/main");
}; };
@ -85,7 +94,13 @@ watchEffect(() => {
<template> <template>
<v-app> <v-app>
<v-navigation-drawer v-model="drawer" border="0" hide-overlay permanent> <v-navigation-drawer
v-model="drawer"
border="0"
hide-overlay
permanent
v-if="!route.meta.hideSidebar"
>
<DrawerComponent /> <DrawerComponent />
</v-navigation-drawer> </v-navigation-drawer>

@ -0,0 +1,27 @@
export interface UserSearch {
searchType: string;
searchText: string;
pageSize: number;
pageNum: number;
}
export interface User {
userId: string;
userPw: string;
userNm: string;
userAuth: string;
delYn: string;
regDate: string;
regUserId: string;
regUserNm: string;
modDate: string;
modUserId: string;
modUserNm: string;
logingDate: string;
token: string;
}
export interface UserProjectMap {
userId: string;
prjCd: string;
}

@ -0,0 +1,23 @@
export interface ApiProject {
id: number | null;
prjCd: string;
prjNm: string;
prjDesc: string;
prjStartDt: string;
prjEndDt: string;
delYn: string;
regDate: string;
regUserId: string;
regUserNm: string;
modDate: string;
modUserId: string;
modUserNm: string;
}
export interface UiProject {
id: number;
title: string;
creator: string;
date: string;
description: string;
}

@ -0,0 +1,131 @@
import axios from "axios";
import { commonStore, loadingStore } from "@/stores/commonStore";
import { storage } from "@/utils/storage";
import router from "@/router";
const loading = loadingStore();
const API_URL = import.meta.env.VITE_APP_API_SERVER_URL;
console.log("API URL:", API_URL);
export const request = {
post: (uri: string, param: any): any => {
return axios.post(`${API_URL}${uri}`, param);
},
get: (uri: string, param: any): any => {
return axios.get(`${API_URL}${uri}`, { params: param });
},
delete: (uri: string, param: any): any => {
return axios.delete(`${API_URL}${uri}`, param);
},
put: (uri: string, param: any): any => {
return axios.put(`${API_URL}${uri}`, param);
},
postFile: (uri: string, param: any, attachment: any, progress: any): any => {
const formData = new FormData();
for (const key in attachment) {
if (Object.prototype.hasOwnProperty.call(attachment, key)) {
const value = attachment[key];
if (Array.isArray(value)) {
for (const file of value) {
formData.append(key, file);
}
} else {
formData.append(key, value);
}
}
}
formData.append("param", JSON.stringify(param));
return axios.post(`${API_URL}${uri}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: progress,
});
},
postResponseFile: (
uri: string,
param: any,
responseParam: any,
attachment: any,
progress: any,
): any => {
const formData = new FormData();
for (const key in attachment) {
if (Object.prototype.hasOwnProperty.call(attachment, key)) {
const value = attachment[key];
if (Array.isArray(value)) {
for (const file of value) {
formData.append(key, file);
}
}
}
}
formData.append("param", JSON.stringify(param));
formData.append("responseParam", JSON.stringify(responseParam));
return axios.post(`${API_URL}${uri}`, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
onUploadProgress: progress,
});
},
// postOptimization: (uri: string, param: any): any => {
// return axios.post(`${PYTHON_API_URL}${uri}`, param);
// },
// postPython: (uri: string, param: any): Promise<any> => {
// return axios.post(`${PYTHON_API_URL}${uri}`, param);
// },
};
// axios.defaults.withCredentials = true;
axios.defaults.headers.common["Access-Control-Allow-Origin"] = "*";
axios.interceptors.request.use(
(config) => {
loading.setLoading(true);
const token = storage.getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
loading.setLoading(false);
console.log("request error", error);
const store = commonStore();
store.setSnackbarMsg({
text: "에러가 발생하였습니다.",
color: "red",
result: 500,
});
return Promise.reject(error);
},
);
axios.interceptors.response.use(
(response) => {
loading.setLoading(false);
return response;
},
(error) => {
loading.setLoading(false);
const store = commonStore();
store.setSnackbarMsg({
text: "에러가 발생하였습니다.",
color: "red",
result: 500,
});
return Promise.reject(error);
},
);

@ -0,0 +1,27 @@
import { request } from "@/components/service/index";
import { UserSearch, User } from "@/components/models/management/User";
export const UserManagerService = {
signIn: (payload: User) => {
return request.post("/api/auth/signin", payload);
},
signUp: (payload: User) => {
return request.post("/api/auth/signup", payload);
},
signOut: () => request.post("/api/auth/signout", {}),
getAll: () => request.get("/api/auth/users", {}),
select: (param: User) => {
return request.post("/management/user/select", param);
},
add: (param: User) => {
return request.post("/management/user/add", param);
},
update: (param: User) => {
return request.post("/management/user/update", param);
},
delete: (param: User) => {
return request.post("/management/user/delete", param);
},
};

@ -0,0 +1,13 @@
import { request } from "@/components/service/index";
import { ApiProject } from "@/components/models/project/Project";
export const ProjectService = {
search: () => request.get("/api/projects", {}),
add: (payload: ApiProject) => {
return request.post("/api/projects", payload);
},
update: (id: number, payload: ApiProject) => {
return request.put(`/api/projects/${id}`, payload);
},
delete: (id: number) => request.delete(`/api/projects/${id}`, {}),
};

@ -5,13 +5,14 @@ import { storage } from "@/utils/storage";
import logo from "@/assets/wordmark.png"; import logo from "@/assets/wordmark.png";
import logo2 from "@/assets/workflow.png"; import logo2 from "@/assets/workflow.png";
import { UserManagerService } from "@/components/service/management/userManagerService";
const router = useRouter(); const router = useRouter();
const data = ref({ const data = ref({
form: false, form: false,
userId: "", username: "",
userPw: "", password: "",
loading: false, loading: false,
snackbar: false, snackbar: false,
snackbarText: "", snackbarText: "",
@ -24,25 +25,41 @@ const resetForm = () => {
data.value.loading = false; data.value.loading = false;
}; };
const resetLogin = () => {
data.value.userId = "";
data.value.userPw = "";
data.value.loading = false;
};
const resetSignup = () => {
data.value.username = "";
data.value.email = "";
data.value.role = [];
data.value.password = "";
data.value.loading = false;
};
const signIn = () => { const signIn = () => {
// if (!data.value.form) return; const payload = {
// data.value.loading = true; username: data.value.username,
// setTimeout(() => (data.value.loading = false), 2000); password: data.value.password,
};
// const params = { UserManagerService.signIn(payload)
// userId: data.value.userId, .then((res) => {
// password: data.value.userPw, // 200 OK
// }; // ( )
// storage.setAuth(res.data);
// UserManagerService.signIn(params).then((d) => { router.push("/select");
// if (d.data.success === true) { })
// storage.setAuth(d.data.data); .catch((err) => {
router.push("/workflows"); // 401 Unauthorized
// } else { if (err.response?.status === 401) {
// resetForm(); data.value.snackbarText = "아이디 또는 비밀번호가 올바르지 않습니다.";
// data.value.snackbar = true; } else {
// } data.value.snackbarText = "서버 에러가 발생했습니다.";
// }); }
data.value.snackbarColor = "red";
data.value.snackbar = true;
});
}; };
</script> </script>
@ -54,7 +71,7 @@ const signIn = () => {
<v-card flat class="bg-transparent d-flex align-center flex-column"> <v-card flat class="bg-transparent d-flex align-center flex-column">
<v-card <v-card
class="mx-auto pa-10 mb-4 login-box rounded-lg d-flex flex-column" class="mx-auto pa-10 mb-4 login-box rounded-lg d-flex flex-column"
style="min-width: 450px !important;" style="min-width: 450px !important"
density="comfortable" density="comfortable"
> >
<div class="mb-4 w-100 d-flex justify-center"> <div class="mb-4 w-100 d-flex justify-center">
@ -64,7 +81,6 @@ const signIn = () => {
> >
<v-icon class="text-primary">mdi-shield-key-outline</v-icon> <v-icon class="text-primary">mdi-shield-key-outline</v-icon>
</div> </div>
</div> </div>
<div class="text-h5 pb-2 text-center font-weight-bold text-primary"> <div class="text-h5 pb-2 text-center font-weight-bold text-primary">
Autoflow Web Console Autoflow Web Console
@ -72,7 +88,7 @@ const signIn = () => {
<v-form v-model="data.form" @submit.prevent="signIn" class="mt-3"> <v-form v-model="data.form" @submit.prevent="signIn" class="mt-3">
<v-text-field <v-text-field
v-model="data.userId" v-model="data.username"
:readonly="data.loading" :readonly="data.loading"
class="mb-2" class="mb-2"
variant="outlined" variant="outlined"
@ -81,7 +97,7 @@ const signIn = () => {
></v-text-field> ></v-text-field>
<v-text-field <v-text-field
v-model="data.userPw" v-model="data.password"
:readonly="data.loading" :readonly="data.loading"
type="password" type="password"
variant="outlined" variant="outlined"
@ -102,7 +118,19 @@ const signIn = () => {
login login
</v-btn> </v-btn>
</v-form> </v-form>
<div class="mt-4 text-center">
<span>계정이 없으십니까?</span>
<router-link
to="/signup"
class="ml-2 font-weight-medium"
style="color: #90caf9; text-decoration: none"
>
SignUp
</router-link>
</div>
</v-card> </v-card>
<!-- 회원가입 -->
</v-card> </v-card>
<v-sheet <v-sheet
@ -110,7 +138,7 @@ const signIn = () => {
style="bottom: 0; left: 0" style="bottom: 0; left: 0"
> >
<v-sheet class="bg-shades-transparent d-flex align-end" <v-sheet class="bg-shades-transparent d-flex align-end"
>Copyright © 2025 Autoflow Web Console >Copyright © 2025 Autoflow Web Console
</v-sheet> </v-sheet>
</v-sheet> </v-sheet>
<v-sheet <v-sheet
@ -144,13 +172,15 @@ const signIn = () => {
} }
.background-image { .background-image {
background-image: linear-gradient( background-image:
90deg, linear-gradient(
rgba(19, 18, 18, 0.5), 90deg,
rgba(19, 18, 18, 0.3), rgba(19, 18, 18, 0.5),
rgba(19, 18, 18, 0.3), rgba(19, 18, 18, 0.3),
rgba(19, 18, 18, 0.5) rgba(19, 18, 18, 0.3),
),url("@/assets/4117551.jpg"); /* 배경 이미지 경로 */ rgba(19, 18, 18, 0.5)
),
url("@/assets/4117551.jpg"); /* 배경 이미지 경로 */
background-size: cover; /* 이미지가 화면을 꽉 채우도록 설정 */ background-size: cover; /* 이미지가 화면을 꽉 채우도록 설정 */
background-position: center; /* 이미지 중앙 정렬 */ background-position: center; /* 이미지 중앙 정렬 */
background-repeat: no-repeat; /* 이미지 반복 방지 */ background-repeat: no-repeat; /* 이미지 반복 방지 */

@ -0,0 +1,210 @@
<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { storage } from "@/utils/storage";
import logo from "@/assets/wordmark.png";
import logo2 from "@/assets/workflow.png";
import { UserManagerService } from "@/components/service/management/userManagerService";
const router = useRouter();
const data = ref({
form: false,
username: "",
email: "",
role: [],
password: "",
loading: false,
snackbar: false,
snackbarText: "",
snackbarColor: "",
});
const resetForm = () => {
data.value.userId = "";
data.value.userPw = "";
data.value.loading = false;
};
const resetLogin = () => {
data.value.userId = "";
data.value.userPw = "";
data.value.loading = false;
};
const resetSignup = () => {
data.value.username = "";
data.value.email = "";
data.value.role = [];
data.value.password = "";
data.value.loading = false;
};
const signUp = () => {
const payload = {
username: data.value.username,
email: data.value.email,
role: data.value.role,
password: data.value.password,
};
console.log("회원가입 호출 payload:", payload);
UserManagerService.signUp(payload)
.then((res) => {
if (res.data.success) {
router.push("/login");
} else {
data.value.snackbarText =
res.data.message || "회원가입에 실패했습니다.";
data.value.snackbarColor = "red";
data.value.snackbar = true;
}
})
.catch((err) => {
console.error(err);
data.value.snackbarText =
"비밀번호는 6자 이상, 40자 이하로 입력해야 합니다.";
data.value.snackbarColor = "red";
data.value.snackbar = true;
});
};
</script>
<template>
<v-container :fluid="true" class="pa-0">
<v-sheet
class="background-image w-100 h-screen d-flex align-center justify-center flex-column"
>
<v-card flat class="bg-transparent d-flex align-center flex-column">
<v-card
class="mx-auto pa-10 mb-4 login-box rounded-lg d-flex flex-column"
style="min-width: 450px !important"
density="comfortable"
>
<div class="mb-4 w-100 d-flex justify-center">
<div
class="bg-transparent rounded-circle pa-2"
style="border: 1.8px solid #398bec"
>
<v-icon class="text-primary">mdi-shield-key-outline</v-icon>
</div>
</div>
<div class="text-h5 pb-2 text-center font-weight-bold text-primary">
Autoflow Web Console
</div>
<v-form v-model="data.form" @submit.prevent="signUp" class="mt-3">
<v-text-field
v-model="data.username"
:readonly="data.loading"
class="mb-2"
variant="outlined"
color="secondary"
placeholder="아이디를 입력해주세요."
></v-text-field>
<v-text-field
v-model="data.email"
:readonly="data.loading"
class="mb-2"
variant="outlined"
color="secondary"
placeholder="이메일을 입력해주세요."
></v-text-field>
<v-select
v-model="data.role"
:items="['ROLE_USER', 'ROLE_MODERATOR', 'ROLE_ADMIN']"
multiple
variant="outlined"
placeholder="Role"
class="mb-2"
/>
<v-text-field
v-model="data.password"
:readonly="data.loading"
type="password"
variant="outlined"
color="secondary"
class="mb-2"
placeholder="비밀번호를 입력해주세요."
></v-text-field>
<v-btn
:disabled="!data.form"
:loading="data.loading"
block
color="primary"
size="large"
type="submit"
variant="flat"
>
SignUp
</v-btn>
</v-form>
<div class="mt-4 text-center">
<span>계정이 있으십니까?</span>
<router-link
to="/login"
class="ml-2 font-weight-medium"
style="color: #90caf9; text-decoration: none"
>
Login
</router-link>
</div>
</v-card>
</v-card>
<v-sheet
class="position-absolute w-100 bg-transparent pa-4"
style="bottom: 0; left: 0"
>
<v-sheet class="bg-shades-transparent d-flex align-end"
>Copyright © 2025 Autoflow Web Console
</v-sheet>
</v-sheet>
<v-sheet
class="position-absolute w-100 bg-transparent pa-4"
style="bottom: 0; right: 0"
>
<v-sheet class="bg-shades-transparent d-flex justify-end">
<img :src="logo" style="width: 5%" />
</v-sheet>
</v-sheet>
</v-sheet>
</v-container>
<v-snackbar
v-model="data.snackbar"
timeout="2000"
location="button"
:color="data.snackbarColor"
>
{{ data.snackbarText }}
<template #actions>
<v-btn variant="text" @click="data.snackbar = false"> 닫기 </v-btn>
</template>
</v-snackbar>
</template>
<style scoped>
.login-box {
background-color: rgba(18, 18, 18, 0.4); /* 흰색 배경에 30% 불투명도 */
backdrop-filter: blur(10px); /* 배경 블러 효과 */
}
.background-image {
background-image:
linear-gradient(
90deg,
rgba(19, 18, 18, 0.5),
rgba(19, 18, 18, 0.3),
rgba(19, 18, 18, 0.3),
rgba(19, 18, 18, 0.5)
),
url("@/assets/4117551.jpg"); /* 배경 이미지 경로 */
background-size: cover; /* 이미지가 화면을 꽉 채우도록 설정 */
background-position: center; /* 이미지 중앙 정렬 */
background-repeat: no-repeat; /* 이미지 반복 방지 */
min-height: 100vh; /* 화면 전체 높이 설정 */
}
</style>

@ -7,7 +7,7 @@ const routes = [
{ {
path: `/`, path: `/`,
component: () => import("@/layouts/default.vue"), component: () => import("@/layouts/default.vue"),
redirect: { name: "signin" }, redirect: { name: "login" },
children: [ children: [
{ {
name: "main", name: "main",
@ -33,6 +33,7 @@ const routes = [
meta: { meta: {
title: "select", title: "select",
requiresAuth: false, requiresAuth: false,
hideSidebar: true,
}, },
component: () => import("@/views/Select.vue"), component: () => import("@/views/Select.vue"),
}, },
@ -114,14 +115,23 @@ const routes = [
], ],
}, },
{ {
name: "signin", name: "login",
path: `/signin`, path: `/login`,
meta: { meta: {
title: "로그인", title: "로그인",
requiresAuth: false, requiresAuth: false,
}, },
component: () => import("@/pages/LoginView.vue"), component: () => import("@/pages/LoginView.vue"),
}, },
{
name: "signup",
path: `/signup`,
meta: {
title: "로그인",
requiresAuth: false,
},
component: () => import("@/pages/SignupView.vue"),
},
]; ];
const router = createRouter({ const router = createRouter({

@ -1,32 +1,148 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from "vue"; import { ApiProject, UiProject } from "@/components/models/project/Project";
const projects = ref([ import { UserManagerService } from "@/components/service/management/userManagerService";
{ import { ProjectService } from "@/components/service/project/projectService";
title: "배터리 상태 예측 모델 프로젝트", import { onMounted, ref } from "vue";
creator: "ADMIN_001", import { useRouter } from "vue-router";
date: "2025-04-22",
description: const dialog = ref(false);
"센서 데이터를 기반으로 배터리 상태를 예측하는 프로젝트입니다.", const contextMenu = ref(false);
}, const menuX = ref(0);
{ const menuY = ref(0);
title: "운전행동 예측 모델 프로젝트", const selectedIndex = ref<number | null>(null);
creator: "ADMIN_001", const projects = ref<UiProject[]>([]);
date: "2025-03-02", const userOptions = ref<string[]>([]);
description: "급가속, 급제동 등 운전 행동을 기반으로 모델을 예측합니다.", const modalMode = ref<"create" | "edit">("create");
}, const editingProjectId = ref<number | null>(null);
{ const router = useRouter();
title: "강화학습 기반 경로 생성 모델 프로젝트", const form = ref({
creator: "ADMIN_001", prjCd: "",
date: "2025-01-20", prjNm: "",
description: "지도 기반 환경에서 최적 경로를 생성하는 모델입니다.", prjDesc: "",
}, selectedUsers: [] as string[],
{ });
title: "교통 표지판 인식모델 프로젝트",
creator: "USER_001", const loadProjects = async (): Promise<void> => {
date: "2024-12-12", try {
description: "지도 기반 환경에서 최적 경로를 생성하는 모델입니다.", const res = await ProjectService.search();
}, projects.value = res.data.map((p) => ({
]); id: p.id!,
title: p.prjNm,
creator: p.regUserId,
date: p.prjStartDt,
description: p.prjDesc,
}));
} catch (error) {
console.error("프로젝트 조회 실패:", error);
}
};
const loadUsers = async () => {
try {
const res = await UserManagerService.getAll();
// res.data [{id, username, }, ]
userOptions.value = res.data.map((u: any) => u.username);
} catch (err) {
console.error("사용자 조회 실패:", err);
}
};
const selectProject = (idx: number): void => {
const p = projects.value[idx];
console.log("Selected project:", p);
router.push("/home");
};
const openContextMenu = (e: MouseEvent, idx: number): void => {
e.preventDefault();
selectedIndex.value = idx;
menuX.value = e.pageX;
menuY.value = e.pageY;
contextMenu.value = true;
};
const saveProject = async () => {
const payload: ApiProject = {
id: modalMode.value === "edit" ? editingProjectId.value! : null,
prjCd: form.value.prjCd,
prjNm: form.value.prjNm,
prjDesc: form.value.prjDesc,
prjStartDt: new Date().toISOString().slice(0, 10),
prjEndDt: new Date().toISOString().slice(0, 10),
delYn: "N",
regDate: new Date().toISOString(),
regUserId: form.value.selectedUsers.join(","),
regUserNm: form.value.selectedUsers.join(","),
modDate: new Date().toISOString(),
modUserId: form.value.selectedUsers.join(","),
modUserNm: form.value.selectedUsers.join(","),
};
try {
if (modalMode.value === "create") {
await ProjectService.add(payload);
} else {
await ProjectService.update(editingProjectId.value!, payload);
}
await loadProjects();
closeDialog();
} catch (err) {
console.error(`${modalMode.value} 실패:`, err);
}
};
const deleteProject = async (): Promise<void> => {
contextMenu.value = false;
const idx = selectedIndex.value;
if (idx === null) return;
const p = projects.value[idx];
try {
await ProjectService.delete(p.id);
await loadProjects();
} catch (error) {
console.error("삭제 실패:", error);
}
};
const onAddProject = () => {
modalMode.value = "create";
editingProjectId.value = null;
form.value.prjCd = `PRJ${Date.now()}`;
form.value.prjNm = "";
form.value.prjDesc = "";
form.value.selectedUsers = [];
dialog.value = true;
};
const modifyProject = () => {
contextMenu.value = false;
const idx = selectedIndex.value;
if (idx === null) return;
const p = projects.value[idx];
modalMode.value = "edit";
editingProjectId.value = p.id;
//
form.value.prjCd = p.title; // title prjCd
form.value.prjNm = p.title;
form.value.prjDesc = p.description;
form.value.selectedUsers = p.creator.split(","); //
dialog.value = true;
};
const cancel = () => {
dialog.value = false;
};
const closeDialog = () => {
dialog.value = false;
contextMenu.value = false;
selectedIndex.value = null;
};
onMounted(() => {
loadProjects();
loadUsers();
});
</script> </script>
<template> <template>
@ -63,7 +179,8 @@ const projects = ref([
variant="elevated" variant="elevated"
elevation="6" elevation="6"
rounded="lg" rounded="lg"
@click="() => console.log(`Selected: ${project.title}`)" @click="selectProject(index)"
@contextmenu.prevent="(e) => openContextMenu(e, index)"
> >
<v-card-title class="d-flex align-center"> <v-card-title class="d-flex align-center">
<v-icon color="#6EC1E4" icon="mdi-file" start size="18" /> <v-icon color="#6EC1E4" icon="mdi-file" start size="18" />
@ -86,6 +203,63 @@ const projects = ref([
</v-card> </v-card>
</v-col> </v-col>
</v-row> </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"
multiple
chips
closable-chips
/>
</v-form>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn text @click="closeDialog">Cancel</v-btn>
<!-- 클릭 핸들러도 공용 saveProject -->
<v-btn color="primary" @click="saveProject">
{{ modalMode === "create" ? "Create" : "Save" }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container> </v-container>
</template> </template>

Loading…
Cancel
Save