대시보드와 주간보고 기능 업데이트
This commit is contained in:
@@ -64,9 +64,11 @@
|
||||
<div
|
||||
class="upload-zone p-5 text-center border rounded"
|
||||
:class="{ 'border-primary bg-light': isDragging }"
|
||||
tabindex="0"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleDrop"
|
||||
@paste="handlePaste"
|
||||
@click="($refs.fileInput as HTMLInputElement).click()"
|
||||
>
|
||||
<input
|
||||
@@ -80,7 +82,7 @@
|
||||
<i class="bi bi-cloud-arrow-up display-4 text-muted"></i>
|
||||
<p class="mt-2 mb-0 text-muted">
|
||||
이미지를 드래그하거나 클릭해서 업로드<br>
|
||||
<small>(최대 10장, PNG/JPG)</small>
|
||||
<small>또는 <strong>Ctrl+V</strong>로 붙여넣기 (최대 10장)</small>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -358,6 +360,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const { getWeekInfo, getWeekDates, getLastWeekInfo, getActualCurrentWeekInfo, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
|
||||
const step = ref(1)
|
||||
@@ -400,12 +403,6 @@ onMounted(async () => {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
if (user.employeeEmail !== 'coziny@gmail.com') {
|
||||
alert('관리자만 접근할 수 있습니다.')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
function getHeaderClass(report: any) {
|
||||
@@ -449,14 +446,36 @@ function handleDrop(e: DragEvent) {
|
||||
if (files) processFiles(files)
|
||||
}
|
||||
|
||||
function handlePaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const imageFiles: File[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile()
|
||||
if (file) imageFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault()
|
||||
processImageFiles(imageFiles)
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) processFiles(input.files)
|
||||
}
|
||||
|
||||
function processFiles(files: FileList) {
|
||||
processImageFiles(Array.from(files))
|
||||
}
|
||||
|
||||
function processImageFiles(files: File[]) {
|
||||
const maxFiles = 10 - uploadedImages.value.length
|
||||
const toProcess = Array.from(files).slice(0, maxFiles)
|
||||
const toProcess = files.slice(0, maxFiles)
|
||||
|
||||
toProcess.forEach(file => {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
@@ -491,67 +510,34 @@ function removeParsedTask(taskArray: any[], idx: number) {
|
||||
}
|
||||
}
|
||||
|
||||
// 주차 계산 함수들
|
||||
function getMonday(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay()
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
|
||||
d.setDate(diff)
|
||||
return d
|
||||
}
|
||||
|
||||
function getSunday(monday: Date): Date {
|
||||
const d = new Date(monday)
|
||||
d.setDate(d.getDate() + 6)
|
||||
return d
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function getWeekNumber(date: Date): { year: number; week: number } {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7)
|
||||
const week1 = new Date(d.getFullYear(), 0, 4)
|
||||
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
|
||||
return { year: d.getFullYear(), week: weekNum }
|
||||
}
|
||||
|
||||
function setWeekDates(monday: Date) {
|
||||
const sunday = getSunday(monday)
|
||||
const weekInfo = getWeekNumber(monday)
|
||||
|
||||
parsedData.value.weekStartDate = formatDate(monday)
|
||||
parsedData.value.weekEndDate = formatDate(sunday)
|
||||
parsedData.value.reportYear = weekInfo.year
|
||||
parsedData.value.reportWeek = weekInfo.week
|
||||
// 주차 관련 함수들 (useWeekCalc 사용)
|
||||
function setWeekFromInfo(info: { year: number; week: number; startDateStr: string; endDateStr: string }) {
|
||||
parsedData.value.reportYear = info.year
|
||||
parsedData.value.reportWeek = info.week
|
||||
parsedData.value.weekStartDate = info.startDateStr
|
||||
parsedData.value.weekEndDate = info.endDateStr
|
||||
}
|
||||
|
||||
function changeWeek(delta: number) {
|
||||
const currentMonday = new Date(parsedData.value.weekStartDate)
|
||||
currentMonday.setDate(currentMonday.getDate() + (delta * 7))
|
||||
setWeekDates(currentMonday)
|
||||
const { year, week } = calcChangeWeek(parsedData.value.reportYear, parsedData.value.reportWeek, delta)
|
||||
const weekInfo = getWeekDates(year, week)
|
||||
setWeekFromInfo(weekInfo)
|
||||
}
|
||||
|
||||
function setLastWeek() {
|
||||
const today = new Date()
|
||||
const lastWeekMonday = getMonday(today)
|
||||
lastWeekMonday.setDate(lastWeekMonday.getDate() - 7)
|
||||
setWeekDates(lastWeekMonday)
|
||||
const lastWeek = getLastWeekInfo()
|
||||
setWeekFromInfo(lastWeek)
|
||||
}
|
||||
|
||||
function setThisWeek() {
|
||||
const today = new Date()
|
||||
const thisWeekMonday = getMonday(today)
|
||||
setWeekDates(thisWeekMonday)
|
||||
const thisWeek = getActualCurrentWeekInfo()
|
||||
setWeekFromInfo(thisWeek)
|
||||
}
|
||||
|
||||
function updateWeekFromDate() {
|
||||
const startDate = new Date(parsedData.value.weekStartDate)
|
||||
const monday = getMonday(startDate)
|
||||
setWeekDates(monday)
|
||||
const weekInfo = getWeekInfo(startDate)
|
||||
setWeekFromInfo(weekInfo)
|
||||
}
|
||||
|
||||
// 분석 결과 처리
|
||||
@@ -568,6 +554,11 @@ function handleParseResult(res: any) {
|
||||
}))
|
||||
p.planTasks = p.planTasks || []
|
||||
})
|
||||
// 내용 없는 프로젝트 제외
|
||||
r.projects = r.projects.filter((p: any) =>
|
||||
(p.workTasks && p.workTasks.some((t: any) => t.description?.trim())) ||
|
||||
(p.planTasks && p.planTasks.some((t: any) => t.description?.trim()))
|
||||
)
|
||||
})
|
||||
employees.value = res.employees
|
||||
projects.value = res.projects
|
||||
@@ -616,12 +607,14 @@ async function bulkRegister() {
|
||||
employeeId: r.createNewEmployee ? null : r.matchedEmployeeId,
|
||||
employeeName: r.employeeName,
|
||||
employeeEmail: r.employeeEmail,
|
||||
projects: r.projects.map((p: any) => ({
|
||||
projectId: p.matchedProjectId,
|
||||
projectName: p.projectName,
|
||||
workTasks: (p.workTasks || []).filter((t: any) => t.description?.trim()),
|
||||
planTasks: (p.planTasks || []).filter((t: any) => t.description?.trim())
|
||||
})),
|
||||
projects: r.projects
|
||||
.map((p: any) => ({
|
||||
projectId: p.matchedProjectId,
|
||||
projectName: p.projectName,
|
||||
workTasks: (p.workTasks || []).filter((t: any) => t.description?.trim()),
|
||||
planTasks: (p.planTasks || []).filter((t: any) => t.description?.trim())
|
||||
}))
|
||||
.filter((p: any) => p.workTasks.length > 0 || p.planTasks.length > 0), // 내용 없는 프로젝트 제외
|
||||
issueDescription: r.issueDescription,
|
||||
vacationDescription: r.vacationDescription,
|
||||
remarkDescription: r.remarkDescription
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
/**
|
||||
* ISO 8601 주차 계산 composable
|
||||
* - 1월 4일이 포함된 주 = 1주차
|
||||
* - 주의 시작 = 월요일
|
||||
* - 예: 2026년 2주차 = 2026-01-05(월) ~ 2026-01-11(일)
|
||||
*/
|
||||
|
||||
interface WeekInfo {
|
||||
export interface WeekInfo {
|
||||
year: number
|
||||
week: number
|
||||
startDate: Date
|
||||
@@ -13,28 +16,114 @@ interface WeekInfo {
|
||||
}
|
||||
|
||||
export function useWeekCalc() {
|
||||
|
||||
/**
|
||||
* 날짜를 YYYY-MM-DD 형식으로 포맷
|
||||
*/
|
||||
function formatDate(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국어 날짜 포맷 (M월 D일)
|
||||
*/
|
||||
function formatDateKr(date: Date): string {
|
||||
return `${date.getMonth() + 1}월 ${date.getDate()}일`
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 날짜의 월요일 반환
|
||||
*/
|
||||
function getMonday(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
const day = d.getDay()
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
|
||||
d.setDate(diff)
|
||||
return d
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 날짜의 일요일 반환
|
||||
*/
|
||||
function getSunday(date: Date): Date {
|
||||
const monday = getMonday(date)
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
return sunday
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 연도의 1주차 월요일 반환 (ISO 8601)
|
||||
* - 1월 4일이 포함된 주의 월요일
|
||||
*/
|
||||
function getWeek1Monday(year: number): Date {
|
||||
const jan4 = new Date(year, 0, 4)
|
||||
return getMonday(jan4)
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 연도의 총 주차 수 반환
|
||||
*/
|
||||
function getWeeksInYear(year: number): number {
|
||||
const dec31 = new Date(year, 11, 31)
|
||||
const weekInfo = getWeekNumber(dec31)
|
||||
// 12월 31일이 다음 해 1주차면 52주, 아니면 해당 주차
|
||||
return weekInfo.year === year ? weekInfo.week : 52
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜의 ISO 주차 번호 반환
|
||||
*/
|
||||
function getWeekNumber(date: Date): { year: number; week: number } {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
|
||||
// 목요일 기준으로 연도 판단 (ISO 8601)
|
||||
const thursday = new Date(d)
|
||||
thursday.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7))
|
||||
|
||||
const year = thursday.getFullYear()
|
||||
const week1Monday = getWeek1Monday(year)
|
||||
|
||||
const diffTime = getMonday(d).getTime() - week1Monday.getTime()
|
||||
const diffDays = Math.round(diffTime / (24 * 60 * 60 * 1000))
|
||||
const week = Math.floor(diffDays / 7) + 1
|
||||
|
||||
return { year, week }
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜의 ISO 주차 정보 반환
|
||||
*/
|
||||
function getWeekInfo(date: Date = new Date()): WeekInfo {
|
||||
const target = new Date(date)
|
||||
target.setHours(0, 0, 0, 0)
|
||||
const monday = getMonday(date)
|
||||
const sunday = getSunday(date)
|
||||
const { year, week } = getWeekNumber(date)
|
||||
|
||||
// 목요일 기준으로 연도 판단 (ISO 규칙)
|
||||
const thursday = new Date(target)
|
||||
thursday.setDate(target.getDate() - ((target.getDay() + 6) % 7) + 3)
|
||||
return {
|
||||
year,
|
||||
week,
|
||||
startDate: monday,
|
||||
endDate: sunday,
|
||||
startDateStr: formatDate(monday),
|
||||
endDateStr: formatDate(sunday),
|
||||
weekString: `${year}-W${week.toString().padStart(2, '0')}`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연도/주차로 날짜 범위 반환
|
||||
*/
|
||||
function getWeekDates(year: number, week: number): WeekInfo {
|
||||
const week1Monday = getWeek1Monday(year)
|
||||
|
||||
const year = thursday.getFullYear()
|
||||
const firstThursday = new Date(year, 0, 4)
|
||||
firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3)
|
||||
const monday = new Date(week1Monday)
|
||||
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
|
||||
|
||||
const week = Math.ceil((thursday.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1
|
||||
|
||||
// 해당 주의 월요일
|
||||
const monday = new Date(target)
|
||||
monday.setDate(target.getDate() - ((target.getDay() + 6) % 7))
|
||||
|
||||
// 해당 주의 일요일
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
|
||||
@@ -49,6 +138,24 @@ export function useWeekCalc() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주차 이동 (delta: +1 다음주, -1 이전주)
|
||||
*/
|
||||
function changeWeek(year: number, week: number, delta: number): { year: number; week: number } {
|
||||
let newYear = year
|
||||
let newWeek = week + delta
|
||||
|
||||
if (newWeek < 1) {
|
||||
newYear--
|
||||
newWeek = getWeeksInYear(newYear)
|
||||
} else if (newWeek > getWeeksInYear(newYear)) {
|
||||
newYear++
|
||||
newWeek = 1
|
||||
}
|
||||
|
||||
return { year: newYear, week: newWeek }
|
||||
}
|
||||
|
||||
/**
|
||||
* 이번 주 정보 (보고서 기준)
|
||||
* - 금~일: 현재 주차
|
||||
@@ -85,13 +192,6 @@ export function useWeekCalc() {
|
||||
return getWeekInfo(lastWeek)
|
||||
}
|
||||
|
||||
/**
|
||||
* 날짜 포맷 (YYYY-MM-DD)
|
||||
*/
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* 주차 문자열 파싱
|
||||
*/
|
||||
@@ -102,39 +202,43 @@ export function useWeekCalc() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 주차별 날짜 범위 텍스트
|
||||
* 주차별 날짜 범위 텍스트 (예: "1월 5일 ~ 1월 11일")
|
||||
*/
|
||||
function getWeekRangeText(year: number, week: number): string {
|
||||
// 해당 연도 첫 번째 목요일 찾기
|
||||
const jan4 = new Date(year, 0, 4)
|
||||
const firstThursday = new Date(jan4)
|
||||
firstThursday.setDate(jan4.getDate() - ((jan4.getDay() + 6) % 7) + 3)
|
||||
|
||||
// 해당 주차의 월요일
|
||||
const monday = new Date(firstThursday)
|
||||
monday.setDate(firstThursday.getDate() - 3 + (week - 1) * 7)
|
||||
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
|
||||
return `${formatDateKr(monday)} ~ ${formatDateKr(sunday)}`
|
||||
const { startDate, endDate } = getWeekDates(year, week)
|
||||
return `${formatDateKr(startDate)} ~ ${formatDateKr(endDate)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 한국어 날짜 포맷 (M월 D일)
|
||||
* 주차별 날짜 범위 텍스트 - ISO 형식 (예: "2026-01-05 ~ 2026-01-11")
|
||||
*/
|
||||
function formatDateKr(date: Date): string {
|
||||
return `${date.getMonth() + 1}월 ${date.getDate()}일`
|
||||
function getWeekRangeTextISO(year: number, week: number): string {
|
||||
const { startDateStr, endDateStr } = getWeekDates(year, week)
|
||||
return `${startDateStr} ~ ${endDateStr}`
|
||||
}
|
||||
|
||||
return {
|
||||
// 기본 유틸
|
||||
formatDate,
|
||||
formatDateKr,
|
||||
getMonday,
|
||||
getSunday,
|
||||
|
||||
// 주차 계산
|
||||
getWeekNumber,
|
||||
getWeekInfo,
|
||||
getWeekDates,
|
||||
getWeeksInYear,
|
||||
changeWeek,
|
||||
|
||||
// 현재/지난주
|
||||
getCurrentWeekInfo,
|
||||
getActualCurrentWeekInfo,
|
||||
getLastWeekInfo,
|
||||
formatDate,
|
||||
|
||||
// 파싱/포맷
|
||||
parseWeekString,
|
||||
getWeekRangeText,
|
||||
formatDateKr
|
||||
getWeekRangeTextISO
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,22 +7,24 @@
|
||||
<div class="row mb-4">
|
||||
<div class="col">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h4 class="mb-1">
|
||||
<i class="bi bi-speedometer2 me-2"></i>리소스 현황
|
||||
</h4>
|
||||
<p class="text-muted mb-0">
|
||||
{{ currentWeek.year }}년 {{ currentWeek.week }}주차
|
||||
({{ currentWeek.startDateStr }} ~ {{ currentWeek.endDateStr }})
|
||||
</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<select class="form-select form-select-sm" style="width: 100px;" v-model="selectedYear" @change="loadStats">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-speedometer2 me-2"></i>대시보드
|
||||
</h4>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="changeWeek(-1)" title="이전 주차">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<select class="form-select form-select-sm" style="width: 100px;" v-model="selectedYear" @change="onYearChange">
|
||||
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}년</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm" style="width: 90px;" v-model="selectedWeek" @change="loadStats">
|
||||
<option v-for="w in weekOptions" :key="w" :value="w">{{ w }}주</option>
|
||||
<select class="form-select form-select-sm" style="width: auto;" v-model="selectedWeek" @change="loadStats">
|
||||
<option v-for="opt in weekOptionsWithDates" :key="opt.week" :value="opt.week">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="changeWeek(1)" title="다음 주차" :disabled="isCurrentWeek">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -74,7 +76,7 @@
|
||||
<div class="col-md-3">
|
||||
<div class="card h-100">
|
||||
<div class="card-body text-center">
|
||||
<div class="text-muted small">리소스 현황</div>
|
||||
<div class="text-muted small">업무 부하</div>
|
||||
<div class="d-flex justify-content-around mt-1">
|
||||
<div>
|
||||
<span class="badge bg-success">{{ resourceStatus.available }}</span>
|
||||
@@ -106,7 +108,7 @@
|
||||
<span class="badge bg-danger">48h~</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
@@ -118,7 +120,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="emp in stats.employees" :key="emp.employeeId"
|
||||
:class="{ 'table-light text-muted': !emp.isSubmitted }">
|
||||
:class="{ 'table-light text-muted': !emp.reportId }">
|
||||
<td>
|
||||
<div class="d-flex align-items-center">
|
||||
<span :class="getWorkloadBadge(emp.workHours)" class="me-2" style="width: 8px; height: 8px; border-radius: 50%; display: inline-block;"></span>
|
||||
@@ -129,19 +131,20 @@
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span v-if="emp.isSubmitted" :class="getWorkloadClass(emp.workHours)">
|
||||
<span v-if="emp.reportId" :class="getWorkloadClass(emp.workHours)">
|
||||
{{ emp.workHours }}h
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span v-if="emp.isSubmitted" :class="getWorkloadClass(emp.planHours)">
|
||||
<span v-if="emp.reportId" :class="getWorkloadClass(emp.planHours)">
|
||||
{{ emp.planHours }}h
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span v-if="emp.isSubmitted" class="badge bg-success">제출</span>
|
||||
<span v-if="emp.reportStatus === 'SUBMITTED' || emp.reportStatus === 'AGGREGATED'" class="badge bg-success">제출</span>
|
||||
<span v-else-if="emp.reportStatus === 'DRAFT'" class="badge bg-warning">작성중</span>
|
||||
<span v-else class="badge bg-secondary">미제출</span>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -160,7 +163,7 @@
|
||||
<div class="card-header">
|
||||
<i class="bi bi-briefcase me-2"></i>프로젝트별 투입 현황
|
||||
</div>
|
||||
<div class="card-body p-0" style="max-height: 500px; overflow-y: auto;">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light sticky-top">
|
||||
<tr>
|
||||
@@ -195,61 +198,79 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 하단: 빠른 링크 -->
|
||||
<div class="row g-3 mt-3">
|
||||
<div class="col-md-3">
|
||||
<NuxtLink to="/report/weekly/write" class="card text-decoration-none h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<i class="bi bi-plus-circle display-6 text-primary"></i>
|
||||
<div class="mt-2">주간보고 작성</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<NuxtLink to="/report/weekly" class="card text-decoration-none h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<i class="bi bi-journal-text display-6 text-success"></i>
|
||||
<div class="mt-2">주간보고 목록</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="col-md-3" v-if="isAdmin">
|
||||
<NuxtLink to="/report/summary" class="card text-decoration-none h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<i class="bi bi-collection display-6 text-info"></i>
|
||||
<div class="mt-2">취합 보고서</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<NuxtLink to="/project" class="card text-decoration-none h-100">
|
||||
<div class="card-body text-center py-3">
|
||||
<i class="bi bi-briefcase display-6 text-warning"></i>
|
||||
<div class="mt-2">프로젝트 관리</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { currentUser, fetchCurrentUser } = useAuth()
|
||||
const { getCurrentWeekInfo } = useWeekCalc()
|
||||
const { getCurrentWeekInfo, getActualCurrentWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
|
||||
const currentWeek = getCurrentWeekInfo()
|
||||
const actualCurrentWeek = getActualCurrentWeekInfo() // 실제 현재 주차
|
||||
const isAdmin = ref(false)
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = [currentYear, currentYear - 1]
|
||||
const weekOptions = Array.from({ length: 53 }, (_, i) => i + 1)
|
||||
|
||||
const selectedYear = ref(currentWeek.year)
|
||||
const selectedWeek = ref(currentWeek.week)
|
||||
|
||||
// 현재 주차인지 확인 (다음 버튼 비활성화용)
|
||||
const isCurrentWeek = computed(() =>
|
||||
selectedYear.value === actualCurrentWeek.year && selectedWeek.value === actualCurrentWeek.week
|
||||
)
|
||||
|
||||
// 미래 주차인지 확인
|
||||
const isFutureWeek = computed(() => {
|
||||
if (selectedYear.value > actualCurrentWeek.year) return true
|
||||
if (selectedYear.value === actualCurrentWeek.year && selectedWeek.value > actualCurrentWeek.week) return true
|
||||
return false
|
||||
})
|
||||
|
||||
// 주차 옵션 (날짜 포함, 현재 주차까지만)
|
||||
const weekOptionsWithDates = computed(() => {
|
||||
const weeksInYear = getWeeksInYear(selectedYear.value)
|
||||
// 현재 연도면 현재 주차까지만, 과거 연도면 전체
|
||||
const maxWeek = selectedYear.value === actualCurrentWeek.year
|
||||
? actualCurrentWeek.week
|
||||
: weeksInYear
|
||||
|
||||
return Array.from({ length: maxWeek }, (_, i) => {
|
||||
const week = i + 1
|
||||
const weekInfo = getWeekDates(selectedYear.value, week)
|
||||
const startMD = weekInfo.startDateStr.slice(5).replace('-', '/') // MM/DD
|
||||
const endMD = weekInfo.endDateStr.slice(5).replace('-', '/') // MM/DD
|
||||
return {
|
||||
week,
|
||||
label: `${week}주차 (${startMD}~${endMD})`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 주차 변경
|
||||
function changeWeek(delta: number) {
|
||||
const result = calcChangeWeek(selectedYear.value, selectedWeek.value, delta)
|
||||
|
||||
// 미래 주차로 이동 방지
|
||||
if (result.year > actualCurrentWeek.year) return
|
||||
if (result.year === actualCurrentWeek.year && result.week > actualCurrentWeek.week) return
|
||||
|
||||
selectedYear.value = result.year
|
||||
selectedWeek.value = result.week
|
||||
loadStats()
|
||||
}
|
||||
|
||||
// 연도 변경 시 주차 범위 조정
|
||||
function onYearChange() {
|
||||
const maxWeek = getWeeksInYear(selectedYear.value)
|
||||
if (selectedWeek.value > maxWeek) {
|
||||
selectedWeek.value = maxWeek
|
||||
}
|
||||
loadStats()
|
||||
}
|
||||
|
||||
const stats = ref<any>({
|
||||
summary: {
|
||||
activeEmployees: 0,
|
||||
|
||||
@@ -152,8 +152,9 @@
|
||||
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
|
||||
<td><code>{{ h.logoutIp || '-' }}</code></td>
|
||||
<td>
|
||||
<span v-if="h.logoutAt" class="badge bg-secondary">로그아웃</span>
|
||||
<span v-if="h.sessionStatus === 'logout'" class="badge bg-secondary">로그아웃</span>
|
||||
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속중</span>
|
||||
<span v-else-if="h.sessionStatus === 'active'" class="badge bg-info">활성</span>
|
||||
<span v-else class="badge bg-warning text-dark">세션만료</span>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -190,7 +190,7 @@
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const { getCurrentWeekInfo } = useWeekCalc()
|
||||
const { getCurrentWeekInfo, getWeekDates } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
|
||||
const currentYear = new Date().getFullYear()
|
||||
@@ -296,19 +296,8 @@ watch(showAggregateModal, (val) => {
|
||||
})
|
||||
|
||||
function getWeekDateRange(year: number, week: number): string {
|
||||
const jan4 = new Date(year, 0, 4)
|
||||
const jan4Day = jan4.getDay() || 7
|
||||
const week1Monday = new Date(jan4)
|
||||
week1Monday.setDate(jan4.getDate() - jan4Day + 1)
|
||||
|
||||
const monday = new Date(week1Monday)
|
||||
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
|
||||
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
|
||||
const fmt = (d: Date) => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
|
||||
return `${fmt(monday)}~${fmt(sunday)}`
|
||||
const weekInfo = getWeekDates(year, week)
|
||||
return `${weekInfo.startDateStr}~${weekInfo.endDateStr}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
|
||||
@@ -140,24 +140,143 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PMO AI 리뷰 -->
|
||||
<div class="card mb-4 border-info">
|
||||
<!-- PMO AI 리뷰 - 작성 품질 점수 -->
|
||||
<div id="ai-review-section" class="card mb-4 border-info">
|
||||
<div class="card-header bg-info bg-opacity-10 d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-robot me-2"></i>PMO AI 리뷰</strong>
|
||||
<strong><i class="bi bi-bar-chart me-2"></i>주간보고 작성 품질 결과</strong>
|
||||
<button class="btn btn-sm btn-outline-info" @click="requestAiReview" :disabled="isReviewing">
|
||||
<span v-if="isReviewing" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-arrow-repeat me-1"></i>
|
||||
{{ report.aiReview ? '리뷰 재요청' : '리뷰 요청' }}
|
||||
{{ qualityScore ? '재평가' : '품질 평가' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="report.aiReview" class="ai-review-content" v-html="renderMarkdown(report.aiReview)"></div>
|
||||
<div v-else class="text-muted text-center py-3">
|
||||
<i class="bi bi-chat-left-dots me-2"></i>
|
||||
아직 AI 리뷰가 없습니다. 리뷰 요청 버튼을 클릭하세요.
|
||||
<!-- 품질 점수가 있는 경우 -->
|
||||
<div v-if="qualityScore">
|
||||
<!-- 1. 총평 (맨 위) -->
|
||||
<div class="alert mb-4" :class="getOverallAlertClass(qualityScore.overall)">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-chat-quote fs-4 me-3"></i>
|
||||
<div>
|
||||
<strong class="d-block mb-1">총평</strong>
|
||||
{{ qualityScore.summary || '-' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 점수 그리드 (2열) -->
|
||||
<div class="row g-3 mb-4">
|
||||
<!-- 구체성 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<strong>구체성</strong>
|
||||
<span class="fs-5 fw-bold" :class="getScoreTextClass(qualityScore.specificity?.score)">
|
||||
{{ getScoreGrade(qualityScore.specificity?.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{ qualityScore.specificity?.improvement || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 완결성 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<strong>완결성</strong>
|
||||
<span class="fs-5 fw-bold" :class="getScoreTextClass(qualityScore.completeness?.score)">
|
||||
{{ getScoreGrade(qualityScore.completeness?.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{ qualityScore.completeness?.improvement || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 시간산정 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<strong>시간산정</strong>
|
||||
<span class="fs-5 fw-bold" :class="getScoreTextClass(qualityScore.timeEstimation?.score)">
|
||||
{{ getScoreGrade(qualityScore.timeEstimation?.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{ qualityScore.timeEstimation?.improvement || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 계획성 -->
|
||||
<div class="col-md-6">
|
||||
<div class="card h-100">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<strong>계획성</strong>
|
||||
<span class="fs-5 fw-bold" :class="getScoreTextClass(qualityScore.planning?.score)">
|
||||
{{ getScoreGrade(qualityScore.planning?.score || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="small text-muted">{{ qualityScore.planning?.improvement || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 종합 점수 -->
|
||||
<div class="text-center mb-4">
|
||||
<span class="badge fs-4 px-4 py-2" :class="getOverallBadgeClass(qualityScore.overall)">
|
||||
종합: {{ getScoreGrade(qualityScore.overall || 0) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 4. 작성 가이드 (모범 답안) -->
|
||||
<div v-if="qualityScore.bestPractice" class="card bg-light">
|
||||
<div class="card-header bg-secondary bg-opacity-10">
|
||||
<strong><i class="bi bi-lightbulb me-2"></i>작성 가이드 (모범 답안)</strong>
|
||||
<div class="small text-muted">아래 예시를 참고하여 주간보고를 보완해보세요</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 금주 실적 모범 답안 -->
|
||||
<div v-if="qualityScore.bestPractice.workTasks?.length" class="mb-3">
|
||||
<h6 class="text-primary mb-2">
|
||||
<i class="bi bi-check2-square me-1"></i>금주 실적
|
||||
</h6>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li v-for="(task, idx) in qualityScore.bestPractice.workTasks" :key="'bp-work-'+idx"
|
||||
class="list-group-item bg-transparent px-0 py-2">
|
||||
<i class="bi bi-dot text-primary"></i>
|
||||
<span class="small">{{ task }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- 차주 계획 모범 답안 -->
|
||||
<div v-if="qualityScore.bestPractice.planTasks?.length">
|
||||
<h6 class="text-success mb-2">
|
||||
<i class="bi bi-calendar-check me-1"></i>차주 계획
|
||||
</h6>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li v-for="(task, idx) in qualityScore.bestPractice.planTasks" :key="'bp-plan-'+idx"
|
||||
class="list-group-item bg-transparent px-0 py-2">
|
||||
<i class="bi bi-dot text-success"></i>
|
||||
<span class="small">{{ task }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small text-end mt-3">
|
||||
<i class="bi bi-clock me-1"></i>평가일시: {{ formatDateTime(report.aiReviewAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="report.aiReviewAt" class="text-muted small mt-3 text-end">
|
||||
<i class="bi bi-clock me-1"></i>리뷰 생성: {{ formatDateTime(report.aiReviewAt) }}
|
||||
|
||||
<!-- 품질 점수가 없는 경우 -->
|
||||
<div v-else class="text-muted text-center py-4">
|
||||
<i class="bi bi-bar-chart display-4 mb-3 d-block opacity-50"></i>
|
||||
<p class="mb-0">아직 품질 평가가 없습니다.<br>품질 평가 버튼을 클릭하여 AI 평가를 받아보세요.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,9 +315,14 @@
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
|
||||
<i class="bi bi-plus"></i> 프로젝트 추가
|
||||
</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-success" @click="showAiModal = true">
|
||||
<i class="bi bi-robot me-1"></i>AI 자동채우기
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
|
||||
<i class="bi bi-plus"></i> 프로젝트 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-for="(group, gIdx) in editProjectGroups" :key="group.projectId" class="border rounded mb-4">
|
||||
@@ -232,7 +356,8 @@
|
||||
{{ task.isCompleted ? '완료' : '진행' }}
|
||||
</label>
|
||||
</div>
|
||||
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="작업 내용"></textarea>
|
||||
<textarea class="form-control form-control-sm auto-resize" v-model="task.description" rows="1"
|
||||
placeholder="작업 내용" @input="autoResize"></textarea>
|
||||
<div class="text-nowrap">
|
||||
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
|
||||
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
|
||||
@@ -255,7 +380,8 @@
|
||||
</div>
|
||||
<div v-for="(task, tIdx) in getEditPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2">
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="계획 내용"></textarea>
|
||||
<textarea class="form-control form-control-sm auto-resize" v-model="task.description" rows="1"
|
||||
placeholder="계획 내용" @input="autoResize"></textarea>
|
||||
<div class="text-nowrap">
|
||||
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
|
||||
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
|
||||
@@ -330,11 +456,242 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showProjectModal" @click="showProjectModal = false"></div>
|
||||
|
||||
<!-- 품질 평가 확인 모달 -->
|
||||
<div class="modal fade show d-block" tabindex="-1" v-if="showAiReviewConfirmModal">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-warning bg-opacity-10">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-exclamation-triangle text-warning me-2"></i>품질 평가 필요
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @click="handleAiReviewCancel"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">
|
||||
제출 전에 <strong>작성 품질 평가</strong>를 선행하고 제출하셔야 합니다.<br><br>
|
||||
지금 품질 평가를 진행하시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="handleAiReviewCancel">아니오</button>
|
||||
<button type="button" class="btn btn-primary" @click="handleAiReviewConfirm">
|
||||
<i class="bi bi-bar-chart me-1"></i>예, 품질 평가 진행
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showAiReviewConfirmModal" @click="handleAiReviewCancel"></div>
|
||||
|
||||
<!-- AI 자동채우기 모달 -->
|
||||
<div class="modal fade" :class="{ show: showAiModal }" :style="{ display: showAiModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-robot me-2"></i>AI 자동채우기
|
||||
<span v-if="aiStep === 'matching'" class="badge bg-primary ms-2">프로젝트 매칭</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @click="closeAiModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Step 1: 입력 -->
|
||||
<template v-if="aiStep === 'input'">
|
||||
<!-- 입력 방식 탭 -->
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: aiInputMode === 'text' }" href="#" @click.prevent="aiInputMode = 'text'">
|
||||
<i class="bi bi-fonts me-1"></i>텍스트
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: aiInputMode === 'image' }" href="#" @click.prevent="aiInputMode = 'image'">
|
||||
<i class="bi bi-image me-1"></i>이미지
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 텍스트 입력 -->
|
||||
<div v-if="aiInputMode === 'text'">
|
||||
<textarea
|
||||
class="form-control font-monospace"
|
||||
v-model="aiRawText"
|
||||
rows="12"
|
||||
placeholder="주간보고 내용을 붙여넣으세요.
|
||||
|
||||
예시:
|
||||
- PIMS 고도화: API 개발 완료 (8시간), UI 수정 (4시간)
|
||||
- 차주: 테스트 진행 예정 (16시간)
|
||||
- 이슈: 서버 메모리 부족
|
||||
- 휴가: 1/10(금) 연차"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 이미지 입력 -->
|
||||
<div v-if="aiInputMode === 'image'">
|
||||
<div
|
||||
class="upload-zone p-5 text-center border rounded"
|
||||
:class="{ 'border-primary bg-light': aiIsDragging }"
|
||||
tabindex="0"
|
||||
@dragover.prevent="aiIsDragging = true"
|
||||
@dragleave.prevent="aiIsDragging = false"
|
||||
@drop.prevent="handleAiDrop"
|
||||
@paste="handleAiPaste"
|
||||
@click="($refs.aiFileInput as HTMLInputElement).click()"
|
||||
>
|
||||
<input
|
||||
ref="aiFileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="d-none"
|
||||
@change="handleAiFileSelect"
|
||||
/>
|
||||
<i class="bi bi-cloud-arrow-up display-4 text-muted"></i>
|
||||
<p class="mt-2 mb-0 text-muted">
|
||||
이미지를 드래그하거나 클릭해서 업로드<br>
|
||||
<small>또는 <strong>Ctrl+V</strong>로 붙여넣기 (최대 10장)</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="aiUploadedImages.length > 0" class="mt-3">
|
||||
<label class="form-label small">업로드된 이미지 ({{ aiUploadedImages.length }}장)</label>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div v-for="(img, idx) in aiUploadedImages" :key="idx" class="position-relative">
|
||||
<img :src="img" class="rounded border" style="width: 100px; height: 100px; object-fit: cover;" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger position-absolute top-0 end-0 rounded-circle"
|
||||
style="transform: translate(30%, -30%); width: 20px; height: 20px; padding: 0; font-size: 10px;"
|
||||
@click="aiUploadedImages.splice(idx, 1)"
|
||||
>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3 mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
AI가 분석한 내용은 기존에 작성된 내용과 <strong>병합</strong>됩니다.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: 프로젝트 매칭 -->
|
||||
<template v-if="aiStep === 'matching' && aiParsedResult">
|
||||
<div class="alert alert-warning small mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
AI가 분석한 프로젝트를 기존 프로젝트와 매칭해주세요.
|
||||
</div>
|
||||
|
||||
<div v-for="(proj, pIdx) in aiParsedResult.projects" :key="pIdx" class="card mb-3">
|
||||
<div class="card-header bg-light">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<small class="text-muted">AI 분석 결과:</small>
|
||||
<strong class="ms-1">{{ proj.originalName }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">매칭할 프로젝트 선택</label>
|
||||
<select class="form-select" v-model="proj.matchedProjectId">
|
||||
<option :value="null" class="text-muted">-- 선택하세요 (미선택시 제외) --</option>
|
||||
<option v-for="p in allProjects" :key="p.projectId" :value="p.projectId">
|
||||
{{ p.projectCode }} - {{ p.projectName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 태스크 미리보기 -->
|
||||
<div class="row">
|
||||
<div class="col-md-6" v-if="proj.workTasks.length > 0">
|
||||
<label class="form-label small text-primary fw-bold">
|
||||
<i class="bi bi-check2-square me-1"></i>금주 실적 ({{ proj.workTasks.length }}건)
|
||||
</label>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li v-for="(task, tIdx) in proj.workTasks" :key="'w'+tIdx" class="text-truncate mb-1">
|
||||
<i class="bi bi-dot"></i>{{ task.description }}
|
||||
<span v-if="task.hours" class="text-muted">({{ task.hours }}h)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6" v-if="proj.planTasks.length > 0">
|
||||
<label class="form-label small text-success fw-bold">
|
||||
<i class="bi bi-calendar-check me-1"></i>차주 계획 ({{ proj.planTasks.length }}건)
|
||||
</label>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li v-for="(task, tIdx) in proj.planTasks" :key="'p'+tIdx" class="text-truncate mb-1">
|
||||
<i class="bi bi-dot"></i>{{ task.description }}
|
||||
<span v-if="task.hours" class="text-muted">({{ task.hours }}h)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공통사항 미리보기 -->
|
||||
<div v-if="aiParsedResult.issueDescription || aiParsedResult.vacationDescription || aiParsedResult.remarkDescription"
|
||||
class="card">
|
||||
<div class="card-header bg-light">
|
||||
<strong><i class="bi bi-chat-text me-1"></i>공통사항</strong>
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<div v-if="aiParsedResult.issueDescription" class="mb-2">
|
||||
<span class="badge bg-danger me-1">이슈</span>{{ aiParsedResult.issueDescription }}
|
||||
</div>
|
||||
<div v-if="aiParsedResult.vacationDescription" class="mb-2">
|
||||
<span class="badge bg-info me-1">휴가</span>{{ aiParsedResult.vacationDescription }}
|
||||
</div>
|
||||
<div v-if="aiParsedResult.remarkDescription">
|
||||
<span class="badge bg-secondary me-1">기타</span>{{ aiParsedResult.remarkDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<!-- Step 1: 입력 -->
|
||||
<template v-if="aiStep === 'input'">
|
||||
<button type="button" class="btn btn-secondary" @click="closeAiModal">취소</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="runAiParse"
|
||||
:disabled="isAiParsing || (aiInputMode === 'text' ? !aiRawText.trim() : aiUploadedImages.length === 0)"
|
||||
>
|
||||
<span v-if="isAiParsing" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-robot me-1"></i>
|
||||
AI 분석
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: 매칭 -->
|
||||
<template v-if="aiStep === 'matching'">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="aiStep = 'input'">
|
||||
<i class="bi bi-arrow-left me-1"></i>이전
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="applyAiResult" :disabled="!hasMatchedProjects">
|
||||
<i class="bi bi-check-lg me-1"></i>적용하기
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showAiModal" @click="closeAiModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const { currentUser, fetchCurrentUser } = useAuth()
|
||||
const { getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -352,6 +709,17 @@ const isSubmitting = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const isReviewing = ref(false)
|
||||
const showProjectModal = ref(false)
|
||||
const showAiReviewConfirmModal = ref(false)
|
||||
|
||||
// AI 자동채우기 모달
|
||||
const showAiModal = ref(false)
|
||||
const aiStep = ref<'input' | 'matching'>('input')
|
||||
const aiInputMode = ref<'text' | 'image'>('text')
|
||||
const aiRawText = ref('')
|
||||
const aiUploadedImages = ref<string[]>([])
|
||||
const aiIsDragging = ref(false)
|
||||
const isAiParsing = ref(false)
|
||||
const aiParsedResult = ref<any>(null)
|
||||
|
||||
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
|
||||
|
||||
@@ -407,6 +775,47 @@ const canEdit = computed(() => {
|
||||
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus !== 'AGGREGATED'
|
||||
})
|
||||
|
||||
// 품질 점수 파싱
|
||||
const qualityScore = computed(() => {
|
||||
if (!report.value?.aiReview) return null
|
||||
try {
|
||||
return JSON.parse(report.value.aiReview)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
// 점수 → 등급 변환
|
||||
function getScoreGrade(score: number): string {
|
||||
if (score >= 8) return '우수'
|
||||
if (score >= 5) return '적합'
|
||||
return '미흡'
|
||||
}
|
||||
|
||||
// 점수별 텍스트 색상 (적합/우수=녹색, 미흡=노랑)
|
||||
function getScoreTextClass(score: number): string {
|
||||
if (score >= 5) return 'text-success'
|
||||
return 'text-warning'
|
||||
}
|
||||
|
||||
// 점수별 프로그레스바 색상
|
||||
function getScoreColorClass(score: number): string {
|
||||
if (score >= 5) return 'bg-success'
|
||||
return 'bg-warning'
|
||||
}
|
||||
|
||||
// 종합 점수 배지 색상
|
||||
function getOverallBadgeClass(score: number): string {
|
||||
if (score >= 5) return 'bg-success'
|
||||
return 'bg-warning text-dark'
|
||||
}
|
||||
|
||||
// 종합 점수 알림 색상
|
||||
function getOverallAlertClass(score: number): string {
|
||||
if (score >= 5) return 'alert-success'
|
||||
return 'alert-warning'
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (!report.value || !currentUser.value) return false
|
||||
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus === 'DRAFT'
|
||||
@@ -494,9 +903,34 @@ watch(isEditing, (val) => {
|
||||
vacationDescription: report.value.vacationDescription || '',
|
||||
remarkDescription: report.value.remarkDescription || ''
|
||||
}
|
||||
|
||||
// 수정 모드 진입 시 textarea 높이 조절
|
||||
initAutoResize()
|
||||
}
|
||||
})
|
||||
|
||||
// === textarea 자동 높이 조절 ===
|
||||
function autoResize(e: Event) {
|
||||
const textarea = e.target as HTMLTextAreaElement
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
function initAutoResize() {
|
||||
nextTick(() => {
|
||||
document.querySelectorAll('textarea.auto-resize').forEach((el) => {
|
||||
const textarea = el as HTMLTextAreaElement
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// editForm.tasks 변경 시 textarea 높이 조절
|
||||
watch(() => editForm.value.tasks, () => {
|
||||
initAutoResize()
|
||||
}, { deep: true })
|
||||
|
||||
// 프로젝트별 시간 계산
|
||||
function getProjectWorkHours(proj: any) {
|
||||
return proj.workTasks.reduce((sum: number, t: any) => sum + (t.hours || 0), 0)
|
||||
@@ -508,64 +942,14 @@ function getProjectPlanHours(proj: any) {
|
||||
|
||||
// 수정 모드 주차 변경
|
||||
function changeEditWeek(delta: number) {
|
||||
let year = editForm.value.reportYear
|
||||
let week = editForm.value.reportWeek + delta
|
||||
|
||||
// 주차 범위 조정
|
||||
if (week < 1) {
|
||||
year--
|
||||
week = getWeeksInYear(year)
|
||||
} else if (week > getWeeksInYear(year)) {
|
||||
year++
|
||||
week = 1
|
||||
}
|
||||
|
||||
const { year, week } = calcChangeWeek(editForm.value.reportYear, editForm.value.reportWeek, delta)
|
||||
editForm.value.reportYear = year
|
||||
editForm.value.reportWeek = week
|
||||
|
||||
// 해당 주차의 월요일~일요일 계산
|
||||
const { monday, sunday } = getWeekDates(year, week)
|
||||
editForm.value.weekStartDate = monday
|
||||
editForm.value.weekEndDate = sunday
|
||||
}
|
||||
|
||||
// 연도의 총 주차 수 계산
|
||||
function getWeeksInYear(year: number): number {
|
||||
const dec31 = new Date(year, 11, 31)
|
||||
const dayOfWeek = dec31.getDay()
|
||||
// 12월 31일이 목요일 이후면 53주, 아니면 52주
|
||||
return dayOfWeek >= 4 || dayOfWeek === 0 ? 53 : 52
|
||||
}
|
||||
|
||||
// 연도와 주차로 해당 주의 월요일~일요일 계산
|
||||
function getWeekDates(year: number, week: number): { monday: string, sunday: string } {
|
||||
// 해당 연도의 첫 번째 목요일이 속한 주가 1주차
|
||||
const jan4 = new Date(year, 0, 4)
|
||||
const jan4DayOfWeek = jan4.getDay() || 7 // 일요일=7로 변환
|
||||
|
||||
// 1주차의 월요일
|
||||
const week1Monday = new Date(jan4)
|
||||
week1Monday.setDate(jan4.getDate() - jan4DayOfWeek + 1)
|
||||
|
||||
// 요청된 주차의 월요일
|
||||
const monday = new Date(week1Monday)
|
||||
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
|
||||
|
||||
// 일요일
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
|
||||
return {
|
||||
monday: formatDateStr(monday),
|
||||
sunday: formatDateStr(sunday)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateStr(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
const weekInfo = getWeekDates(year, week)
|
||||
editForm.value.weekStartDate = weekInfo.startDateStr
|
||||
editForm.value.weekEndDate = weekInfo.endDateStr
|
||||
}
|
||||
|
||||
// 수정 모드 함수들
|
||||
@@ -675,6 +1059,16 @@ async function handleUpdate() {
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
// AI 리뷰가 없으면 먼저 확인
|
||||
if (!report.value?.aiReview) {
|
||||
showAiReviewConfirmModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
await doSubmit()
|
||||
}
|
||||
|
||||
async function doSubmit() {
|
||||
if (!confirm('제출하시겠습니까? 제출 후에는 수정할 수 없습니다.')) return
|
||||
|
||||
isSubmitting.value = true
|
||||
@@ -689,6 +1083,25 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
// AI 리뷰 확인 모달에서 "예" 클릭
|
||||
async function handleAiReviewConfirm() {
|
||||
showAiReviewConfirmModal.value = false
|
||||
await requestAiReview()
|
||||
|
||||
// AI 리뷰 섹션으로 포커스 이동
|
||||
nextTick(() => {
|
||||
const aiReviewSection = document.getElementById('ai-review-section')
|
||||
if (aiReviewSection) {
|
||||
aiReviewSection.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// AI 리뷰 확인 모달에서 "아니오" 클릭
|
||||
function handleAiReviewCancel() {
|
||||
showAiReviewConfirmModal.value = false
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
const authorName = report.value?.authorName || ''
|
||||
const weekInfo = `${report.value?.reportYear}년 ${report.value?.reportWeek}주차`
|
||||
@@ -710,14 +1123,14 @@ async function handleDelete() {
|
||||
async function requestAiReview() {
|
||||
isReviewing.value = true
|
||||
try {
|
||||
const res = await $fetch<{ review: string; reviewedAt: string }>('/api/report/review', {
|
||||
const res = await $fetch<{ qualityScore: any; reviewedAt: string }>('/api/report/review', {
|
||||
method: 'POST',
|
||||
body: { reportId: parseInt(reportId.value) }
|
||||
})
|
||||
report.value.aiReview = res.review
|
||||
report.value.aiReview = JSON.stringify(res.qualityScore)
|
||||
report.value.aiReviewAt = res.reviewedAt
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || 'AI 리뷰 요청에 실패했습니다.')
|
||||
alert(e.data?.message || 'AI 품질 평가 요청에 실패했습니다.')
|
||||
} finally {
|
||||
isReviewing.value = false
|
||||
}
|
||||
@@ -748,9 +1161,9 @@ function formatDate(dateStr: string) {
|
||||
|
||||
function getStatusBadgeClass(status: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'DRAFT': 'badge bg-secondary',
|
||||
'DRAFT': 'badge bg-warning',
|
||||
'SUBMITTED': 'badge bg-success',
|
||||
'AGGREGATED': 'badge bg-info'
|
||||
'AGGREGATED': 'badge bg-success'
|
||||
}
|
||||
return classes[status] || 'badge bg-secondary'
|
||||
}
|
||||
@@ -758,11 +1171,169 @@ function getStatusBadgeClass(status: string) {
|
||||
function getStatusText(status: string) {
|
||||
const texts: Record<string, string> = {
|
||||
'DRAFT': '작성중',
|
||||
'SUBMITTED': '제출완료',
|
||||
'AGGREGATED': '취합완료'
|
||||
'SUBMITTED': '제출',
|
||||
'AGGREGATED': '제출'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// === AI 자동채우기 관련 ===
|
||||
function closeAiModal() {
|
||||
showAiModal.value = false
|
||||
aiStep.value = 'input'
|
||||
aiRawText.value = ''
|
||||
aiUploadedImages.value = []
|
||||
aiParsedResult.value = null
|
||||
}
|
||||
|
||||
function handleAiDrop(e: DragEvent) {
|
||||
aiIsDragging.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
if (files) processAiFiles(Array.from(files))
|
||||
}
|
||||
|
||||
function handleAiPaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const imageFiles: File[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile()
|
||||
if (file) imageFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault()
|
||||
processAiFiles(imageFiles)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAiFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) processAiFiles(Array.from(input.files))
|
||||
}
|
||||
|
||||
function processAiFiles(files: File[]) {
|
||||
const maxFiles = 10 - aiUploadedImages.value.length
|
||||
const toProcess = files.slice(0, maxFiles)
|
||||
|
||||
toProcess.forEach(file => {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
aiUploadedImages.value.push(e.target.result as string)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function runAiParse() {
|
||||
isAiParsing.value = true
|
||||
try {
|
||||
let res: any
|
||||
if (aiInputMode.value === 'text') {
|
||||
res = await $fetch<any>('/api/ai/parse-my-report', {
|
||||
method: 'POST',
|
||||
body: { rawText: aiRawText.value }
|
||||
})
|
||||
} else {
|
||||
res = await $fetch<any>('/api/ai/parse-my-report-image', {
|
||||
method: 'POST',
|
||||
body: { images: aiUploadedImages.value }
|
||||
})
|
||||
}
|
||||
|
||||
// 파싱 결과를 임시 저장하고 매칭 단계로 이동
|
||||
if (res.parsed?.projects?.length > 0) {
|
||||
aiParsedResult.value = {
|
||||
projects: res.parsed.projects.map((p: any) => ({
|
||||
originalName: p.projectName || '알 수 없음',
|
||||
matchedProjectId: p.matchedProjectId || null,
|
||||
workTasks: p.workTasks || [],
|
||||
planTasks: p.planTasks || []
|
||||
})),
|
||||
issueDescription: res.parsed.issueDescription,
|
||||
vacationDescription: res.parsed.vacationDescription,
|
||||
remarkDescription: res.parsed.remarkDescription
|
||||
}
|
||||
aiStep.value = 'matching'
|
||||
} else {
|
||||
alert('분석된 내용이 없습니다.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || 'AI 분석에 실패했습니다.')
|
||||
} finally {
|
||||
isAiParsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭된 프로젝트가 있는지 확인
|
||||
const hasMatchedProjects = computed(() => {
|
||||
if (!aiParsedResult.value) return false
|
||||
return aiParsedResult.value.projects.some((p: any) => p.matchedProjectId !== null)
|
||||
})
|
||||
|
||||
// 매칭 완료 후 적용
|
||||
function applyAiResult() {
|
||||
if (!aiParsedResult.value) return
|
||||
|
||||
const parsed = aiParsedResult.value
|
||||
|
||||
// 프로젝트별 태스크 병합
|
||||
for (const proj of parsed.projects) {
|
||||
const projectId = proj.matchedProjectId
|
||||
if (!projectId) continue // 미선택은 제외
|
||||
|
||||
// 금주 실적 추가
|
||||
for (const task of proj.workTasks) {
|
||||
if (task.description?.trim()) {
|
||||
editForm.value.tasks.push({
|
||||
projectId,
|
||||
taskType: 'WORK',
|
||||
description: task.description,
|
||||
hours: task.hours || 0,
|
||||
isCompleted: task.isCompleted !== false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 차주 계획 추가
|
||||
for (const task of proj.planTasks) {
|
||||
if (task.description?.trim()) {
|
||||
editForm.value.tasks.push({
|
||||
projectId,
|
||||
taskType: 'PLAN',
|
||||
description: task.description,
|
||||
hours: task.hours || 0,
|
||||
isCompleted: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공통사항 병합
|
||||
if (parsed.issueDescription) {
|
||||
editForm.value.issueDescription = editForm.value.issueDescription
|
||||
? editForm.value.issueDescription + '\n' + parsed.issueDescription
|
||||
: parsed.issueDescription
|
||||
}
|
||||
if (parsed.vacationDescription) {
|
||||
editForm.value.vacationDescription = editForm.value.vacationDescription
|
||||
? editForm.value.vacationDescription + '\n' + parsed.vacationDescription
|
||||
: parsed.vacationDescription
|
||||
}
|
||||
if (parsed.remarkDescription) {
|
||||
editForm.value.remarkDescription = editForm.value.remarkDescription
|
||||
? editForm.value.remarkDescription + '\n' + parsed.remarkDescription
|
||||
: parsed.remarkDescription
|
||||
}
|
||||
|
||||
closeAiModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -777,4 +1348,16 @@ function getStatusText(status: string) {
|
||||
color: #0d6efd;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
textarea.auto-resize {
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
min-height: 32px;
|
||||
}
|
||||
.upload-zone {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.upload-zone:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -139,6 +139,7 @@ import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const { fetchCurrentUser, isAdmin } = useAuth()
|
||||
const { getWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isLoaded = ref(false)
|
||||
@@ -178,72 +179,23 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function initCurrentWeek() {
|
||||
const now = new Date()
|
||||
const jan4 = new Date(now.getFullYear(), 0, 4)
|
||||
const jan4Day = jan4.getDay() || 7
|
||||
const week1Monday = new Date(jan4)
|
||||
week1Monday.setDate(jan4.getDate() - jan4Day + 1)
|
||||
|
||||
const diff = now.getTime() - week1Monday.getTime()
|
||||
const weekNum = Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1
|
||||
|
||||
selectedYear.value = now.getFullYear()
|
||||
selectedWeek.value = weekNum > 0 ? weekNum : 1
|
||||
const weekInfo = getWeekInfo(new Date())
|
||||
selectedYear.value = weekInfo.year
|
||||
selectedWeek.value = weekInfo.week
|
||||
updateWeekDates()
|
||||
}
|
||||
|
||||
function changeWeek(delta: number) {
|
||||
let year = selectedYear.value
|
||||
let week = selectedWeek.value + delta
|
||||
|
||||
if (week < 1) {
|
||||
year--
|
||||
week = getWeeksInYear(year)
|
||||
} else if (week > getWeeksInYear(year)) {
|
||||
year++
|
||||
week = 1
|
||||
}
|
||||
|
||||
const { year, week } = calcChangeWeek(selectedYear.value, selectedWeek.value, delta)
|
||||
selectedYear.value = year
|
||||
selectedWeek.value = week
|
||||
updateWeekDates()
|
||||
}
|
||||
|
||||
function getWeeksInYear(year: number): number {
|
||||
const dec31 = new Date(year, 11, 31)
|
||||
const dayOfWeek = dec31.getDay()
|
||||
return dayOfWeek >= 4 || dayOfWeek === 0 ? 53 : 52
|
||||
}
|
||||
|
||||
function updateWeekDates() {
|
||||
const { monday, sunday } = getWeekDates(selectedYear.value, selectedWeek.value)
|
||||
weekStartDate.value = monday
|
||||
weekEndDate.value = sunday
|
||||
}
|
||||
|
||||
function getWeekDates(year: number, week: number): { monday: string, sunday: string } {
|
||||
const jan4 = new Date(year, 0, 4)
|
||||
const jan4Day = jan4.getDay() || 7
|
||||
const week1Monday = new Date(jan4)
|
||||
week1Monday.setDate(jan4.getDate() - jan4Day + 1)
|
||||
|
||||
const monday = new Date(week1Monday)
|
||||
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
|
||||
|
||||
const sunday = new Date(monday)
|
||||
sunday.setDate(monday.getDate() + 6)
|
||||
|
||||
return {
|
||||
monday: formatDate(monday),
|
||||
sunday: formatDate(sunday)
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
const y = date.getFullYear()
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const d = String(date.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${d}`
|
||||
const weekInfo = getWeekDates(selectedYear.value, selectedWeek.value)
|
||||
weekStartDate.value = weekInfo.startDateStr
|
||||
weekEndDate.value = weekInfo.endDateStr
|
||||
}
|
||||
|
||||
async function loadAggregate() {
|
||||
|
||||
@@ -8,10 +8,7 @@
|
||||
<i class="bi bi-journal-text me-2"></i>주간보고
|
||||
</h4>
|
||||
<div class="d-flex gap-2">
|
||||
<NuxtLink v-if="isAdmin" to="/report/summary" class="btn btn-outline-primary">
|
||||
<i class="bi bi-collection me-1"></i>취합하기
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/report/weekly/write" class="btn btn-primary">
|
||||
<NuxtLink :to="`/report/weekly/write?year=${filters.year}&week=${filters.week}`" class="btn btn-primary">
|
||||
<i class="bi bi-plus me-1"></i>작성하기
|
||||
</NuxtLink>
|
||||
</div>
|
||||
@@ -21,16 +18,8 @@
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<!-- 전체보기 (관리자만) -->
|
||||
<div class="col-auto" v-if="isAdmin">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="viewAll" v-model="filters.viewAll" @change="loadReports">
|
||||
<label class="form-check-label" for="viewAll">전체 보기</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작성자 -->
|
||||
<div class="col-md-2" v-if="isAdmin">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted">작성자</label>
|
||||
<select class="form-select form-select-sm" v-model="filters.authorId" @change="loadReports">
|
||||
<option value="">전체</option>
|
||||
@@ -40,21 +29,24 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 연도 -->
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small text-muted">연도</label>
|
||||
<select class="form-select form-select-sm" v-model="filters.year" @change="loadReports">
|
||||
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}년</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 주차 -->
|
||||
<div class="col-md-1">
|
||||
<label class="form-label small text-muted">주차</label>
|
||||
<select class="form-select form-select-sm" v-model="filters.week" @change="loadReports">
|
||||
<option value="">전체</option>
|
||||
<option v-for="w in weekOptions" :key="w" :value="w">{{ w }}주</option>
|
||||
</select>
|
||||
<!-- 연도/주차 -->
|
||||
<div class="col-auto">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="changeWeek(-1)" title="이전 주차">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<select class="form-select form-select-sm" style="width: 100px;" v-model="filters.year" @change="onYearChange">
|
||||
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}년</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm" style="width: auto;" v-model="filters.week" @change="loadReports">
|
||||
<option v-for="opt in weekOptionsWithDates" :key="opt.week" :value="opt.week">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="btn btn-outline-secondary btn-sm" @click="changeWeek(1)" title="다음 주차" :disabled="isCurrentWeek">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 초기화 -->
|
||||
@@ -76,20 +68,22 @@
|
||||
<tr>
|
||||
<th style="width: 120px">주차</th>
|
||||
<th style="width: 180px">기간</th>
|
||||
<th v-if="isAdmin" style="width: 120px">작성자</th>
|
||||
<th>프로젝트</th>
|
||||
<th v-if="isAdmin" style="width: 80px">작성자</th>
|
||||
<th style="width: 90px">상태</th>
|
||||
<th style="width: 100px">제출일</th>
|
||||
<th style="width: 130px">작성/수정</th>
|
||||
<th style="width: 130px">제출일시</th>
|
||||
<th style="width: 70px">품질</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="isLoading">
|
||||
<td :colspan="isAdmin ? 6 : 5" class="text-center py-4">
|
||||
<td :colspan="isAdmin ? 8 : 7" class="text-center py-4">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>로딩 중...
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-else-if="reports.length === 0">
|
||||
<td :colspan="isAdmin ? 6 : 5" class="text-center py-5 text-muted">
|
||||
<td :colspan="isAdmin ? 8 : 7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-inbox display-4"></i>
|
||||
<p class="mt-2 mb-0">조회된 주간보고가 없습니다.</p>
|
||||
</td>
|
||||
@@ -103,21 +97,33 @@
|
||||
<td class="small">
|
||||
{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}
|
||||
</td>
|
||||
<td v-if="isAdmin">
|
||||
<span class="badge bg-secondary">{{ r.authorName }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="text-truncate d-inline-block" style="max-width: 400px;" :title="r.projectNames">
|
||||
{{ r.projectNames || '-' }}
|
||||
</span>
|
||||
<span class="badge bg-light text-dark ms-1">{{ r.projectCount }}건</span>
|
||||
</td>
|
||||
<td v-if="isAdmin">{{ r.authorName }}</td>
|
||||
<td>
|
||||
<span :class="getStatusBadgeClass(r.reportStatus)">
|
||||
{{ getStatusText(r.reportStatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="small">{{ formatDateTime(r.submittedAt || r.createdAt) }}</td>
|
||||
<td class="small">
|
||||
<div>{{ formatShortDateTime(r.createdAt) }}</div>
|
||||
<div v-if="r.updatedAt && r.updatedAt !== r.createdAt" class="text-muted">
|
||||
{{ formatShortDateTime(r.updatedAt) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="small">
|
||||
{{ r.submittedAt ? formatShortDateTime(r.submittedAt) : '-' }}
|
||||
</td>
|
||||
<td>
|
||||
<span v-if="r.aiReview" class="fw-bold" :class="getQualityTextClass(r.aiReview)">
|
||||
{{ getQualityGrade(r.aiReview) }}
|
||||
</span>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -133,24 +139,74 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const { getCurrentWeekInfo, getActualCurrentWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const reports = ref<any[]>([])
|
||||
const employees = ref<any[]>([])
|
||||
const projects = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isAdmin = ref(false)
|
||||
|
||||
const currentWeek = getCurrentWeekInfo()
|
||||
const actualCurrentWeek = getActualCurrentWeekInfo() // 실제 현재 주차
|
||||
const currentYear = new Date().getFullYear()
|
||||
const yearOptions = [currentYear, currentYear - 1, currentYear - 2]
|
||||
const weekOptions = Array.from({ length: 53 }, (_, i) => i + 1)
|
||||
|
||||
// 현재 주차인지 확인 (다음 버튼 비활성화용)
|
||||
const isCurrentWeek = computed(() =>
|
||||
filters.value.year === actualCurrentWeek.year && filters.value.week === actualCurrentWeek.week
|
||||
)
|
||||
|
||||
// 주차 옵션 (날짜 포함, 현재 주차까지만)
|
||||
const weekOptionsWithDates = computed(() => {
|
||||
const weeksInYear = getWeeksInYear(filters.value.year)
|
||||
// 현재 연도면 현재 주차까지만, 과거 연도면 전체
|
||||
const maxWeek = filters.value.year === actualCurrentWeek.year
|
||||
? actualCurrentWeek.week
|
||||
: weeksInYear
|
||||
|
||||
return Array.from({ length: maxWeek }, (_, i) => {
|
||||
const week = i + 1
|
||||
const weekInfo = getWeekDates(filters.value.year, week)
|
||||
const startMD = weekInfo.startDateStr.slice(5).replace('-', '/')
|
||||
const endMD = weekInfo.endDateStr.slice(5).replace('-', '/')
|
||||
return {
|
||||
week,
|
||||
label: `${week}주차 (${startMD}~${endMD})`
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const filters = ref({
|
||||
viewAll: false,
|
||||
authorId: '',
|
||||
year: currentYear,
|
||||
week: ''
|
||||
year: currentWeek.year,
|
||||
week: currentWeek.week
|
||||
})
|
||||
|
||||
// 주차 변경
|
||||
function changeWeek(delta: number) {
|
||||
const result = calcChangeWeek(filters.value.year, filters.value.week, delta)
|
||||
|
||||
// 미래 주차로 이동 방지
|
||||
if (result.year > actualCurrentWeek.year) return
|
||||
if (result.year === actualCurrentWeek.year && result.week > actualCurrentWeek.week) return
|
||||
|
||||
filters.value.year = result.year
|
||||
filters.value.week = result.week
|
||||
loadReports()
|
||||
}
|
||||
|
||||
// 연도 변경 시 주차 범위 조정
|
||||
function onYearChange() {
|
||||
const maxWeek = getWeeksInYear(filters.value.year)
|
||||
if (filters.value.week > maxWeek) {
|
||||
filters.value.week = maxWeek
|
||||
}
|
||||
loadReports()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
@@ -160,11 +216,15 @@ onMounted(async () => {
|
||||
|
||||
isAdmin.value = user.employeeEmail === 'coziny@gmail.com'
|
||||
|
||||
// 직원, 프로젝트 목록 로드 (관리자용)
|
||||
if (isAdmin.value) {
|
||||
await loadFilterOptions()
|
||||
// URL 쿼리 파라미터가 있으면 필터에 적용
|
||||
if (route.query.year && route.query.week) {
|
||||
filters.value.year = parseInt(route.query.year as string)
|
||||
filters.value.week = parseInt(route.query.week as string)
|
||||
}
|
||||
|
||||
// 직원 목록 로드
|
||||
await loadFilterOptions()
|
||||
|
||||
loadReports()
|
||||
})
|
||||
|
||||
@@ -187,7 +247,6 @@ async function loadReports() {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filters.value.viewAll) params.append('viewAll', 'true')
|
||||
if (filters.value.authorId) params.append('authorId', filters.value.authorId)
|
||||
if (filters.value.year) params.append('year', String(filters.value.year))
|
||||
if (filters.value.week) params.append('week', String(filters.value.week))
|
||||
@@ -203,10 +262,9 @@ async function loadReports() {
|
||||
|
||||
function resetFilters() {
|
||||
filters.value = {
|
||||
viewAll: false,
|
||||
authorId: '',
|
||||
year: currentYear,
|
||||
week: ''
|
||||
year: currentWeek.year,
|
||||
week: currentWeek.week
|
||||
}
|
||||
loadReports()
|
||||
}
|
||||
@@ -222,11 +280,25 @@ function formatDateTime(dateStr: string) {
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`
|
||||
}
|
||||
|
||||
function formatSubmittedAt(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토']
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const date = String(d.getDate()).padStart(2, '0')
|
||||
const day = days[d.getDay()]
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${year}-${month}-${date}(${day}) ${hours}:${minutes}:${seconds}`
|
||||
}
|
||||
|
||||
function getStatusBadgeClass(status: string) {
|
||||
const classes: Record<string, string> = {
|
||||
'DRAFT': 'badge bg-secondary',
|
||||
'DRAFT': 'badge bg-warning',
|
||||
'SUBMITTED': 'badge bg-success',
|
||||
'AGGREGATED': 'badge bg-info'
|
||||
'AGGREGATED': 'badge bg-success'
|
||||
}
|
||||
return classes[status] || 'badge bg-secondary'
|
||||
}
|
||||
@@ -234,11 +306,49 @@ function getStatusBadgeClass(status: string) {
|
||||
function getStatusText(status: string) {
|
||||
const texts: Record<string, string> = {
|
||||
'DRAFT': '작성중',
|
||||
'SUBMITTED': '제출완료',
|
||||
'AGGREGATED': '취합완료'
|
||||
'SUBMITTED': '제출',
|
||||
'AGGREGATED': '제출'
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 짧은 날짜시간 형식 (MM/DD HH:mm)
|
||||
function formatShortDateTime(dateStr: string) {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const date = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
return `${month}/${date} ${hours}:${minutes}`
|
||||
}
|
||||
|
||||
// 품질 등급 추출
|
||||
function getQualityGrade(aiReview: string): string {
|
||||
if (!aiReview) return '-'
|
||||
try {
|
||||
const data = JSON.parse(aiReview)
|
||||
const score = data.overall || 0
|
||||
if (score >= 8) return '우수'
|
||||
if (score >= 5) return '적합'
|
||||
return '미흡'
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
// 품질 등급 색상 (적합/우수=녹색, 미흡=노랑)
|
||||
function getQualityTextClass(aiReview: string): string {
|
||||
if (!aiReview) return ''
|
||||
try {
|
||||
const data = JSON.parse(aiReview)
|
||||
const score = data.overall || 0
|
||||
if (score >= 5) return 'text-success'
|
||||
return 'text-warning'
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -12,31 +12,12 @@
|
||||
<div class="card mb-4">
|
||||
<div class="card-header"><strong>보고 주차</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row align-items-end">
|
||||
<div class="col-auto">
|
||||
<div class="input-group">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(-1)">
|
||||
<i class="bi bi-chevron-left"></i>
|
||||
</button>
|
||||
<span class="input-group-text bg-white" style="min-width: 160px;">
|
||||
<strong>{{ form.reportYear }}년 {{ form.reportWeek }}주차</strong>
|
||||
</span>
|
||||
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(1)">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="input-group">
|
||||
<input type="date" class="form-control" v-model="form.weekStartDate" @change="updateWeekFromDate" />
|
||||
<span class="input-group-text">~</span>
|
||||
<input type="date" class="form-control" v-model="form.weekEndDate" readonly />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" @click="setLastWeek">지난주</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm ms-1" @click="setThisWeek">이번주</button>
|
||||
</div>
|
||||
<div class="d-flex align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-calendar-week me-2 text-primary"></i>
|
||||
<strong>{{ form.reportYear }}년 {{ form.reportWeek }}주차</strong>
|
||||
<span class="text-muted ms-2">({{ form.weekStartDate }} ~ {{ form.weekEndDate }})</span>
|
||||
</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -45,9 +26,14 @@
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
|
||||
<i class="bi bi-plus"></i> 프로젝트 추가
|
||||
</button>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-success" @click="showAiModal = true">
|
||||
<i class="bi bi-robot me-1"></i>AI 자동채우기
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
|
||||
<i class="bi bi-plus"></i> 프로젝트 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div v-if="projectGroups.length === 0" class="text-center text-muted py-4">
|
||||
@@ -88,7 +74,8 @@
|
||||
{{ task.isCompleted ? '완료' : '진행' }}
|
||||
</label>
|
||||
</div>
|
||||
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="작업 내용"></textarea>
|
||||
<textarea class="form-control form-control-sm auto-resize" v-model="task.description" rows="1"
|
||||
placeholder="작업 내용" @input="autoResize"></textarea>
|
||||
<div class="text-nowrap">
|
||||
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
|
||||
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
|
||||
@@ -114,7 +101,8 @@
|
||||
</div>
|
||||
<div v-for="(task, tIdx) in getPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2">
|
||||
<div class="d-flex gap-2 align-items-start">
|
||||
<textarea class="form-control form-control-sm" v-model="task.description" rows="2" placeholder="계획 내용"></textarea>
|
||||
<textarea class="form-control form-control-sm auto-resize" v-model="task.description" rows="1"
|
||||
placeholder="계획 내용" @input="autoResize"></textarea>
|
||||
<div class="text-nowrap">
|
||||
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;"
|
||||
v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
|
||||
@@ -205,11 +193,269 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showProjectModal" @click="showProjectModal = false"></div>
|
||||
|
||||
<!-- 이전 계획 로드 확인 모달 -->
|
||||
<div class="modal fade" :class="{ show: showLoadConfirmModal }" :style="{ display: showLoadConfirmModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-clipboard-check me-2"></i>이전 계획 불러오기
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @click="showLoadConfirmModal = false"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">
|
||||
지난주에 작성된 주간보고 내용이 없습니다.<br>
|
||||
최근에 작성한 <strong>{{ recentReportInfo?.reportYear }}년 {{ recentReportInfo?.reportWeek }}주차</strong> 계획을 불러오시겠습니까?
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="showLoadConfirmModal = false">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="confirmLoadRecentPlan">
|
||||
<i class="bi bi-download me-1"></i>불러오기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showLoadConfirmModal" @click="showLoadConfirmModal = false"></div>
|
||||
|
||||
<!-- 기존 보고서 존재 확인 모달 -->
|
||||
<div class="modal fade" :class="{ show: showExistingReportModal }" :style="{ display: showExistingReportModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-exclamation-circle me-2 text-warning"></i>주간보고 존재
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="mb-0">
|
||||
<strong>{{ existingReportInfo?.reportYear }}년 {{ existingReportInfo?.reportWeek }}주차</strong>에 작성된 주간보고가 이미 존재합니다.<br>
|
||||
수정화면으로 이동합니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="goToList">취소</button>
|
||||
<button type="button" class="btn btn-primary" @click="goToExistingReport">
|
||||
<i class="bi bi-pencil me-1"></i>확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showExistingReportModal"></div>
|
||||
|
||||
<!-- AI 자동채우기 모달 -->
|
||||
<div class="modal fade" :class="{ show: showAiModal }" :style="{ display: showAiModal ? 'block' : 'none' }">
|
||||
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="bi bi-robot me-2"></i>AI 자동채우기
|
||||
<span v-if="aiStep === 'matching'" class="badge bg-primary ms-2">프로젝트 매칭</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close" @click="closeAiModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- Step 1: 입력 -->
|
||||
<template v-if="aiStep === 'input'">
|
||||
<!-- 입력 방식 탭 -->
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: aiInputMode === 'text' }" href="#" @click.prevent="aiInputMode = 'text'">
|
||||
<i class="bi bi-fonts me-1"></i>텍스트
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" :class="{ active: aiInputMode === 'image' }" href="#" @click.prevent="aiInputMode = 'image'">
|
||||
<i class="bi bi-image me-1"></i>이미지
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 텍스트 입력 -->
|
||||
<div v-if="aiInputMode === 'text'">
|
||||
<textarea
|
||||
class="form-control font-monospace"
|
||||
v-model="aiRawText"
|
||||
rows="12"
|
||||
placeholder="주간보고 내용을 붙여넣으세요.
|
||||
|
||||
예시:
|
||||
- PIMS 고도화: API 개발 완료 (8시간), UI 수정 (4시간)
|
||||
- 차주: 테스트 진행 예정 (16시간)
|
||||
- 이슈: 서버 메모리 부족
|
||||
- 휴가: 1/10(금) 연차"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- 이미지 입력 -->
|
||||
<div v-if="aiInputMode === 'image'">
|
||||
<div
|
||||
class="upload-zone p-5 text-center border rounded"
|
||||
:class="{ 'border-primary bg-light': isDragging }"
|
||||
tabindex="0"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="handleAiDrop"
|
||||
@paste="handleAiPaste"
|
||||
@click="($refs.aiFileInput as HTMLInputElement).click()"
|
||||
>
|
||||
<input
|
||||
ref="aiFileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/*"
|
||||
class="d-none"
|
||||
@change="handleAiFileSelect"
|
||||
/>
|
||||
<i class="bi bi-cloud-arrow-up display-4 text-muted"></i>
|
||||
<p class="mt-2 mb-0 text-muted">
|
||||
이미지를 드래그하거나 클릭해서 업로드<br>
|
||||
<small>또는 <strong>Ctrl+V</strong>로 붙여넣기 (최대 10장)</small>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="aiUploadedImages.length > 0" class="mt-3">
|
||||
<label class="form-label small">업로드된 이미지 ({{ aiUploadedImages.length }}장)</label>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<div v-for="(img, idx) in aiUploadedImages" :key="idx" class="position-relative">
|
||||
<img :src="img" class="rounded border" style="width: 100px; height: 100px; object-fit: cover;" />
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-danger position-absolute top-0 end-0 rounded-circle"
|
||||
style="transform: translate(30%, -30%); width: 20px; height: 20px; padding: 0; font-size: 10px;"
|
||||
@click="aiUploadedImages.splice(idx, 1)"
|
||||
>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-info mt-3 mb-0 small">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
AI가 분석한 내용은 기존에 작성된 내용과 <strong>병합</strong>됩니다.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: 프로젝트 매칭 -->
|
||||
<template v-if="aiStep === 'matching' && aiParsedResult">
|
||||
<div class="alert alert-warning small mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
AI가 분석한 프로젝트를 기존 프로젝트와 매칭해주세요.
|
||||
</div>
|
||||
|
||||
<div v-for="(proj, pIdx) in aiParsedResult.projects" :key="pIdx" class="card mb-3">
|
||||
<div class="card-header bg-light">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<small class="text-muted">AI 분석 결과:</small>
|
||||
<strong class="ms-1">{{ proj.originalName }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-bold">매칭할 프로젝트 선택</label>
|
||||
<select class="form-select" v-model="proj.matchedProjectId">
|
||||
<option :value="null" class="text-muted">-- 선택하세요 (미선택시 제외) --</option>
|
||||
<option v-for="p in allProjects" :key="p.projectId" :value="p.projectId">
|
||||
{{ p.projectCode }} - {{ p.projectName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 태스크 미리보기 -->
|
||||
<div class="row">
|
||||
<div class="col-md-6" v-if="proj.workTasks.length > 0">
|
||||
<label class="form-label small text-primary fw-bold">
|
||||
<i class="bi bi-check2-square me-1"></i>금주 실적 ({{ proj.workTasks.length }}건)
|
||||
</label>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li v-for="(task, tIdx) in proj.workTasks" :key="'w'+tIdx" class="text-truncate mb-1">
|
||||
<i class="bi bi-dot"></i>{{ task.description }}
|
||||
<span v-if="task.hours" class="text-muted">({{ task.hours }}h)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6" v-if="proj.planTasks.length > 0">
|
||||
<label class="form-label small text-success fw-bold">
|
||||
<i class="bi bi-calendar-check me-1"></i>차주 계획 ({{ proj.planTasks.length }}건)
|
||||
</label>
|
||||
<ul class="list-unstyled small mb-0">
|
||||
<li v-for="(task, tIdx) in proj.planTasks" :key="'p'+tIdx" class="text-truncate mb-1">
|
||||
<i class="bi bi-dot"></i>{{ task.description }}
|
||||
<span v-if="task.hours" class="text-muted">({{ task.hours }}h)</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공통사항 미리보기 -->
|
||||
<div v-if="aiParsedResult.issueDescription || aiParsedResult.vacationDescription || aiParsedResult.remarkDescription"
|
||||
class="card">
|
||||
<div class="card-header bg-light">
|
||||
<strong><i class="bi bi-chat-text me-1"></i>공통사항</strong>
|
||||
</div>
|
||||
<div class="card-body small">
|
||||
<div v-if="aiParsedResult.issueDescription" class="mb-2">
|
||||
<span class="badge bg-danger me-1">이슈</span>{{ aiParsedResult.issueDescription }}
|
||||
</div>
|
||||
<div v-if="aiParsedResult.vacationDescription" class="mb-2">
|
||||
<span class="badge bg-info me-1">휴가</span>{{ aiParsedResult.vacationDescription }}
|
||||
</div>
|
||||
<div v-if="aiParsedResult.remarkDescription">
|
||||
<span class="badge bg-secondary me-1">기타</span>{{ aiParsedResult.remarkDescription }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<!-- Step 1: 입력 -->
|
||||
<template v-if="aiStep === 'input'">
|
||||
<button type="button" class="btn btn-secondary" @click="closeAiModal">취소</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
@click="runAiParse"
|
||||
:disabled="isAiParsing || (aiInputMode === 'text' ? !aiRawText.trim() : aiUploadedImages.length === 0)"
|
||||
>
|
||||
<span v-if="isAiParsing" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-robot me-1"></i>
|
||||
AI 분석
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: 매칭 -->
|
||||
<template v-if="aiStep === 'matching'">
|
||||
<button type="button" class="btn btn-outline-secondary" @click="aiStep = 'input'">
|
||||
<i class="bi bi-arrow-left me-1"></i>이전
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" @click="applyAiResult" :disabled="!hasMatchedProjects">
|
||||
<i class="bi bi-check-lg me-1"></i>적용하기
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-backdrop fade show" v-if="showAiModal" @click="closeAiModal"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const route = useRoute()
|
||||
const { getWeekInfo, getWeekDates, getLastWeekInfo, getActualCurrentWeekInfo, getMonday, changeWeek: calcChangeWeek } = useWeekCalc()
|
||||
const router = useRouter()
|
||||
|
||||
interface TaskItem {
|
||||
@@ -230,6 +476,37 @@ const allProjects = ref<any[]>([])
|
||||
const showProjectModal = ref(false)
|
||||
const isSaving = ref(false)
|
||||
|
||||
// 이전 계획 로드 확인 모달
|
||||
const showLoadConfirmModal = ref(false)
|
||||
const recentReportInfo = ref<{ reportId: number; reportYear: number; reportWeek: number } | null>(null)
|
||||
|
||||
// 기존 보고서 존재 확인 모달
|
||||
const showExistingReportModal = ref(false)
|
||||
const existingReportInfo = ref<{ reportId: number; reportYear: number; reportWeek: number } | null>(null)
|
||||
|
||||
// AI 자동채우기 모달
|
||||
const showAiModal = ref(false)
|
||||
const aiStep = ref<'input' | 'matching'>('input')
|
||||
const aiInputMode = ref<'text' | 'image'>('text')
|
||||
const aiRawText = ref('')
|
||||
const aiUploadedImages = ref<string[]>([])
|
||||
const isAiParsing = ref(false)
|
||||
const isDragging = ref(false)
|
||||
|
||||
// AI 분석 결과 (매칭 전 임시 저장)
|
||||
interface AiParsedProject {
|
||||
originalName: string
|
||||
matchedProjectId: number | null
|
||||
workTasks: { description: string; hours: number; isCompleted: boolean }[]
|
||||
planTasks: { description: string; hours: number }[]
|
||||
}
|
||||
const aiParsedResult = ref<{
|
||||
projects: AiParsedProject[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
} | null>(null)
|
||||
|
||||
const form = ref({
|
||||
reportYear: new Date().getFullYear(),
|
||||
reportWeek: 1,
|
||||
@@ -266,7 +543,7 @@ const totalPlanHours = computed(() =>
|
||||
form.value.tasks.filter(t => t.taskType === 'PLAN').reduce((sum, t) => sum + (t.hours || 0), 0)
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => form.value.tasks.some(t => t.description.trim()))
|
||||
const canSubmit = computed(() => form.value.tasks.some(t => t.description?.trim()))
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
@@ -276,9 +553,60 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
await loadProjects()
|
||||
setLastWeek()
|
||||
await setDefaultWeek(user.employeeId)
|
||||
|
||||
// 이번 주차에 이미 작성한 보고서가 있는지 확인
|
||||
const existingReport = await checkExistingReport(user.employeeId)
|
||||
if (existingReport) {
|
||||
// 이미 작성한 보고서가 있으면 모달로 확인
|
||||
existingReportInfo.value = {
|
||||
reportId: existingReport.reportId,
|
||||
reportYear: form.value.reportYear,
|
||||
reportWeek: form.value.reportWeek
|
||||
}
|
||||
showExistingReportModal.value = true
|
||||
return
|
||||
}
|
||||
|
||||
await loadLastWeekPlan(user.employeeId)
|
||||
initAutoResize()
|
||||
})
|
||||
|
||||
// tasks 변경 시 textarea 높이 조절
|
||||
watch(() => form.value.tasks, () => {
|
||||
initAutoResize()
|
||||
}, { deep: true })
|
||||
|
||||
// 이번 주차에 이미 작성한 보고서 확인
|
||||
async function checkExistingReport(userId: number) {
|
||||
try {
|
||||
const res = await $fetch<any>(`/api/report/weekly/list?year=${form.value.reportYear}&week=${form.value.reportWeek}&authorId=${userId}`)
|
||||
if (res.reports && res.reports.length > 0) {
|
||||
return res.reports[0]
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('기존 보고서 확인 실패:', e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 기존 보고서 모달 - 확인 (수정화면으로 이동)
|
||||
function goToExistingReport() {
|
||||
if (existingReportInfo.value) {
|
||||
router.replace(`/report/weekly/${existingReportInfo.value.reportId}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 기존 보고서 모달 - 취소 (목록으로 이동)
|
||||
function goToList() {
|
||||
// 쿼리 파라미터가 있으면 해당 주차 목록으로 이동
|
||||
if (route.query.year && route.query.week) {
|
||||
router.replace(`/report/weekly?year=${route.query.year}&week=${route.query.week}`)
|
||||
} else {
|
||||
router.replace('/report/weekly')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
try {
|
||||
const res = await $fetch<any>('/api/project/list')
|
||||
@@ -288,67 +616,150 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
// 주차 관련 함수들
|
||||
function getMonday(date: Date): Date {
|
||||
const d = new Date(date)
|
||||
const day = d.getDay()
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
|
||||
d.setDate(diff)
|
||||
return d
|
||||
}
|
||||
|
||||
function getSunday(monday: Date): Date {
|
||||
const d = new Date(monday)
|
||||
d.setDate(d.getDate() + 6)
|
||||
return d
|
||||
}
|
||||
|
||||
function formatDate(date: Date): string {
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
function getWeekNumber(date: Date): { year: number; week: number } {
|
||||
const d = new Date(date)
|
||||
d.setHours(0, 0, 0, 0)
|
||||
d.setDate(d.getDate() + 3 - (d.getDay() + 6) % 7)
|
||||
const week1 = new Date(d.getFullYear(), 0, 4)
|
||||
const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7)
|
||||
return { year: d.getFullYear(), week: weekNum }
|
||||
}
|
||||
|
||||
function setWeekDates(monday: Date) {
|
||||
const sunday = getSunday(monday)
|
||||
const weekInfo = getWeekNumber(monday)
|
||||
|
||||
form.value.weekStartDate = formatDate(monday)
|
||||
form.value.weekEndDate = formatDate(sunday)
|
||||
form.value.reportYear = weekInfo.year
|
||||
form.value.reportWeek = weekInfo.week
|
||||
// 주차 관련 함수들 (useWeekCalc 사용)
|
||||
function setWeekFromInfo(info: { year: number; week: number; startDateStr: string; endDateStr: string }) {
|
||||
form.value.reportYear = info.year
|
||||
form.value.reportWeek = info.week
|
||||
form.value.weekStartDate = info.startDateStr
|
||||
form.value.weekEndDate = info.endDateStr
|
||||
}
|
||||
|
||||
function changeWeek(delta: number) {
|
||||
const currentMonday = new Date(form.value.weekStartDate)
|
||||
currentMonday.setDate(currentMonday.getDate() + (delta * 7))
|
||||
setWeekDates(currentMonday)
|
||||
const { year, week } = calcChangeWeek(form.value.reportYear, form.value.reportWeek, delta)
|
||||
const weekInfo = getWeekDates(year, week)
|
||||
setWeekFromInfo(weekInfo)
|
||||
}
|
||||
|
||||
function setLastWeek() {
|
||||
const today = new Date()
|
||||
const lastWeekMonday = getMonday(today)
|
||||
lastWeekMonday.setDate(lastWeekMonday.getDate() - 7)
|
||||
setWeekDates(lastWeekMonday)
|
||||
const lastWeek = getLastWeekInfo()
|
||||
setWeekFromInfo(lastWeek)
|
||||
}
|
||||
|
||||
function setThisWeek() {
|
||||
const today = new Date()
|
||||
const thisWeekMonday = getMonday(today)
|
||||
setWeekDates(thisWeekMonday)
|
||||
const thisWeek = getActualCurrentWeekInfo()
|
||||
setWeekFromInfo(thisWeek)
|
||||
}
|
||||
|
||||
async function setDefaultWeek(userId: number) {
|
||||
// 쿼리 파라미터가 있으면 해당 주차로 설정
|
||||
if (route.query.year && route.query.week) {
|
||||
const year = parseInt(route.query.year as string)
|
||||
const week = parseInt(route.query.week as string)
|
||||
const weekInfo = getWeekDates(year, week)
|
||||
setWeekFromInfo(weekInfo)
|
||||
return
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const dayOfWeek = now.getDay() // 0=일, 1=월, ...
|
||||
const hour = now.getHours()
|
||||
|
||||
// 기본값: 이번 주
|
||||
const thisWeek = getActualCurrentWeekInfo()
|
||||
setWeekFromInfo(thisWeek)
|
||||
|
||||
// 월요일 9시 전인 경우, 지난주 보고서 확인
|
||||
if (dayOfWeek === 1 && hour < 9) {
|
||||
const lastWeek = getLastWeekInfo()
|
||||
|
||||
try {
|
||||
// 현재 사용자의 지난주 보고서가 있는지 확인
|
||||
const res = await $fetch<any>(`/api/report/weekly/list?year=${lastWeek.year}&week=${lastWeek.week}&authorId=${userId}`)
|
||||
const hasLastWeekReport = res.reports && res.reports.length > 0
|
||||
|
||||
// 지난주 보고서가 없으면 지난주로 설정
|
||||
if (!hasLastWeekReport) {
|
||||
setWeekFromInfo(lastWeek)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('지난주 보고서 확인 실패:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 지난주 계획을 이번주 실적에 로드
|
||||
async function loadLastWeekPlan(userId: number) {
|
||||
try {
|
||||
// 작성하려는 주차
|
||||
const targetYear = form.value.reportYear
|
||||
const targetWeek = form.value.reportWeek
|
||||
|
||||
// 직전 주차 계산
|
||||
const prevWeek = calcChangeWeek(targetYear, targetWeek, -1)
|
||||
|
||||
// 작성하려는 주차 이전의 보고서만 조회 (최신순, 1건)
|
||||
const res = await $fetch<any>(`/api/report/weekly/list?authorId=${userId}&beforeYear=${targetYear}&beforeWeek=${targetWeek}&limit=1`)
|
||||
|
||||
if (!res.reports || res.reports.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const recent = res.reports[0]
|
||||
|
||||
if (recent.reportYear === prevWeek.year && recent.reportWeek === prevWeek.week) {
|
||||
// 직전 주차면 → 자동 로드
|
||||
await loadPlanFromReport(recent.reportId)
|
||||
} else {
|
||||
// 직전 주차가 아니면 → 모달로 물어봄
|
||||
recentReportInfo.value = {
|
||||
reportId: recent.reportId,
|
||||
reportYear: recent.reportYear,
|
||||
reportWeek: recent.reportWeek
|
||||
}
|
||||
showLoadConfirmModal.value = true
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('이전 계획 로드 실패:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 보고서에서 계획 로드 (공통 함수)
|
||||
async function loadPlanFromReport(reportId: number) {
|
||||
try {
|
||||
const detail = await $fetch<any>(`/api/report/weekly/${reportId}/detail`)
|
||||
|
||||
if (!detail.projects || detail.projects.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// 모든 프로젝트의 planTasks를 WORK로 변환
|
||||
const tasks: TaskItem[] = []
|
||||
for (const proj of detail.projects) {
|
||||
if (proj.planTasks && proj.planTasks.length > 0) {
|
||||
for (const t of proj.planTasks) {
|
||||
tasks.push({
|
||||
projectId: proj.projectId,
|
||||
projectCode: proj.projectCode || '',
|
||||
projectName: proj.projectName || '',
|
||||
taskType: 'WORK',
|
||||
description: t.description || '',
|
||||
hours: t.hours || 0,
|
||||
isCompleted: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tasks.length > 0) {
|
||||
form.value.tasks = tasks
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('계획 로드 실패:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 모달에서 확인 클릭 시
|
||||
async function confirmLoadRecentPlan() {
|
||||
if (recentReportInfo.value) {
|
||||
await loadPlanFromReport(recentReportInfo.value.reportId)
|
||||
}
|
||||
showLoadConfirmModal.value = false
|
||||
}
|
||||
|
||||
function updateWeekFromDate() {
|
||||
const startDate = new Date(form.value.weekStartDate)
|
||||
const monday = getMonday(startDate)
|
||||
setWeekDates(monday)
|
||||
const weekInfo = getWeekInfo(startDate)
|
||||
setWeekFromInfo(weekInfo)
|
||||
}
|
||||
|
||||
// Task 관련 함수들
|
||||
@@ -464,10 +875,281 @@ async function handleSubmit() {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// === textarea 자동 높이 조절 ===
|
||||
function autoResize(e: Event) {
|
||||
const textarea = e.target as HTMLTextAreaElement
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
}
|
||||
|
||||
// 초기 로드 시 모든 textarea 높이 조절
|
||||
function initAutoResize() {
|
||||
nextTick(() => {
|
||||
document.querySelectorAll('textarea.auto-resize').forEach((el) => {
|
||||
const textarea = el as HTMLTextAreaElement
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = textarea.scrollHeight + 'px'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// === AI 자동채우기 관련 ===
|
||||
function closeAiModal() {
|
||||
showAiModal.value = false
|
||||
aiStep.value = 'input'
|
||||
aiRawText.value = ''
|
||||
aiUploadedImages.value = []
|
||||
aiParsedResult.value = null
|
||||
}
|
||||
|
||||
function handleAiDrop(e: DragEvent) {
|
||||
isDragging.value = false
|
||||
const files = e.dataTransfer?.files
|
||||
if (files) processAiFiles(Array.from(files))
|
||||
}
|
||||
|
||||
function handleAiPaste(e: ClipboardEvent) {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
|
||||
const imageFiles: File[] = []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
if (items[i].type.startsWith('image/')) {
|
||||
const file = items[i].getAsFile()
|
||||
if (file) imageFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
if (imageFiles.length > 0) {
|
||||
e.preventDefault()
|
||||
processAiFiles(imageFiles)
|
||||
}
|
||||
}
|
||||
|
||||
function handleAiFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
if (input.files) processAiFiles(Array.from(input.files))
|
||||
}
|
||||
|
||||
function processAiFiles(files: File[]) {
|
||||
const maxFiles = 10 - aiUploadedImages.value.length
|
||||
const toProcess = files.slice(0, maxFiles)
|
||||
|
||||
toProcess.forEach(file => {
|
||||
if (!file.type.startsWith('image/')) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
if (e.target?.result) {
|
||||
aiUploadedImages.value.push(e.target.result as string)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
async function runAiParse() {
|
||||
isAiParsing.value = true
|
||||
try {
|
||||
let res: any
|
||||
if (aiInputMode.value === 'text') {
|
||||
res = await $fetch<any>('/api/ai/parse-my-report', {
|
||||
method: 'POST',
|
||||
body: { rawText: aiRawText.value }
|
||||
})
|
||||
} else {
|
||||
res = await $fetch<any>('/api/ai/parse-my-report-image', {
|
||||
method: 'POST',
|
||||
body: { images: aiUploadedImages.value }
|
||||
})
|
||||
}
|
||||
|
||||
console.log('=== AI 분석 응답 ===')
|
||||
console.log(res)
|
||||
|
||||
// 파싱 결과를 임시 저장하고 매칭 단계로 이동
|
||||
if (res.parsed?.projects?.length > 0) {
|
||||
aiParsedResult.value = {
|
||||
projects: res.parsed.projects.map((p: any) => ({
|
||||
originalName: p.projectName || '알 수 없음',
|
||||
matchedProjectId: p.matchedProjectId || null,
|
||||
workTasks: p.workTasks || [],
|
||||
planTasks: p.planTasks || []
|
||||
})),
|
||||
issueDescription: res.parsed.issueDescription,
|
||||
vacationDescription: res.parsed.vacationDescription,
|
||||
remarkDescription: res.parsed.remarkDescription
|
||||
}
|
||||
aiStep.value = 'matching'
|
||||
} else {
|
||||
alert('분석된 내용이 없습니다.')
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('=== AI 분석 에러 ===', e)
|
||||
alert(e.data?.message || 'AI 분석에 실패했습니다.')
|
||||
} finally {
|
||||
isAiParsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭된 프로젝트가 있는지 확인
|
||||
const hasMatchedProjects = computed(() => {
|
||||
if (!aiParsedResult.value) return false
|
||||
return aiParsedResult.value.projects.some(p => p.matchedProjectId !== null)
|
||||
})
|
||||
|
||||
// 매칭 완료 후 적용
|
||||
function applyAiResult() {
|
||||
if (!aiParsedResult.value) return
|
||||
|
||||
const parsed = aiParsedResult.value
|
||||
|
||||
// 프로젝트별 태스크 병합
|
||||
for (const proj of parsed.projects) {
|
||||
const projectId = proj.matchedProjectId
|
||||
if (!projectId) continue // 미선택은 제외
|
||||
|
||||
// 금주 실적 추가
|
||||
for (const task of proj.workTasks) {
|
||||
if (task.description?.trim()) {
|
||||
form.value.tasks.push({
|
||||
projectId,
|
||||
taskType: 'WORK',
|
||||
description: task.description,
|
||||
hours: task.hours || 0,
|
||||
isCompleted: task.isCompleted !== false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 차주 계획 추가
|
||||
for (const task of proj.planTasks) {
|
||||
if (task.description?.trim()) {
|
||||
form.value.tasks.push({
|
||||
projectId,
|
||||
taskType: 'PLAN',
|
||||
description: task.description,
|
||||
hours: task.hours || 0,
|
||||
isCompleted: false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공통사항 병합
|
||||
if (parsed.issueDescription) {
|
||||
form.value.issueDescription = form.value.issueDescription
|
||||
? form.value.issueDescription + '\n' + parsed.issueDescription
|
||||
: parsed.issueDescription
|
||||
}
|
||||
if (parsed.vacationDescription) {
|
||||
form.value.vacationDescription = form.value.vacationDescription
|
||||
? form.value.vacationDescription + '\n' + parsed.vacationDescription
|
||||
: parsed.vacationDescription
|
||||
}
|
||||
if (parsed.remarkDescription) {
|
||||
form.value.remarkDescription = form.value.remarkDescription
|
||||
? form.value.remarkDescription + '\n' + parsed.remarkDescription
|
||||
: parsed.remarkDescription
|
||||
}
|
||||
|
||||
closeAiModal()
|
||||
}
|
||||
|
||||
function mergeAiResult(parsed: any) {
|
||||
console.log('=== mergeAiResult 시작 ===')
|
||||
console.log('parsed:', parsed)
|
||||
console.log('allProjects:', allProjects.value)
|
||||
|
||||
// 프로젝트별 태스크 병합
|
||||
if (parsed.projects && Array.isArray(parsed.projects)) {
|
||||
for (const proj of parsed.projects) {
|
||||
console.log('처리 중인 프로젝트:', proj.projectName, 'matchedProjectId:', proj.matchedProjectId)
|
||||
|
||||
// 기존 프로젝트 찾기 (이름으로 매칭)
|
||||
const existingProject = allProjects.value.find(p =>
|
||||
p.projectName.toLowerCase().includes(proj.projectName?.toLowerCase()) ||
|
||||
proj.projectName?.toLowerCase().includes(p.projectName.toLowerCase())
|
||||
)
|
||||
|
||||
const projectId = proj.matchedProjectId || existingProject?.projectId
|
||||
|
||||
if (!projectId) {
|
||||
console.warn('매칭되는 프로젝트 없음:', proj.projectName)
|
||||
continue
|
||||
}
|
||||
|
||||
// 금주 실적 추가
|
||||
if (proj.workTasks && Array.isArray(proj.workTasks)) {
|
||||
for (const task of proj.workTasks) {
|
||||
if (task.description?.trim()) {
|
||||
form.value.tasks.push({
|
||||
projectId,
|
||||
taskType: 'WORK',
|
||||
description: task.description,
|
||||
hours: task.hours || 0,
|
||||
isCompleted: task.isCompleted !== false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 차주 계획 추가
|
||||
if (proj.planTasks && Array.isArray(proj.planTasks)) {
|
||||
for (const task of proj.planTasks) {
|
||||
if (task.description?.trim()) {
|
||||
form.value.tasks.push({
|
||||
projectId,
|
||||
taskType: 'PLAN',
|
||||
description: task.description,
|
||||
hours: task.hours || 0,
|
||||
isCompleted: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 공통사항 병합 (기존 내용 + 새 내용)
|
||||
if (parsed.issueDescription) {
|
||||
form.value.issueDescription = form.value.issueDescription
|
||||
? form.value.issueDescription + '\n' + parsed.issueDescription
|
||||
: parsed.issueDescription
|
||||
}
|
||||
if (parsed.vacationDescription) {
|
||||
form.value.vacationDescription = form.value.vacationDescription
|
||||
? form.value.vacationDescription + '\n' + parsed.vacationDescription
|
||||
: parsed.vacationDescription
|
||||
}
|
||||
if (parsed.remarkDescription) {
|
||||
form.value.remarkDescription = form.value.remarkDescription
|
||||
? form.value.remarkDescription + '\n' + parsed.remarkDescription
|
||||
: parsed.remarkDescription
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal.show {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.upload-zone {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border-style: dashed !important;
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.upload-zone:hover {
|
||||
border-color: #0d6efd !important;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
textarea.auto-resize {
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
min-height: 32px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user