Compare commits

..

No commits in common. "main" and "mypage" have entirely different histories.
main ... mypage

14 changed files with 49 additions and 474 deletions

View File

@ -138,20 +138,17 @@
// //
quillInstance.getModule('toolbar').addHandler('video', () => { quillInstance.getModule('toolbar').addHandler('video', () => {
const url = prompt('YouTube 영상 URL을 입력하세요:'); const url = prompt('YouTube 영상 URL을 입력하세요:');
let src = ''; let src = '';
if (!url || url.trim() == '') return; if (!url || url.trim() == '') return;
// youtube url // youtube url
if (url.indexOf('watch?v=') !== -1) { if (url.indexOf('watch?v=') != -1) {
src = url.replace('watch?v=', 'embed/'); src = url.replace('watch?v=', 'embed/');
// youtu.be URL (ex : https://youtu.be/CfiojceAaeQ?si=G7eM56sdDjIEw-Tz)
} else if (url.indexOf('youtu.be/') !== -1) {
const videoId = url.split('youtu.be/')[1].split('?')[0];
src = `https://www.youtube.com/embed/${videoId}`;
// iframe // iframe
} else if (url.indexOf('<iframe') !== -1) { } else if (url.indexOf('<iframe') != -1) {
// DOMParser embeded url // DOMParser embeded url
const parser = new DOMParser(); const parser = new DOMParser();
const doc = parser.parseFromString(url, 'text/html'); const doc = parser.parseFromString(url, 'text/html');

View File

@ -14,7 +14,7 @@
:value="computedValue" :value="computedValue"
:disabled="disabled" :disabled="disabled"
:maxLength="maxlength" :maxLength="maxlength"
:placeholder="placeholder ? placeholder : title" :placeholder="title"
@blur="$emit('blur')" @blur="$emit('blur')"
/> />
<span class="input-group-text">@ localhost.co.kr</span> <span class="input-group-text">@ localhost.co.kr</span>
@ -29,7 +29,7 @@
:value="computedValue" :value="computedValue"
:disabled="disabled" :disabled="disabled"
:maxLength="maxlength" :maxLength="maxlength"
:placeholder="placeholder ? placeholder : title" :placeholder="title"
@blur="$emit('blur')" @blur="$emit('blur')"
@click="handleDateClick" @click="handleDateClick"
ref="inputElement" ref="inputElement"
@ -89,10 +89,6 @@
default: false, default: false,
required: false, required: false,
}, },
placeholder: {
type: String,
default: ''
},
}); });
const emits = defineEmits(['update:data', 'update:alert', 'blur']); const emits = defineEmits(['update:data', 'update:alert', 'blur']);

View File

@ -14,14 +14,7 @@
{{ user.name }} {{ user.name }}
</p> </p>
<CommuterBtn <CommuterBtn :userId="user.id" :checkedInProject="checkedInProject || {}" ref="workTimeComponentRef" />
ref="workTimeComponentRef"
:userId="user.id"
:checkedInProject="checkedInProject || {}"
:pendingProjectChange="pendingProjectChange"
@update:pendingProjectChange="pendingProjectChange = $event"
@leaveTimeUpdated="handleLeaveTimeUpdate"
/>
<MainEventList <MainEventList
:categoryList="categoryList" :categoryList="categoryList"
@ -106,7 +99,6 @@
const selectedProject = ref(null); const selectedProject = ref(null);
const checkedInProject = ref(null); const checkedInProject = ref(null);
const pendingProjectChange = ref(null);
// //
const showModal = ref(false); const showModal = ref(false);
@ -613,48 +605,10 @@
if (newProject) { if (newProject) {
selectedProject.value = newProject.PROJCTSEQ; selectedProject.value = newProject.PROJCTSEQ;
checkedInProject.value = newProject; checkedInProject.value = newProject;
} else {
selectedProject.value = null;
checkedInProject.value = null;
} }
}, },
); );
const handleLeaveTimeUpdate = async event => {
const memberSeq = user.value.id;
if (!memberSeq) return;
//
const { data } = await $api.post('main/getUserLeaveRecord', {
memberSeq: memberSeq,
});
const res = data?.data;
if (res && !res?.COMMUTLVE) {
await projectStore.getMemberProjects();
if (projectStore.activeMemberProjectList.length > 0) {
const previousProject =
projectStore.activeMemberProjectList.find(p => res.MEMBERSEQ === user.value.id && res.PROJCTLVE === p.PROJCTSEQ) ||
projectStore.activeMemberProjectList[0]; //
if (previousProject) {
selectedProject.value = previousProject.PROJCTSEQ;
projectStore.setSelectedProject(previousProject);
} else if (projectStore.activeProjectList.length > 0) {
selectedProject.value = projectStore.activeProjectList[0].PROJCTSEQ;
projectStore.setSelectedProject(projectStore.activeProjectList[0]);
} else {
selectedProject.value = null;
projectStore.setSelectedProject(null);
}
} else {
selectedProject.value = null;
projectStore.setSelectedProject(null);
}
}
};
onMounted(async () => { onMounted(async () => {
await userStore.userInfo(); await userStore.userInfo();
user.value = userStore.user; user.value = userStore.user;

View File

@ -50,15 +50,14 @@
<label class="switch" <label class="switch"
><input ><input
type="checkbox" type="checkbox"
:checked="member.checked" :checked="checked"
@click="handleRegisterMember($event, member)" /> @change="handleRegisterMember(member.MEMBERSEQ)" /><span class="slider round"></span
<span class="slider round"></span
></label> ></label>
</div> </div>
<button <button
class="btn-close btn-close-sm" class="btn-close btn-close-sm"
style="position: absolute; top: 10px; right: 10px" style="position: absolute; top: 10px; right: 10px"
@click="handleRejectMember(member)" @click="handleRejectMember(member.MEMBERSEQ)"
></button> ></button>
</div> </div>
</div> </div>
@ -77,37 +76,30 @@
import $api from '@api'; import $api from '@api';
const memberList = ref([]); const memberList = ref([]);
const checked = ref(false);
const toast = useToastStore(); const toast = useToastStore();
const imgURL = import.meta.env.VITE_SERVER_IMG_URL; const imgURL = import.meta.env.VITE_SERVER_IMG_URL;
// api // api
const fetchRegisterMemberList = async () => { const fetchRegisterMemberList = async () => {
const { data } = await $api.get('main/registerMemberList'); const { data } = await $api.get('main/registerMemberList');
if (data?.data) { if (data?.data) memberList.value = data.data;
memberList.value = data.data.map(member => ({
...member,
checked: false, // checked
}));
}
}; };
// api // api
const handleRegisterMember = async (e, member) => { const handleRegisterMember = async memberSeq => {
e.preventDefault(); const { data } = await $api.post('main/registerMember', { memberSeq: memberSeq });
const { data } = await $api.post('main/registerMember', { memberSeq: member.MEMBERSEQ });
if (data?.data) { if (data?.data) {
member.checked = true;
toast.onToast(data.data, 's'); toast.onToast(data.data, 's');
fetchRegisterMemberList(); fetchRegisterMemberList();
} }
}; };
// api // api
const handleRejectMember = async member => { const handleRejectMember = async memberSeq => {
if (!confirm('해당 사원 등록을 거절하시겠습니까?')) return; if (!confirm('해당 사원 등록을 거절하시겠습니까?')) return;
const { data } = await $api.post('main/rejectMember', { memberSeq: member.MEMBERSEQ }); const { data } = await $api.post('main/rejectMember', { memberSeq: memberSeq });
if (data?.data) { if (data?.data) {
toast.onToast(data.data, 's'); toast.onToast(data.data, 's');
fetchRegisterMemberList(); fetchRegisterMemberList();

View File

@ -25,7 +25,6 @@
@update:alert="idAlert = $event" @update:alert="idAlert = $event"
@blur="checkIdDuplicate" @blur="checkIdDuplicate"
:value="id" :value="id"
@keypress="noSpace"
/> />
<span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span> <span v-if="idError" class="invalid-feedback d-block">{{ idError }}</span>
@ -38,7 +37,6 @@
@update:data="password = $event" @update:data="password = $event"
@update:alert="passwordAlert = $event" @update:alert="passwordAlert = $event"
:value="password" :value="password"
@keypress="noSpace"
/> />
<span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span> <span v-if="passwordError" class="invalid-feedback d-block">{{ passwordError }}</span>
@ -51,7 +49,6 @@
@update:data="passwordcheck = $event" @update:data="passwordcheck = $event"
@update:alert="passwordcheckAlert = $event" @update:alert="passwordcheckAlert = $event"
:value="passwordcheck" :value="passwordcheck"
@keypress="noSpace"
/> />
<span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span> <span v-if="passwordcheckError" class="invalid-feedback d-block">{{ passwordcheckError }}</span>
@ -85,7 +82,6 @@
@update:data="name = $event" @update:data="name = $event"
@update:alert="nameAlert = $event" @update:alert="nameAlert = $event"
:value="name" :value="name"
@keypress="noSpace"
class="me-2 w-50" class="me-2 w-50"
/> />
@ -218,10 +214,6 @@
const toastStore = useToastStore(); const toastStore = useToastStore();
const noSpace = (e) => {
if (e.key === ' ') e.preventDefault();
};
// //
const profileValid = (size, type) => { const profileValid = (size, type) => {
const maxSize = 5 * 1024 * 1024; const maxSize = 5 * 1024 * 1024;
@ -352,7 +344,6 @@
}); });
watch(password, (newValue) => { watch(password, (newValue) => {
if (newValue && newValue.length >= 4) { if (newValue && newValue.length >= 4) {
passwordErrorAlert.value = false; passwordErrorAlert.value = false;
passwordError.value = ''; passwordError.value = '';
@ -405,10 +396,8 @@
} else { } else {
passwordError.value = ''; passwordError.value = '';
} }
const phoneRegex = /^010\d{8}$/;
const isFormatValid = phoneRegex.test(phone.value);
if (!/^\d+$/.test(phone.value) || !isFormatValid) { if (!/^\d+$/.test(phone.value)) {
phoneAlert.value = true; phoneAlert.value = true;
} else { } else {
phoneAlert.value = false; phoneAlert.value = false;
@ -445,13 +434,13 @@
} }
const formData = new FormData(); const formData = new FormData();
formData.append('memberIds', id.value.trim()); formData.append('memberIds', id.value);
formData.append('memberPwd', password.value.trim()); formData.append('memberPwd', password.value);
formData.append('memberPwh', pwhint.value); formData.append('memberPwh', pwhint.value);
formData.append('memberPwr', pwhintRes.value.trim()); formData.append('memberPwr', pwhintRes.value);
formData.append('memberNam', name.value.trim()); formData.append('memberNam', name.value);
formData.append('memberArr', address.value); formData.append('memberArr', address.value);
formData.append('memberDtl', detailAddress.value.trim()); formData.append('memberDtl', detailAddress.value);
formData.append('memberZip', postcode.value); formData.append('memberZip', postcode.value);
formData.append('memberBth', birth.value); formData.append('memberBth', birth.value);
formData.append('memberTel', phone.value); formData.append('memberTel', phone.value);

View File

@ -80,12 +80,12 @@
<div class="text-truncate">Authorization</div> <div class="text-truncate">Authorization</div>
</RouterLink> </RouterLink>
</li> </li>
<li class="menu-item" :class="$route.path.includes('/people') ? 'active' : ''"> <!-- <li class="menu-item" :class="$route.path.includes('/sample') ? 'active' : ''">
<RouterLink class="menu-link" to="/people"> <i class="bi "></i> <RouterLink class="menu-link" to="/sample"> <i class="bi "></i>
<i class="menu-icon icon-base bi bi-people-fill"></i> <i class="menu-icon tf-icons bx bx-calendar"></i>
<div class="text-truncate">people</div> <div class="text-truncate">Sample</div>
</RouterLink> </RouterLink>
</li> </li> -->
</ul> </ul>
</aside> </aside>
<!-- / Menu --> <!-- / Menu -->
@ -94,7 +94,6 @@
<script setup> <script setup>
import { computed } from "vue"; import { computed } from "vue";
import { useUserInfoStore } from '@s/useUserInfoStore'; import { useUserInfoStore } from '@s/useUserInfoStore';
import "bootstrap-icons/font/bootstrap-icons.css";
const userStore = useUserInfoStore(); const userStore = useUserInfoStore();
const allowedUserId = 1; // ID (!!) const allowedUserId = 1; // ID (!!)

View File

@ -46,27 +46,16 @@
> >
<span class="position-relative"> <span class="position-relative">
<i class="bx bx-bell bx-md"></i> <i class="bx bx-bell bx-md"></i>
<!-- 알림이 있을 경우에만 뱃지를 표시 --> <span class="badge rounded-pill bg-danger badge-dot badge-notifications border"></span>
<span
v-if="notificationCount > 0"
class="badge rounded-pill bg-danger badge-dot badge-notifications border"
></span>
</span> </span>
</a> </a>
<ul class="dropdown-menu dropdown-menu-end p-0"> <ul class="dropdown-menu dropdown-menu-end p-0">
<li class="dropdown-notifications-list scrollable-container p-3"> <li class="dropdown-notifications-list scrollable-container p-3">
<!-- 알림이 없으면 "알림이 없습니다." 메시지 표시 -->
<div v-if="notificationCount === 0">
알림이 없습니다. 알림이 없습니다.
</div> <!-- <ul class="list-group list-group-flush">
<!-- 알림이 있을 목록 렌더링--> <li class="list-group-item list-group-item-action dropdown-notifications-item">
<div v-else>
<ul>
<li v-for="notification in notifications" :key="notification.id">
{{ notification.text }}
</li> </li>
</ul> </ul> -->
</div>
</li> </li>
</ul> </ul>
</li> </li>
@ -131,8 +120,6 @@
const selectedProject = ref(null); const selectedProject = ref(null);
const weather = ref({}); const weather = ref({});
const dailyWeatherList = ref([]); const dailyWeatherList = ref([]);
const notifications = ref([]);
const notificationCount = ref(0);
const weatherReady = computed(() => { const weatherReady = computed(() => {
return ( return (

View File

@ -109,12 +109,6 @@ const routes = [
component: () => import('@v/admin/TheAuthorization.vue'), component: () => import('@v/admin/TheAuthorization.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/people',
name: 'people',
component: () => import('@v/people/PeopleList.vue'),
meta: { requiresAuth: true },
},
{ {
path: '/error/400', path: '/error/400',
name: 'Error400', name: 'Error400',

View File

@ -40,13 +40,7 @@ export const useWeatherStore = defineStore('weather', () => {
return; return;
} }
// 검은색 태양 아이콘 변경 dailyWeatherList.value = resData.dailyWeatherList;
dailyWeatherList.value = resData.dailyWeatherList.map(w => {
return {
...w,
icon: w.icon.replace(/n$/, 'd'),
};
});
const now = new Date(); const now = new Date();
const todayStr = now.toISOString().split('T')[0]; const todayStr = now.toISOString().split('T')[0];

View File

@ -8,7 +8,7 @@
<!-- 프로필 사진 --> <!-- 프로필 사진 -->
<img <img
:src="getProfileImage(user.photo)" :src="getProfileImage(user.photo)"
class="user-avatar2" class="profile-img"
alt="프로필 사진" alt="프로필 사진"
@error="setDefaultImage" @error="setDefaultImage"
/> />
@ -107,14 +107,5 @@ onMounted(fetchUsers);
</script> </script>
<style scoped> <style scoped>
.user-avatar2 { /* 필요에 따라 스타일 추가 */
width: 160px;
height: 200px;
object-fit: cover;
border-radius: 50%;
display: block;
margin: 1rem auto 0 auto;
margin-top: 0px;
margin-bottom: 10px;
}
</style> </style>

View File

@ -47,7 +47,7 @@
<tr> <tr>
<th style="width: 11%" class="text-center fw-bold">번호</th> <th style="width: 11%" class="text-center fw-bold">번호</th>
<th style="width: 45%" class="text-center fw-bold">제목</th> <th style="width: 45%" class="text-center fw-bold">제목</th>
<th style="width: 10%" class="text-strat fw-bold">작성자</th> <th style="width: 10%" class="text-center fw-bold">작성자</th>
<th style="width: 15%" class="text-center fw-bold">작성일</th> <th style="width: 15%" class="text-center fw-bold">작성일</th>
<th style="width: 9%" class="text-center fw-bold">조회수</th> <th style="width: 9%" class="text-center fw-bold">조회수</th>
</tr> </tr>

View File

@ -278,7 +278,7 @@
try { try {
const boardData = { const boardData = {
LOCBRDTTL: title.value.trim(), LOCBRDTTL: title.value,
LOCBRDCON: JSON.stringify(content.value), // Delta JSON LOCBRDCON: JSON.stringify(content.value), // Delta JSON
LOCBRDNIC: categoryValue.value === 300102 ? nickname.value : null, LOCBRDNIC: categoryValue.value === 300102 ? nickname.value : null,
LOCBRDPWD: categoryValue.value === 300102 ? password.value : null, LOCBRDPWD: categoryValue.value === 300102 ? password.value : null,

View File

@ -65,7 +65,7 @@
</span> </span>
<!-- 기존 비밀번호 입력 --> <!-- 기존 비밀번호 입력 -->
<UserFormInput title="비밀번호 재설정" placeholder="기존 비밀번호를 입력하세요" name="currentPw" type="password" <UserFormInput title="기존 비밀번호" name="currentPw" type="password"
:value="password.current" @update:data="password.current = $event" :value="password.current" @update:data="password.current = $event"
@blur="checkCurrentPassword" @keypress="noSpace" /> @blur="checkCurrentPassword" @keypress="noSpace" />
<span v-if="passwordError" class="text-danger invalid-feedback mt-1 d-block"> <span v-if="passwordError" class="text-danger invalid-feedback mt-1 d-block">

View File

@ -1,318 +0,0 @@
<template>
<div class="container-xxl flex-grow-1 container-p-y">
<div class="card">
<!-- 사원 목록이 없을 경우 표시 -->
<div v-if="allUserList.length === 0" class="text-center my-4">
<p class="text-muted">등록된 사원이 없습니다.</p>
</div>
<!-- 사원 카드 리스트 영역 -->
<div class="card-body">
<div class="card-list">
<div
v-for="(person, index) in allUserList"
:key="index"
class="person-card"
@click="openModal(person)"
>
<div>
<img
class="rounded-circle user-avatar pointer"
:src="getProfileImage(person.MEMBERPRF)"
:style="{ borderColor: person.usercolor }"
@error="setDefaultImage"
/>
</div>
<div class="card-body">
<h3 class="person-name">{{ person.MEMBERNAM }}</h3>
<p class="person-email">{{ person.MEMBERIDS }}@local-host.co.kr</p>
<p class="person-phone">{{ person.MEMBERTEL }}</p>
<small>
{{ person.MEMBERARR }} {{ person.MEMBERDTL }}
</small>
</div>
</div>
</div>
</div>
<!-- 상세보기 Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-content">
<button class="close-btn" @click="closeModal">×</button>
<div class="modal-body">
<img
class="user-avatar2"
:src="getProfileImage(selectedPerson.MEMBERPRF)"
:style="{ borderColor: selectedPerson.usercolor }"
@error="setDefaultImage"
/>
<h4>{{ selectedPerson.MEMBERNAM }}</h4>
<p>{{ selectedPerson.MEMBERIDS }}@local-host.co.kr</p>
<p>{{ selectedPerson.MEMBERTEL }}</p>
<p>{{ selectedPerson.MEMBERARR }} {{ selectedPerson.MEMBERDTL }}</p>
<hr />
<!-- 추가 정보: 사용자가 속한 프로젝트 목록 -->
<h5>참여 프로젝트</h5>
<div v-if="memberProjects.length > 0" class="project-list-container">
<ul>
<li
v-for="(project, idx) in memberProjects"
:key="idx"
class="project-item"
>
<span class="project-name">{{ project.PROJCTNAM }}</span>
<span class="project-period">
<!-- projectEndDate가 있는 경우 -->
<!-- <template v-if="project.projectEndDate"> -->
{{ project.userStartDate ? project.userStartDate : project.projectStartDate }} ~
{{ project.userEndDate ? project.userEndDate : project.projectEndDate }}
<!-- </template> -->
<!-- 없으면 종료일 표시 안함 -->
<!-- <template v-else>
{{ project.userStartDate ? project.userStartDate : project.projectStartDate }} ~
</template> -->
</span>
</li>
</ul>
</div>
<div v-else>
<p>참여중인 프로젝트가 없습니다.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import axios from '@api' // API Axios
import { ref, onMounted } from 'vue'
import SearchBar from '@c/search/SearchBar.vue'
export default {
name: 'PeopleList',
components: { SearchBar },
setup() {
const allUserList = ref([]) //
const user = ref({}) // ( )
const showModal = ref(false) //
const selectedPerson = ref({})//
const memberProjects = ref([])//
onMounted(async () => {
try {
const response = await axios.get('user/allUserList')
allUserList.value = response.data.data.allUserList
user.value = response.data.data.user
} catch (error) {
console.error('사원 목록 조회 실패:', error)
}
})
const baseUrl = axios.defaults.baseURL.replace(/api\/$/, '')
const defaultProfile = '/img/icons/icon.png'
const getProfileImage = (profilePath) => {
return profilePath && profilePath.trim()
? `${baseUrl}upload/img/profile/${profilePath}`
: defaultProfile
}
const setDefaultImage = (event) => {
event.target.src = defaultProfile
}
// API
const fetchMemberProjects = async (memberSeq) => {
try {
const res = await axios.get(`project/people/${memberSeq}`)
memberProjects.value = res.data.data
} catch (error) {
console.error('프로젝트 조회 실패:', error)
memberProjects.value = []
}
}
const openModal = (person) => {
selectedPerson.value = person
fetchMemberProjects(person.MEMBERSEQ)
showModal.value = true
}
const closeModal = () => {
showModal.value = false
}
return {
allUserList,
user,
showModal,
selectedPerson,
memberProjects,
openModal,
closeModal,
getProfileImage,
defaultProfile,
setDefaultImage
}
}
}
</script>
<style scoped>
.container-xxl {
padding: 1rem;
}
.card-list {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.person-card {
width: 280px;
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: #fff;
transition: box-shadow 0.2s ease-in-out;
}
.person-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.person-card .card-header {
width: 100%;
height: 120px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
}
.user-avatar {
width: 160px;
height: 200px;
object-fit: cover;
border-radius: 50%;
border: 2px solid #ddd;
display: block;
margin: 1rem auto 0 auto;
}
.user-avatar2 {
width: 160px;
height: 200px;
object-fit: cover;
border-radius: 50%;
display: block;
margin: 1rem auto 0 auto;
margin-top: 0px;
margin-bottom: 10px;
}
.person-card .card-body {
padding: 0.75rem;
}
.person-name {
margin: 0;
font-size: 1.1rem;
font-weight: 600;
}
.person-email,
.person-phone {
margin: 0;
font-size: 0.9rem;
color: #555;
}
/* 모달 스타일 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 111%;
background-color: rgba(0, 0, 0, 0.4);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.modal-content {
position: relative;
width: 400px;
background: #fff;
padding: 1.5rem;
border-radius: 8px;
animation: slideDown 0.3s ease forwards;
}
.close-btn {
background: transparent;
border: none;
font-size: 1.5rem;
position: absolute;
top: 0.5rem;
right: 0.5rem;
cursor: pointer;
}
.modal-body {
text-align: center;
}
.modal-img {
width: 50%;
height: 50%;
border-radius: 50%;
margin-bottom: 1rem;
object-fit: cover;
}
/* 프로젝트 리스트 스타일 */
.project-list-container {
max-height: 200px; /* 필요에 따라 높이 조절 */
overflow-y: auto;
margin-top: 1rem;
}
.project-item {
display: flex;
align-items: center;
list-style: none;
font-size: 0.9rem;
padding: 0.25rem 0;
}
.project-name {
font-weight: 600;
}
.project-period {
font-size: 1rem;
color: #888;
margin-left: 10px;
}
@keyframes slideDown {
0% {
transform: translateY(-15%);
opacity: 0;
}
100% {
transform: translateY(0);
opacity: 1;
}
}
</style>