대시보드와 주간보고 기능 업데이트

This commit is contained in:
2026-01-10 14:40:01 +09:00
parent 0dd4b561f0
commit e4627caa4c
26 changed files with 3329 additions and 1720 deletions

1
.idea/vcs.xml generated
View File

@@ -2,6 +2,5 @@
<project version="4"> <project version="4">
<component name="VcsDirectoryMappings"> <component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" /> <mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component> </component>
</project> </project>

View File

@@ -46,10 +46,11 @@ weeklyreport/
│ ├── composables/ # Vue Composables │ ├── composables/ # Vue Composables
│ ├── report/ # 주간보고 관련 │ ├── report/ # 주간보고 관련
│ │ ├── weekly/ # 개인 주간보고 │ │ ├── weekly/ # 개인 주간보고
│ │ │ └── bulk-import.vue # AI 일괄 등록
│ │ └── summary/ # 취합 보고서 │ │ └── summary/ # 취합 보고서
│ ├── employee/ # 직원 관리 │ ├── employee/ # 직원 관리
│ ├── project/ # 프로젝트 관리 │ ├── project/ # 프로젝트 관리
│ └── admin/ # 관리자 기능 │ └── mypage/ # 마이페이지
├── backend/ ├── backend/
│ ├── api/ # API 엔드포인트 │ ├── api/ # API 엔드포인트
│ │ ├── auth/ # 인증 │ │ ├── auth/ # 인증
@@ -61,6 +62,29 @@ weeklyreport/
└── package.json └── package.json
``` ```
## 🔗 메뉴 구조
| 메뉴 | 경로 | 설명 | 권한 |
|------|------|------|------|
| 로그인 | `/login` | 로그인 페이지 | 전체 |
| 홈 | `/` | 대시보드 | 로그인 |
| **주간보고** | | | |
| ├ 목록 | `/report/weekly` | 주간보고 목록 | 로그인 |
| ├ 작성 | `/report/weekly/write` | 주간보고 작성 | 로그인 |
| ├ 일괄작성 | `/report/weekly/bulk-import` | AI 기반 일괄 등록 | 로그인 |
| ├ 상세 | `/report/weekly/[id]` | 주간보고 상세보기 | 로그인 |
| └ 취합현황 | `/report/weekly/aggregate` | 주차별 취합 현황 | 로그인 |
| **취합보고서** | | | |
| ├ 목록 | `/report/summary` | 취합 보고서 목록 | 관리자 |
| └ 상세 | `/report/summary/[id]` | 취합 보고서 상세 | 관리자 |
| **관리** | | | |
| ├ 직원 목록 | `/employee` | 직원 관리 | 관리자 |
| ├ 직원 상세 | `/employee/[id]` | 직원 정보 상세 | 관리자 |
| ├ 프로젝트 목록 | `/project` | 프로젝트 관리 | 관리자 |
| └ 프로젝트 상세 | `/project/[id]` | 프로젝트 정보 상세 | 관리자 |
| **마이페이지** | `/mypage` | 내 정보 | 로그인 |
| **피드백** | `/feedback` | 피드백 | 로그인 |
## 🗄 데이터베이스 스키마 ## 🗄 데이터베이스 스키마
### 주요 테이블 ### 주요 테이블

View File

@@ -0,0 +1,133 @@
import { query } from '../../utils/db'
import { callOpenAIVision } from '../../utils/openai'
/**
* 개인 주간보고 이미지 분석 (OpenAI Vision)
* POST /api/ai/parse-my-report-image
*/
export default defineEventHandler(async (event) => {
const userId = getCookie(event, 'user_id')
if (!userId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
const body = await readBody<{ images: string[] }>(event)
if (!body.images || body.images.length === 0) {
throw createError({ statusCode: 400, message: '분석할 이미지를 업로드해주세요.' })
}
// 기존 프로젝트 목록 조회
const projects = await query<any>(`
SELECT project_id, project_code, project_name
FROM wr_project_info
WHERE project_status = 'IN_PROGRESS'
`)
// 프로젝트 목록을 ID 포함해서 전달
const projectList = projects.map(p => `[ID:${p.project_id}] ${p.project_code}: ${p.project_name}`).join('\n')
// OpenAI Vision 분석
const prompt = buildImagePrompt(projectList)
console.log('=== AI 이미지 분석 시작 ===')
console.log('이미지 개수:', body.images.length)
console.log('프로젝트 목록:', projectList)
const aiResponse = await callOpenAIVision(prompt, body.images)
console.log('=== AI 응답 (raw) ===')
console.log(aiResponse)
let parsed: any
try {
parsed = JSON.parse(aiResponse)
console.log('=== AI 응답 (parsed) ===')
console.log(JSON.stringify(parsed, null, 2))
} catch (e) {
console.error('=== JSON 파싱 실패 ===')
console.error(e)
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
}
// 프로젝트 매칭
if (parsed.projects) {
for (const proj of parsed.projects) {
if (!proj.matchedProjectId && proj.projectName) {
const matched = projects.find((p: any) =>
p.project_name.toLowerCase().includes(proj.projectName.toLowerCase()) ||
proj.projectName.toLowerCase().includes(p.project_name.toLowerCase()) ||
p.project_code.toLowerCase() === proj.projectName.toLowerCase()
)
if (matched) {
proj.matchedProjectId = matched.project_id
proj.projectName = matched.project_name
}
}
proj.workTasks = (proj.workTasks || []).map((t: any) => ({
description: t.description || '',
hours: t.hours || 0,
isCompleted: t.isCompleted !== false
}))
proj.planTasks = (proj.planTasks || []).map((t: any) => ({
description: t.description || '',
hours: t.hours || 0
}))
}
// 내용 없는 프로젝트 제외 (workTasks, planTasks 모두 비어있으면 제외)
parsed.projects = parsed.projects.filter((proj: any) =>
(proj.workTasks && proj.workTasks.length > 0) ||
(proj.planTasks && proj.planTasks.length > 0)
)
}
return {
success: true,
parsed,
projects
}
})
function buildImagePrompt(projectList: string): string {
return `당신은 주간보고 내용을 분석하는 AI입니다.
이미지에서 주간보고 내용을 추출하여 JSON으로 반환해주세요.
이미지에 여러 사람의 내용이 있어도 모두 추출하여 하나의 보고서로 통합해주세요.
현재 등록된 프로젝트 목록 (형식: [ID:숫자] 코드: 이름):
${projectList}
⚠️ 중요: 이미지에서 추출한 프로젝트명과 위 목록을 비교하여 가장 유사한 프로젝트의 ID를 matchedProjectId에 반환하세요.
- 유사도 판단: 키워드 일치, 약어, 부분 문자열 등 고려
- 예: "한우 유전체" → "보은 한우 온라인 유전체 분석" 매칭 가능
- 예: "HEIS" → "보건환경연구원 HEIS" 매칭 가능
- 매칭되는 프로젝트가 없으면 matchedProjectId는 null
응답은 반드시 아래 JSON 형식으로만 출력하세요:
{
"projects": [
{
"projectName": "이미지에서 추출한 원본 프로젝트명",
"matchedProjectId": 5,
"workTasks": [
{"description": "작업내용", "hours": 8, "isCompleted": true}
],
"planTasks": [
{"description": "계획내용", "hours": 8}
]
}
],
"issueDescription": "이슈/리스크 내용 또는 null",
"vacationDescription": "휴가일정 내용 또는 null",
"remarkDescription": "기타사항 내용 또는 null"
}
규칙:
1. 이미지에서 모든 주간보고 내용을 추출
2. projectName은 이미지에서 추출한 원본 텍스트 그대로
3. matchedProjectId는 위 프로젝트 목록에서 가장 유사한 프로젝트의 ID (숫자)
4. "금주 실적", "이번주", "완료" 등은 workTasks로 분류
5. "차주 계획", "다음주", "예정" 등은 planTasks로 분류
6. 시간이 명시되지 않은 경우 hours는 0으로
7. JSON 외의 텍스트는 절대 출력하지 마세요`
}

View File

@@ -0,0 +1,152 @@
import { query } from '../../utils/db'
import { callOpenAI } from '../../utils/openai'
interface ParsedTask {
description: string
hours: number
isCompleted?: boolean
}
interface ParsedProject {
projectName: string
matchedProjectId: number | null
workTasks: ParsedTask[]
planTasks: ParsedTask[]
}
interface ParsedResult {
projects: ParsedProject[]
issueDescription: string | null
vacationDescription: string | null
remarkDescription: string | null
}
/**
* 개인 주간보고 텍스트 분석 (OpenAI)
* POST /api/ai/parse-my-report
*/
export default defineEventHandler(async (event) => {
const userId = getCookie(event, 'user_id')
if (!userId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
const body = await readBody<{ rawText: string }>(event)
if (!body.rawText || body.rawText.trim().length < 5) {
throw createError({ statusCode: 400, message: '분석할 텍스트를 입력해주세요.' })
}
// 기존 프로젝트 목록 조회
const projects = await query<any>(`
SELECT project_id, project_code, project_name
FROM wr_project_info
WHERE project_status = 'IN_PROGRESS'
`)
// 프로젝트 목록을 ID 포함해서 전달
const projectList = projects.map(p => `[ID:${p.project_id}] ${p.project_code}: ${p.project_name}`).join('\n')
// OpenAI 분석
const prompt = buildMyReportPrompt(body.rawText, projectList)
const aiResponse = await callOpenAI(prompt, true)
let parsed: ParsedResult
try {
parsed = JSON.parse(aiResponse)
} catch (e) {
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
}
// 프로젝트 매칭
if (parsed.projects) {
for (const proj of parsed.projects) {
if (!proj.matchedProjectId && proj.projectName) {
const matched = projects.find((p: any) =>
p.project_name.toLowerCase().includes(proj.projectName.toLowerCase()) ||
proj.projectName.toLowerCase().includes(p.project_name.toLowerCase()) ||
p.project_code.toLowerCase() === proj.projectName.toLowerCase()
)
if (matched) {
proj.matchedProjectId = matched.project_id
proj.projectName = matched.project_name
}
}
// workTasks 기본값
proj.workTasks = (proj.workTasks || []).map((t: any) => ({
description: t.description || '',
hours: t.hours || 0,
isCompleted: t.isCompleted !== false
}))
// planTasks 기본값
proj.planTasks = (proj.planTasks || []).map((t: any) => ({
description: t.description || '',
hours: t.hours || 0
}))
}
// 내용 없는 프로젝트 제외 (workTasks, planTasks 모두 비어있으면 제외)
parsed.projects = parsed.projects.filter((proj: any) =>
(proj.workTasks && proj.workTasks.length > 0) ||
(proj.planTasks && proj.planTasks.length > 0)
)
}
return {
success: true,
parsed,
projects
}
})
function buildMyReportPrompt(rawText: string, projectList: string): any[] {
return [
{
role: 'system',
content: `당신은 주간보고 내용을 분석하는 AI입니다.
사용자가 입력한 텍스트를 분석하여 프로젝트별 업무 내용을 추출해주세요.
현재 등록된 프로젝트 목록 (형식: [ID:숫자] 코드: 이름):
${projectList}
⚠️ 중요: 입력 텍스트에서 추출한 프로젝트명과 위 목록을 비교하여 가장 유사한 프로젝트의 ID를 matchedProjectId에 반환하세요.
- 유사도 판단: 키워드 일치, 약어, 부분 문자열 등 고려
- 예: "한우 유전체" → "보은 한우 온라인 유전체 분석" 매칭 가능
- 예: "HEIS" → "보건환경연구원 HEIS" 매칭 가능
- 매칭되는 프로젝트가 없으면 matchedProjectId는 null
응답은 반드시 아래 JSON 형식으로만 출력하세요:
{
"projects": [
{
"projectName": "입력에서 추출한 원본 프로젝트명",
"matchedProjectId": 5,
"workTasks": [
{"description": "작업내용", "hours": 8, "isCompleted": true}
],
"planTasks": [
{"description": "계획내용", "hours": 8}
]
}
],
"issueDescription": "이슈/리스크 내용 또는 null",
"vacationDescription": "휴가일정 내용 또는 null",
"remarkDescription": "기타사항 내용 또는 null"
}
규칙:
1. projectName은 입력 텍스트에서 추출한 원본 그대로
2. matchedProjectId는 위 프로젝트 목록에서 가장 유사한 프로젝트의 ID (숫자)
3. "금주 실적", "이번주", "완료" 등은 workTasks로 분류
4. "차주 계획", "다음주", "예정" 등은 planTasks로 분류
5. 시간이 명시되지 않은 경우 hours는 0으로
6. JSON 외의 텍스트는 절대 출력하지 마세요`
},
{
role: 'user',
content: rawText
}
]
}

View File

@@ -1,26 +1,48 @@
import { queryOne } from '../../utils/db' import { getSession, refreshSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
/** /**
* 현재 로그인 사용자 정보 * 현재 로그인 사용자 정보
* GET /api/auth/current-user * GET /api/auth/current-user
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = getCookie(event, 'user_id') const sessionId = getSessionIdFromCookie(event)
if (!userId) { if (!sessionId) {
return { user: null } return { user: null }
} }
// DB에서 세션 조회
const session = await getSession(sessionId)
if (!session) {
// 세션이 만료되었거나 없음 → 쿠키 삭제
deleteSessionCookie(event)
return { user: null }
}
// 사용자 정보 조회
const employee = await queryOne<any>(` const employee = await queryOne<any>(`
SELECT * FROM wr_employee_info SELECT * FROM wr_employee_info
WHERE employee_id = $1 AND is_active = true WHERE employee_id = $1 AND is_active = true
`, [parseInt(userId)]) `, [session.employeeId])
if (!employee) { if (!employee) {
deleteCookie(event, 'user_id') deleteSessionCookie(event)
return { user: null } return { user: null }
} }
// 세션 갱신 (Sliding Expiration - 10분 연장)
await refreshSession(sessionId)
// 로그인 이력의 last_active_at도 업데이트
if (session.loginHistoryId) {
await execute(`
UPDATE wr_login_history
SET last_active_at = NOW()
WHERE history_id = $1
`, [session.loginHistoryId])
}
return { return {
user: { user: {
employeeId: employee.employee_id, employeeId: employee.employee_id,

View File

@@ -1,17 +1,32 @@
import { query } from '../../utils/db' import { getSession, getSessionIdFromCookie, deleteSessionCookie, SESSION_TIMEOUT_MINUTES } from '../../utils/session'
/** /**
* 본인 로그인 이력 조회 * 본인 로그인 이력 조회
* GET /api/auth/login-history * GET /api/auth/login-history
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = getCookie(event, 'user_id') const sessionId = getSessionIdFromCookie(event)
const currentHistoryId = getCookie(event, 'login_history_id')
if (!userId) { if (!sessionId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
} }
const session = await getSession(sessionId)
if (!session) {
deleteSessionCookie(event)
throw createError({ statusCode: 401, message: '세션이 만료되었습니다.' })
}
// 현재 활성 세션 ID 목록 조회
const activeSessions = await query<any>(`
SELECT login_history_id FROM wr_session
WHERE employee_id = $1 AND expires_at > NOW()
`, [session.employeeId])
const activeHistoryIds = new Set(activeSessions.map(s => s.login_history_id))
// 로그인 이력 조회
const history = await query<any>(` const history = await query<any>(`
SELECT SELECT
history_id, history_id,
@@ -24,17 +39,37 @@ export default defineEventHandler(async (event) => {
WHERE employee_id = $1 WHERE employee_id = $1
ORDER BY login_at DESC ORDER BY login_at DESC
LIMIT 50 LIMIT 50
`, [userId]) `, [session.employeeId])
const SESSION_TIMEOUT_MS = SESSION_TIMEOUT_MINUTES * 60 * 1000
const now = Date.now()
return {
history: history.map(h => {
const isCurrentSession = h.history_id === session.loginHistoryId
const isActiveSession = activeHistoryIds.has(h.history_id)
// 세션 상태 판단
let sessionStatus: 'active' | 'logout' | 'expired'
if (h.logout_at) {
sessionStatus = 'logout'
} else if (isActiveSession) {
sessionStatus = 'active'
} else {
// 활성 세션에 없으면 만료
sessionStatus = 'expired'
}
return { return {
history: history.map(h => ({
historyId: h.history_id, historyId: h.history_id,
loginAt: h.login_at, loginAt: h.login_at,
loginIp: h.login_ip, loginIp: h.login_ip,
logoutAt: h.logout_at, logoutAt: h.logout_at,
logoutIp: h.logout_ip, logoutIp: h.logout_ip,
lastActiveAt: h.last_active_at, lastActiveAt: h.last_active_at,
isCurrentSession: currentHistoryId && h.history_id === parseInt(currentHistoryId) isCurrentSession,
})) sessionStatus
}
})
} }
}) })

View File

@@ -1,5 +1,5 @@
import { query, insertReturning, execute } from '../../utils/db'
import { getClientIp } from '../../utils/ip' import { getClientIp } from '../../utils/ip'
import { createSession, setSessionCookie } from '../../utils/session'
interface LoginBody { interface LoginBody {
email: string email: string
@@ -7,12 +7,13 @@ interface LoginBody {
} }
/** /**
* 이메일+이름 로그인 (임시) * 이메일+이름 로그인
* POST /api/auth/login * POST /api/auth/login
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody<LoginBody>(event) const body = await readBody<LoginBody>(event)
const clientIp = getClientIp(event) const clientIp = getClientIp(event)
const userAgent = getHeader(event, 'user-agent') || null
if (!body.email || !body.name) { if (!body.email || !body.name) {
throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' }) throw createError({ statusCode: 400, message: '이메일과 이름을 입력해주세요.' })
@@ -60,18 +61,16 @@ export default defineEventHandler(async (event) => {
RETURNING history_id RETURNING history_id
`, [employeeData.employee_id, clientIp, emailLower]) `, [employeeData.employee_id, clientIp, emailLower])
// 쿠키에 사용자 정보 저장 // DB 기반 세션 생성
setCookie(event, 'user_id', String(employeeData.employee_id), { const sessionId = await createSession(
httpOnly: true, employeeData.employee_id,
maxAge: 60 * 60 * 24 * 7, loginHistory.history_id,
path: '/' clientIp,
}) userAgent
)
setCookie(event, 'login_history_id', String(loginHistory.history_id), { // 세션 쿠키 설정
httpOnly: true, setSessionCookie(event, sessionId)
maxAge: 60 * 60 * 24 * 7,
path: '/'
})
return { return {
success: true, success: true,

View File

@@ -1,26 +1,33 @@
import { execute } from '../../utils/db'
import { getClientIp } from '../../utils/ip' import { getClientIp } from '../../utils/ip'
import { getSession, deleteSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
/** /**
* 로그아웃 * 로그아웃
* POST /api/auth/logout * POST /api/auth/logout
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const historyId = getCookie(event, 'login_history_id') const sessionId = getSessionIdFromCookie(event)
const clientIp = getClientIp(event) const clientIp = getClientIp(event)
if (sessionId) {
// 세션 정보 조회
const session = await getSession(sessionId)
// 로그아웃 이력 기록 // 로그아웃 이력 기록
if (historyId) { if (session?.loginHistoryId) {
await execute(` await execute(`
UPDATE wr_login_history UPDATE wr_login_history
SET logout_at = NOW(), logout_ip = $1 SET logout_at = NOW(), logout_ip = $1
WHERE history_id = $2 WHERE history_id = $2
`, [clientIp, historyId]) `, [clientIp, session.loginHistoryId])
} }
// 쿠키 삭제 // DB에서 세션 삭제
deleteCookie(event, 'user_id') await deleteSession(sessionId)
deleteCookie(event, 'login_history_id') }
// 세션 쿠키 삭제
deleteSessionCookie(event)
return { success: true } return { success: true }
}) })

View File

@@ -1,15 +1,24 @@
import { queryOne } from '../../utils/db' import { getSession, getSessionIdFromCookie, deleteSessionCookie } from '../../utils/session'
/** /**
* 로그인된 사용자 정보 조회 * 로그인된 사용자 상세 정보 조회
* GET /api/auth/me * GET /api/auth/me
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const userId = getCookie(event, 'user_id') const sessionId = getSessionIdFromCookie(event)
if (!userId) {
if (!sessionId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
} }
// DB에서 세션 조회
const session = await getSession(sessionId)
if (!session) {
deleteSessionCookie(event)
throw createError({ statusCode: 401, message: '세션이 만료되었습니다. 다시 로그인해주세요.' })
}
const employee = await queryOne<any>(` const employee = await queryOne<any>(`
SELECT SELECT
employee_id, employee_id,
@@ -22,7 +31,7 @@ export default defineEventHandler(async (event) => {
is_active is_active
FROM wr_employee_info FROM wr_employee_info
WHERE employee_id = $1 WHERE employee_id = $1
`, [userId]) `, [session.employeeId])
if (!employee) { if (!employee) {
throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' }) throw createError({ statusCode: 404, message: '사용자를 찾을 수 없습니다.' })

View File

@@ -1,4 +1,5 @@
import { queryOne, execute } from '../../utils/db' import { getClientIp } from '../../utils/ip'
import { createSession, setSessionCookie } from '../../utils/session'
interface SelectUserBody { interface SelectUserBody {
employeeId: number employeeId: number
@@ -10,6 +11,8 @@ interface SelectUserBody {
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const body = await readBody<SelectUserBody>(event) const body = await readBody<SelectUserBody>(event)
const clientIp = getClientIp(event)
const userAgent = getHeader(event, 'user-agent') || null
if (!body.employeeId) { if (!body.employeeId) {
throw createError({ statusCode: 400, message: '사용자를 선택해주세요.' }) throw createError({ statusCode: 400, message: '사용자를 선택해주세요.' })
@@ -26,16 +29,22 @@ export default defineEventHandler(async (event) => {
} }
// 로그인 이력 추가 // 로그인 이력 추가
await execute(` const loginHistory = await insertReturning(`
INSERT INTO wr_login_history (employee_id) VALUES ($1) INSERT INTO wr_login_history (employee_id, login_ip, login_email)
`, [employee.employee_id]) VALUES ($1, $2, $3)
RETURNING history_id
`, [employee.employee_id, clientIp, employee.employee_email])
// 쿠키 설정 // DB 기반 세션 생성
setCookie(event, 'user_id', String(employee.employee_id), { const sessionId = await createSession(
httpOnly: true, employee.employee_id,
maxAge: 60 * 60 * 24 * 7, loginHistory.history_id,
path: '/' clientIp,
}) userAgent
)
// 세션 쿠키 설정
setSessionCookie(event, sessionId)
return { return {
success: true, success: true,

View File

@@ -54,14 +54,15 @@ export default defineEventHandler(async (event) => {
JOIN wr_weekly_report r ON t.report_id = r.report_id JOIN wr_weekly_report r ON t.report_id = r.report_id
AND r.report_year = $1 AND r.report_week = $2 AND r.report_year = $1 AND r.report_week = $2
JOIN wr_employee_info e ON r.author_id = e.employee_id JOIN wr_employee_info e ON r.author_id = e.employee_id
WHERE p.project_status = 'IN_PROGRESS'
GROUP BY p.project_id, p.project_code, p.project_name GROUP BY p.project_id, p.project_code, p.project_name
ORDER BY work_hours DESC ORDER BY work_hours DESC
`, [year, week]) `, [year, week])
// 3. 전체 요약 // 3. 전체 요약
const activeEmployees = employeeStats.length const activeEmployees = employeeStats.length
const submittedCount = employeeStats.filter((e: any) => e.report_id).length const submittedCount = employeeStats.filter((e: any) =>
e.report_status === 'SUBMITTED' || e.report_status === 'AGGREGATED'
).length
const totalWorkHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.work_hours || 0), 0) const totalWorkHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.work_hours || 0), 0)
const totalPlanHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.plan_hours || 0), 0) const totalPlanHours = employeeStats.reduce((sum: number, e: any) => sum + parseFloat(e.plan_hours || 0), 0)
@@ -86,7 +87,7 @@ export default defineEventHandler(async (event) => {
planHours: parseFloat(e.plan_hours) || 0, planHours: parseFloat(e.plan_hours) || 0,
workProjectCount: parseInt(e.work_project_count) || 0, workProjectCount: parseInt(e.work_project_count) || 0,
planProjectCount: parseInt(e.plan_project_count) || 0, planProjectCount: parseInt(e.plan_project_count) || 0,
isSubmitted: !!e.report_id isSubmitted: e.report_status === 'SUBMITTED' || e.report_status === 'AGGREGATED'
})), })),
projects: projectStats.map((p: any) => ({ projects: projectStats.map((p: any) => ({
projectId: p.project_id, projectId: p.project_id,

View File

@@ -6,8 +6,32 @@ const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY apiKey: process.env.OPENAI_API_KEY
}) })
// 문자열 해시 함수 (seed용)
function hashCode(str: string): number {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash // 32bit 정수로 변환
}
return Math.abs(hash)
}
interface QualityScore {
summary: string // 총평 (맨 위)
specificity: { score: number; improvement: string } // 구체성
completeness: { score: number; improvement: string } // 완결성
timeEstimation: { score: number; improvement: string } // 시간산정
planning: { score: number; improvement: string } // 계획성
overall: number // 종합점수
bestPractice: { // 모범 답안
workTasks: string[] // 금주 실적 모범 답안
planTasks: string[] // 차주 계획 모범 답안
}
}
/** /**
* 주간보고 PMO AI 리뷰 * 주간보고 PMO AI 리뷰 - 작성 품질 점수 + 모범 답안
* POST /api/report/review * POST /api/report/review
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@@ -77,61 +101,75 @@ export default defineEventHandler(async (event) => {
}) })
} }
// OpenAI PMO 리뷰 요청 // OpenAI 품질 점수 + 모범 답안 요청
const systemPrompt = `당신은 SI 프로젝트의 PMO(Project Management Officer)이자 주간보고 작성 코치입니다. const systemPrompt = `당신은 SI 프로젝트의 PMO(Project Management Officer)입니다.
개발자들이 더 나은 주간보고 작성할 수 있도록 구체적인 피드백과 가이드를해주세요. 주간보고 작성 품질을 평가하고, 모범 답안을해주세요.
[주간보고 작성의 목적] [평가 항목] (각 1~10점)
- 프로젝트 진행 현황을 명확히 파악 1. 구체성 (specificity): 작업 내용이 어떤 기능/모듈인지 구체적으로 작성되었는지
- 일정 지연이나 리스크를 사전에 감지 2. 완결성 (completeness): 필수 정보 포함 여부
- 팀원 간 업무 공유 및 협업 촉진 3. 시간산정 (timeEstimation): 작업 시간이 내용 대비 적절하게 배분되었는지
4. 계획성 (planning): 차주 계획이 실현 가능하고 명확한 목표가 있는지
[검토 기준 - 엄격하게 적용] [완결성 상세 기준] - 엄격하게 적용
- 진행중 작업에 진척률(%)이 없으면 -2점
- 진행중 작업에 완료 예정일이 없으면 -2점
- 완료 작업인데 산출물/결과 언급이 없으면 -1점
- 상태(완료/진행중)가 명확하지 않으면 -1점
1. **실적의 구체성** (가장 중요!) [계획성 상세 기준] - 엄격하게 적용
- "DB 작업", "화면 개발", "API 개발" 같은 모호한 표현 지양 - 차주 계획에 예상 소요시간 근거가 없으면 -1점
- 좋은 예시: "사용자 관리 테이블 3개(user, role, permission) 설계 및 생성" - 차주 계획에 목표 완료일/산출물이 없으면 -2점
- 좋은 예시: "로그인 API 개발 - JWT 토큰 발급, 리프레시 토큰 구현" - 단순 "~할 예정", "~진행" 만 있고 구체적 목표가 없으면 -2점
- 좋은 예시: "검색 화면 UI 구현 - 필터 조건 5개, 페이징, 엑셀 다운로드" - 실현 가능성이 낮은 과도한 계획이면 -1점
- 어떤 기능/모듈/화면인지, 무엇을 구체적으로 했는지 명시되어야 함
2. **일정의 명확성** [점수 기준]
- "진행중"만 있고 완료 예정일이 없으면 부족 - 1~3점: 매우 부족 (내용이 거의 없거나 한 단어 수준)
- 언제 완료될 예정인지, 진척률은 얼마인지 표기 권장 - 4~5점: 부족 (진척률/예정일 누락, 모호한 표현)
- 좋은 예시: "사용자 관리 화면 개발 (70% 완료, 1/10 완료 예정)" - 6~7점: 보통 (기본 내용은 있으나 구체성 부족)
- 8~9점: 양호 (진척률, 예정일, 산출물 모두 명시)
- 10점: 우수 (완벽한 모범 사례)
3. **시간 산정의 적절성** ※ 진행중 작업에 진척률/예정일이 없으면 완결성은 6점 이하로 평가하세요.
- 8시간(1일) 이상 작업은 세부 내역이 필요 ※ 차주 계획에 구체적 목표가 없으면 계획성은 6점 이하로 평가하세요.
- 16시간(2일) 이상인데 내용이 한 줄이면 분리 필요
- "회의", "검토" 등은 별도 기재 권장
4. **차주 계획의 실현 가능성** [모범 답안 작성 규칙]
- 계획이 너무 추상적이면 실행하기 어려움 - 사용자가 작성한 내용을 기반으로 더 구체적으로 보완
- 구체적인 목표와 예상 산출물 명시 필요 - 같은 프로젝트명, 비슷한 작업 내용을 유지하되 구체성 추가
- 좋은 예시: "결제 모듈 연동 - PG사 API 연동, 결제 테스트 완료 목표" - 진행중인 작업은 반드시 진척률(%)과 완료 예정일 추가
- 시간이 긴 작업은 세부 내역 포함
- 차주 계획은 목표 산출물과 예상 완료일 명시
- 형식: "프로젝트명 / 작업내용 (세부사항, 진척률, 예정일) / 시간h / 상태"
[피드백 작성 규칙] [응답 규칙]
- 각 Task별로 구체적인 개선 제안 제시 - 반드시 아래 JSON 형식으로만 응답
- 잘 작성된 부분은 "✅" 로 인정 - summary: 전체적인 총평 (30~60자, 격려 포함)
- 보완이 필요한 부분은 "📝" 로 개선 방향 제시 - improvement: 각 항목별 개선 포인트 (15~30자, 구체적으로)
- 일정 관련 질문은 "📅" 로 표시 - bestPractice: 모범 답안 (workTasks, planTasks 배열)
- 리스크/우려사항은 "⚠️" 로 경고 - JSON 외의 텍스트는 절대 포함하지 마세요`
- **반드시 어떻게 수정하면 좋을지 예시를 들어 설명**
- 친절하지만 명확하게, 구체적인 작성 예시를 포함
- 마지막에 전체적인 작성 팁 1-2개 추가
[피드백 톤] const userPrompt = `다음 주간보고의 작성 품질을 평가하고, 모범 답안을 만들어주세요.
- 비난하지 않고 코칭하는 느낌으로
- "~하면 더 좋겠습니다", "~로 수정해보시면 어떨까요?" 형태로
- 개선점뿐 아니라 잘한 점도 언급`
const userPrompt = `다음 주간보고를 PMO 관점에서 상세히 리뷰해주세요. ${taskText}
특히 실적과 계획이 구체적으로 작성되었는지, 일정이 명확한지 중점적으로 검토해주세요.
모호한 표현이 있다면 어떻게 수정하면 좋을지 예시와 함께 피드백해주세요.
${taskText}` 아래 JSON 형식으로만 응답하세요:
{
"summary": "총평 (격려 포함)",
"specificity": { "score": 숫자, "improvement": "개선포인트" },
"completeness": { "score": 숫자, "improvement": "개선포인트" },
"timeEstimation": { "score": 숫자, "improvement": "개선포인트" },
"planning": { "score": 숫자, "improvement": "개선포인트" },
"overall": 종합점수(소수점1자리),
"bestPractice": {
"workTasks": ["모범답안1", "모범답안2", ...],
"planTasks": ["모범답안1", "모범답안2", ...]
}
}`
try { try {
// Task 내용 기반 seed 생성 (같은 내용 = 같은 점수)
const seed = hashCode(taskText)
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: 'gpt-4o-mini', model: 'gpt-4o-mini',
messages: [ messages: [
@@ -139,29 +177,52 @@ ${taskText}`
{ role: 'user', content: userPrompt } { role: 'user', content: userPrompt }
], ],
max_tokens: 1500, max_tokens: 1500,
temperature: 0.7 temperature: 0.2, // 낮춰서 일관성 강화
seed: seed // 같은 내용 = 같은 seed = 같은 결과
}) })
const review = response.choices[0]?.message?.content || '리뷰를 생성할 수 없습니다.' const content = response.choices[0]?.message?.content || ''
// JSON 파싱
let qualityScore: QualityScore
try {
// JSON 블록 추출 (```json ... ``` 형태 처리)
let jsonStr = content
const jsonMatch = content.match(/```json\s*([\s\S]*?)\s*```/)
if (jsonMatch) {
jsonStr = jsonMatch[1]
} else {
// { } 사이 추출
const braceMatch = content.match(/\{[\s\S]*\}/)
if (braceMatch) {
jsonStr = braceMatch[0]
}
}
qualityScore = JSON.parse(jsonStr)
} catch (parseError) {
console.error('JSON 파싱 실패:', content)
throw new Error('AI 응답을 파싱할 수 없습니다.')
}
const reviewedAt = new Date().toISOString() const reviewedAt = new Date().toISOString()
// DB에 저장 // DB에 저장 (ai_review에 JSON 문자열로 저장)
await query(` await query(`
UPDATE wr_weekly_report UPDATE wr_weekly_report
SET ai_review = $1, ai_review_at = $2 SET ai_review = $1, ai_review_at = $2
WHERE report_id = $3 WHERE report_id = $3
`, [review, reviewedAt, reportId]) `, [JSON.stringify(qualityScore), reviewedAt, reportId])
return { return {
success: true, success: true,
review, qualityScore,
reviewedAt reviewedAt
} }
} catch (error: any) { } catch (error: any) {
console.error('OpenAI API error:', error) console.error('OpenAI API error:', error)
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
message: 'AI 리뷰 생성 중 오류가 발생했습니다: ' + error.message message: 'AI 품질 평가 중 오류가 발생했습니다: ' + error.message
}) })
} }
}) })

View File

@@ -32,28 +32,16 @@ export default defineEventHandler(async (event) => {
const q = getQuery(event) const q = getQuery(event)
const limit = parseInt(q.limit as string) || 100 const limit = parseInt(q.limit as string) || 100
const viewAll = q.viewAll === 'true'
// 필터 조건 구성 // 필터 조건 구성
const conditions: string[] = [] const conditions: string[] = []
const params: any[] = [] const params: any[] = []
let paramIndex = 1 let paramIndex = 1
// 관리자가 viewAll이면 전체 조회, 아니면 본인 것만 // 작성자 필터 (선택된 경우에만 적용)
if (!isAdmin || !viewAll) {
// 작성자 필터 (본인 또는 지정된 작성자)
if (q.authorId) { if (q.authorId) {
conditions.push(`r.author_id = $${paramIndex++}`) conditions.push(`r.author_id = $${paramIndex++}`)
params.push(q.authorId) params.push(q.authorId)
} else if (!isAdmin) {
// 관리자가 아니면 본인 것만
conditions.push(`r.author_id = $${paramIndex++}`)
params.push(userId)
}
} else if (q.authorId) {
// 관리자가 viewAll이어도 작성자 필터가 있으면 적용
conditions.push(`r.author_id = $${paramIndex++}`)
params.push(q.authorId)
} }
// 프로젝트 필터 // 프로젝트 필터
@@ -77,6 +65,15 @@ export default defineEventHandler(async (event) => {
params.push(q.week) params.push(q.week)
} }
// 특정 주차 이전 필터 (beforeYear, beforeWeek)
if (q.beforeYear && q.beforeWeek) {
conditions.push(`(r.report_year < $${paramIndex} OR (r.report_year = $${paramIndex} AND r.report_week < $${paramIndex + 1}))`)
params.push(q.beforeYear)
paramIndex++
params.push(q.beforeWeek)
paramIndex++
}
// 주차 범위 필터 // 주차 범위 필터
if (q.weekFrom) { if (q.weekFrom) {
conditions.push(`r.report_week >= $${paramIndex++}`) conditions.push(`r.report_week >= $${paramIndex++}`)
@@ -122,6 +119,8 @@ export default defineEventHandler(async (event) => {
r.report_status, r.report_status,
r.submitted_at, r.submitted_at,
r.created_at, r.created_at,
r.updated_at,
r.ai_review,
(SELECT COUNT(DISTINCT project_id) FROM wr_weekly_report_task WHERE report_id = r.report_id) as project_count, (SELECT COUNT(DISTINCT project_id) FROM wr_weekly_report_task WHERE report_id = r.report_id) as project_count,
(SELECT string_agg(DISTINCT p.project_name, ', ') (SELECT string_agg(DISTINCT p.project_name, ', ')
FROM wr_weekly_report_task t FROM wr_weekly_report_task t
@@ -152,6 +151,8 @@ export default defineEventHandler(async (event) => {
reportStatus: r.report_status, reportStatus: r.report_status,
submittedAt: r.submitted_at, submittedAt: r.submitted_at,
createdAt: r.created_at, createdAt: r.created_at,
updatedAt: r.updated_at,
aiReview: r.ai_review,
projectCount: parseInt(r.project_count), projectCount: parseInt(r.project_count),
projectNames: r.project_names, projectNames: r.project_names,
totalWorkHours: parseFloat(r.total_work_hours) || 0, totalWorkHours: parseFloat(r.total_work_hours) || 0,

View File

@@ -0,0 +1,18 @@
-- 세션 테이블 (Spring Session JDBC와 유사한 구조)
CREATE TABLE IF NOT EXISTS wr_session (
session_id VARCHAR(64) PRIMARY KEY, -- 세션 토큰 (랜덤 생성)
employee_id INTEGER NOT NULL REFERENCES wr_employee_info(employee_id),
login_history_id INTEGER REFERENCES wr_login_history(history_id),
created_at TIMESTAMP DEFAULT NOW(), -- 세션 생성 시간
last_access_at TIMESTAMP DEFAULT NOW(), -- 마지막 접근 시간
expires_at TIMESTAMP NOT NULL, -- 만료 시간
login_ip VARCHAR(45), -- 로그인 IP
user_agent TEXT -- 브라우저 정보
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_wr_session_employee_id ON wr_session(employee_id);
CREATE INDEX IF NOT EXISTS idx_wr_session_expires_at ON wr_session(expires_at);
-- 만료된 세션 자동 정리 (선택사항 - 배치로 실행)
-- DELETE FROM wr_session WHERE expires_at < NOW();

198
backend/utils/session.ts Normal file
View File

@@ -0,0 +1,198 @@
import crypto from 'crypto'
import { query, queryOne, execute, insertReturning } from './db'
// 세션 설정
const SESSION_TIMEOUT_MINUTES = 10 // 10분 타임아웃
const SESSION_COOKIE_NAME = 'session_token'
const SESSION_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 쿠키는 7일 (세션 만료와 별개)
interface Session {
sessionId: string
employeeId: number
loginHistoryId: number | null
createdAt: Date
lastAccessAt: Date
expiresAt: Date
loginIp: string | null
userAgent: string | null
}
/**
* 랜덤 세션 토큰 생성 (Spring Session과 유사)
*/
function generateSessionToken(): string {
return crypto.randomBytes(32).toString('hex') // 64자 hex 문자열
}
/**
* 세션 생성
*/
export async function createSession(
employeeId: number,
loginHistoryId: number,
loginIp: string | null,
userAgent: string | null
): Promise<string> {
const sessionId = generateSessionToken()
const expiresAt = new Date(Date.now() + SESSION_TIMEOUT_MINUTES * 60 * 1000)
await execute(`
INSERT INTO wr_session (session_id, employee_id, login_history_id, expires_at, login_ip, user_agent)
VALUES ($1, $2, $3, $4, $5, $6)
`, [sessionId, employeeId, loginHistoryId, expiresAt, loginIp, userAgent])
return sessionId
}
/**
* 세션 조회 (유효한 세션만)
*/
export async function getSession(sessionId: string): Promise<Session | null> {
if (!sessionId) return null
const row = await queryOne<any>(`
SELECT
session_id,
employee_id,
login_history_id,
created_at,
last_access_at,
expires_at,
login_ip,
user_agent
FROM wr_session
WHERE session_id = $1 AND expires_at > NOW()
`, [sessionId])
if (!row) return null
return {
sessionId: row.session_id,
employeeId: row.employee_id,
loginHistoryId: row.login_history_id,
createdAt: row.created_at,
lastAccessAt: row.last_access_at,
expiresAt: row.expires_at,
loginIp: row.login_ip,
userAgent: row.user_agent
}
}
/**
* 세션 갱신 (Sliding Expiration)
* - 마지막 접근 시간 업데이트
* - 만료 시간 연장
*/
export async function refreshSession(sessionId: string): Promise<boolean> {
const newExpiresAt = new Date(Date.now() + SESSION_TIMEOUT_MINUTES * 60 * 1000)
const result = await execute(`
UPDATE wr_session
SET last_access_at = NOW(), expires_at = $1
WHERE session_id = $2 AND expires_at > NOW()
`, [newExpiresAt, sessionId])
return result.rowCount > 0
}
/**
* 세션 삭제 (로그아웃)
*/
export async function deleteSession(sessionId: string): Promise<boolean> {
const result = await execute(`
DELETE FROM wr_session WHERE session_id = $1
`, [sessionId])
return result.rowCount > 0
}
/**
* 사용자의 모든 세션 삭제 (모든 기기에서 로그아웃)
*/
export async function deleteAllUserSessions(employeeId: number): Promise<number> {
const result = await execute(`
DELETE FROM wr_session WHERE employee_id = $1
`, [employeeId])
return result.rowCount
}
/**
* 만료된 세션 정리 (배치용)
*/
export async function cleanupExpiredSessions(): Promise<number> {
const result = await execute(`
DELETE FROM wr_session WHERE expires_at < NOW()
`)
return result.rowCount
}
/**
* 세션 쿠키 설정
*/
export function setSessionCookie(event: any, sessionId: string) {
setCookie(event, SESSION_COOKIE_NAME, sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: SESSION_COOKIE_MAX_AGE,
path: '/'
})
}
/**
* 세션 쿠키 삭제
*/
export function deleteSessionCookie(event: any) {
deleteCookie(event, SESSION_COOKIE_NAME)
}
/**
* 세션 쿠키에서 세션 ID 가져오기
*/
export function getSessionIdFromCookie(event: any): string | null {
return getCookie(event, SESSION_COOKIE_NAME) || null
}
// 설정값 export
export { SESSION_TIMEOUT_MINUTES, SESSION_COOKIE_NAME }
/**
* 인증된 사용자 ID 가져오기 (다른 API에서 사용)
* - 세션이 없거나 만료되면 null 반환
* - 세션이 유효하면 자동 갱신
* - [호환성] 기존 user_id 쿠키도 지원 (마이그레이션 기간)
*/
export async function getAuthenticatedUserId(event: any): Promise<number | null> {
// 1. 새로운 세션 토큰 확인
const sessionId = getSessionIdFromCookie(event)
if (sessionId) {
const session = await getSession(sessionId)
if (session) {
await refreshSession(sessionId)
return session.employeeId
}
// 세션 만료 → 쿠키 삭제
deleteSessionCookie(event)
}
// 2. [호환성] 기존 user_id 쿠키 확인 (마이그레이션 기간)
const legacyUserId = getCookie(event, 'user_id')
if (legacyUserId) {
return parseInt(legacyUserId)
}
return null
}
/**
* 인증 필수 API용 - 미인증시 에러 throw
*/
export async function requireAuth(event: any): Promise<number> {
const userId = await getAuthenticatedUserId(event)
if (!userId) {
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
}
return userId
}

View File

@@ -64,9 +64,11 @@
<div <div
class="upload-zone p-5 text-center border rounded" class="upload-zone p-5 text-center border rounded"
:class="{ 'border-primary bg-light': isDragging }" :class="{ 'border-primary bg-light': isDragging }"
tabindex="0"
@dragover.prevent="isDragging = true" @dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false" @dragleave.prevent="isDragging = false"
@drop.prevent="handleDrop" @drop.prevent="handleDrop"
@paste="handlePaste"
@click="($refs.fileInput as HTMLInputElement).click()" @click="($refs.fileInput as HTMLInputElement).click()"
> >
<input <input
@@ -80,7 +82,7 @@
<i class="bi bi-cloud-arrow-up display-4 text-muted"></i> <i class="bi bi-cloud-arrow-up display-4 text-muted"></i>
<p class="mt-2 mb-0 text-muted"> <p class="mt-2 mb-0 text-muted">
이미지를 드래그하거나 클릭해서 업로드<br> 이미지를 드래그하거나 클릭해서 업로드<br>
<small>(최대 10, PNG/JPG)</small> <small>또는 <strong>Ctrl+V</strong> 붙여넣기 (최대 10)</small>
</p> </p>
</div> </div>
</div> </div>
@@ -358,6 +360,7 @@
<script setup lang="ts"> <script setup lang="ts">
const { fetchCurrentUser } = useAuth() const { fetchCurrentUser } = useAuth()
const { getWeekInfo, getWeekDates, getLastWeekInfo, getActualCurrentWeekInfo, changeWeek: calcChangeWeek } = useWeekCalc()
const router = useRouter() const router = useRouter()
const step = ref(1) const step = ref(1)
@@ -400,12 +403,6 @@ onMounted(async () => {
router.push('/login') router.push('/login')
return return
} }
if (user.employeeEmail !== 'coziny@gmail.com') {
alert('관리자만 접근할 수 있습니다.')
router.push('/')
return
}
}) })
function getHeaderClass(report: any) { function getHeaderClass(report: any) {
@@ -449,14 +446,36 @@ function handleDrop(e: DragEvent) {
if (files) processFiles(files) 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) { function handleFileSelect(e: Event) {
const input = e.target as HTMLInputElement const input = e.target as HTMLInputElement
if (input.files) processFiles(input.files) if (input.files) processFiles(input.files)
} }
function processFiles(files: FileList) { function processFiles(files: FileList) {
processImageFiles(Array.from(files))
}
function processImageFiles(files: File[]) {
const maxFiles = 10 - uploadedImages.value.length const maxFiles = 10 - uploadedImages.value.length
const toProcess = Array.from(files).slice(0, maxFiles) const toProcess = files.slice(0, maxFiles)
toProcess.forEach(file => { toProcess.forEach(file => {
if (!file.type.startsWith('image/')) return if (!file.type.startsWith('image/')) return
@@ -491,67 +510,34 @@ function removeParsedTask(taskArray: any[], idx: number) {
} }
} }
// 주차 계산 함수들 // 주차 관련 함수들 (useWeekCalc 사용)
function getMonday(date: Date): Date { function setWeekFromInfo(info: { year: number; week: number; startDateStr: string; endDateStr: string }) {
const d = new Date(date) parsedData.value.reportYear = info.year
const day = d.getDay() parsedData.value.reportWeek = info.week
const diff = d.getDate() - day + (day === 0 ? -6 : 1) parsedData.value.weekStartDate = info.startDateStr
d.setDate(diff) parsedData.value.weekEndDate = info.endDateStr
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
} }
function changeWeek(delta: number) { function changeWeek(delta: number) {
const currentMonday = new Date(parsedData.value.weekStartDate) const { year, week } = calcChangeWeek(parsedData.value.reportYear, parsedData.value.reportWeek, delta)
currentMonday.setDate(currentMonday.getDate() + (delta * 7)) const weekInfo = getWeekDates(year, week)
setWeekDates(currentMonday) setWeekFromInfo(weekInfo)
} }
function setLastWeek() { function setLastWeek() {
const today = new Date() const lastWeek = getLastWeekInfo()
const lastWeekMonday = getMonday(today) setWeekFromInfo(lastWeek)
lastWeekMonday.setDate(lastWeekMonday.getDate() - 7)
setWeekDates(lastWeekMonday)
} }
function setThisWeek() { function setThisWeek() {
const today = new Date() const thisWeek = getActualCurrentWeekInfo()
const thisWeekMonday = getMonday(today) setWeekFromInfo(thisWeek)
setWeekDates(thisWeekMonday)
} }
function updateWeekFromDate() { function updateWeekFromDate() {
const startDate = new Date(parsedData.value.weekStartDate) const startDate = new Date(parsedData.value.weekStartDate)
const monday = getMonday(startDate) const weekInfo = getWeekInfo(startDate)
setWeekDates(monday) setWeekFromInfo(weekInfo)
} }
// 분석 결과 처리 // 분석 결과 처리
@@ -568,6 +554,11 @@ function handleParseResult(res: any) {
})) }))
p.planTasks = p.planTasks || [] 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 employees.value = res.employees
projects.value = res.projects projects.value = res.projects
@@ -616,12 +607,14 @@ async function bulkRegister() {
employeeId: r.createNewEmployee ? null : r.matchedEmployeeId, employeeId: r.createNewEmployee ? null : r.matchedEmployeeId,
employeeName: r.employeeName, employeeName: r.employeeName,
employeeEmail: r.employeeEmail, employeeEmail: r.employeeEmail,
projects: r.projects.map((p: any) => ({ projects: r.projects
.map((p: any) => ({
projectId: p.matchedProjectId, projectId: p.matchedProjectId,
projectName: p.projectName, projectName: p.projectName,
workTasks: (p.workTasks || []).filter((t: any) => t.description?.trim()), workTasks: (p.workTasks || []).filter((t: any) => t.description?.trim()),
planTasks: (p.planTasks || []).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, issueDescription: r.issueDescription,
vacationDescription: r.vacationDescription, vacationDescription: r.vacationDescription,
remarkDescription: r.remarkDescription remarkDescription: r.remarkDescription

View File

@@ -1,8 +1,11 @@
/** /**
* ISO 8601 주차 계산 composable * ISO 8601 주차 계산 composable
* - 1월 4일이 포함된 주 = 1주차
* - 주의 시작 = 월요일
* - 예: 2026년 2주차 = 2026-01-05(월) ~ 2026-01-11(일)
*/ */
interface WeekInfo { export interface WeekInfo {
year: number year: number
week: number week: number
startDate: Date startDate: Date
@@ -13,28 +16,114 @@ interface WeekInfo {
} }
export function useWeekCalc() { 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 주차 정보 반환 * 특정 날짜의 ISO 주차 정보 반환
*/ */
function getWeekInfo(date: Date = new Date()): WeekInfo { function getWeekInfo(date: Date = new Date()): WeekInfo {
const target = new Date(date) const monday = getMonday(date)
target.setHours(0, 0, 0, 0) const sunday = getSunday(date)
const { year, week } = getWeekNumber(date)
// 목요일 기준으로 연도 판단 (ISO 규칙) return {
const thursday = new Date(target) year,
thursday.setDate(target.getDate() - ((target.getDay() + 6) % 7) + 3) week,
startDate: monday,
endDate: sunday,
startDateStr: formatDate(monday),
endDateStr: formatDate(sunday),
weekString: `${year}-W${week.toString().padStart(2, '0')}`
}
}
const year = thursday.getFullYear() /**
const firstThursday = new Date(year, 0, 4) * 연도/주차로 날짜 범위 반환
firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3) */
function getWeekDates(year: number, week: number): WeekInfo {
const week1Monday = getWeek1Monday(year)
const week = Math.ceil((thursday.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1 const monday = new Date(week1Monday)
monday.setDate(week1Monday.getDate() + (week - 1) * 7)
// 해당 주의 월요일
const monday = new Date(target)
monday.setDate(target.getDate() - ((target.getDay() + 6) % 7))
// 해당 주의 일요일
const sunday = new Date(monday) const sunday = new Date(monday)
sunday.setDate(monday.getDate() + 6) 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) 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 { function getWeekRangeText(year: number, week: number): string {
// 해당 연도 첫 번째 목요일 찾기 const { startDate, endDate } = getWeekDates(year, week)
const jan4 = new Date(year, 0, 4) return `${formatDateKr(startDate)} ~ ${formatDateKr(endDate)}`
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)}`
} }
/** /**
* 한국어 날짜 포맷 (M월 D일) * 주차별 날짜 범위 텍스트 - ISO 형식 (예: "2026-01-05 ~ 2026-01-11")
*/ */
function formatDateKr(date: Date): string { function getWeekRangeTextISO(year: number, week: number): string {
return `${date.getMonth() + 1}${date.getDate()}` const { startDateStr, endDateStr } = getWeekDates(year, week)
return `${startDateStr} ~ ${endDateStr}`
} }
return { return {
// 기본 유틸
formatDate,
formatDateKr,
getMonday,
getSunday,
// 주차 계산
getWeekNumber,
getWeekInfo, getWeekInfo,
getWeekDates,
getWeeksInYear,
changeWeek,
// 현재/지난주
getCurrentWeekInfo, getCurrentWeekInfo,
getActualCurrentWeekInfo, getActualCurrentWeekInfo,
getLastWeekInfo, getLastWeekInfo,
formatDate,
// 파싱/포맷
parseWeekString, parseWeekString,
getWeekRangeText, getWeekRangeText,
formatDateKr getWeekRangeTextISO
} }
} }

View File

@@ -7,22 +7,24 @@
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div> <h4 class="mb-0">
<h4 class="mb-1"> <i class="bi bi-speedometer2 me-2"></i>대시보드
<i class="bi bi-speedometer2 me-2"></i>리소스 현황
</h4> </h4>
<p class="text-muted mb-0"> <div class="d-flex align-items-center gap-2">
{{ currentWeek.year }} {{ currentWeek.week }}주차 <button class="btn btn-outline-secondary btn-sm" @click="changeWeek(-1)" title="이전 주차">
({{ currentWeek.startDateStr }} ~ {{ currentWeek.endDateStr }}) <i class="bi bi-chevron-left"></i>
</p> </button>
</div> <select class="form-select form-select-sm" style="width: 100px;" v-model="selectedYear" @change="onYearChange">
<div class="d-flex gap-2">
<select class="form-select form-select-sm" style="width: 100px;" v-model="selectedYear" @change="loadStats">
<option v-for="y in yearOptions" :key="y" :value="y">{{ y }}</option> <option v-for="y in yearOptions" :key="y" :value="y">{{ y }}</option>
</select> </select>
<select class="form-select form-select-sm" style="width: 90px;" v-model="selectedWeek" @change="loadStats"> <select class="form-select form-select-sm" style="width: auto;" v-model="selectedWeek" @change="loadStats">
<option v-for="w in weekOptions" :key="w" :value="w">{{ w }}</option> <option v-for="opt in weekOptionsWithDates" :key="opt.week" :value="opt.week">
{{ opt.label }}
</option>
</select> </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> </div>
</div> </div>
@@ -74,7 +76,7 @@
<div class="col-md-3"> <div class="col-md-3">
<div class="card h-100"> <div class="card h-100">
<div class="card-body text-center"> <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 class="d-flex justify-content-around mt-1">
<div> <div>
<span class="badge bg-success">{{ resourceStatus.available }}</span> <span class="badge bg-success">{{ resourceStatus.available }}</span>
@@ -106,7 +108,7 @@
<span class="badge bg-danger">48h~</span> <span class="badge bg-danger">48h~</span>
</div> </div>
</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"> <table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top"> <thead class="table-light sticky-top">
<tr> <tr>
@@ -118,7 +120,7 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="emp in stats.employees" :key="emp.employeeId" <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> <td>
<div class="d-flex align-items-center"> <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> <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> </div>
</td> </td>
<td class="text-center"> <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 {{ emp.workHours }}h
</span> </span>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
<td class="text-center"> <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 {{ emp.planHours }}h
</span> </span>
<span v-else class="text-muted">-</span> <span v-else class="text-muted">-</span>
</td> </td>
<td class="text-center"> <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> <span v-else class="badge bg-secondary">미제출</span>
</td> </td>
</tr> </tr>
@@ -160,7 +163,7 @@
<div class="card-header"> <div class="card-header">
<i class="bi bi-briefcase me-2"></i>프로젝트별 투입 현황 <i class="bi bi-briefcase me-2"></i>프로젝트별 투입 현황
</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"> <table class="table table-sm table-hover mb-0">
<thead class="table-light sticky-top"> <thead class="table-light sticky-top">
<tr> <tr>
@@ -195,61 +198,79 @@
</div> </div>
</div> </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>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { currentUser, fetchCurrentUser } = useAuth() const { currentUser, fetchCurrentUser } = useAuth()
const { getCurrentWeekInfo } = useWeekCalc() const { getCurrentWeekInfo, getActualCurrentWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
const router = useRouter() const router = useRouter()
const currentWeek = getCurrentWeekInfo() const currentWeek = getCurrentWeekInfo()
const actualCurrentWeek = getActualCurrentWeekInfo() // 실제 현재 주차
const isAdmin = ref(false) const isAdmin = ref(false)
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const yearOptions = [currentYear, currentYear - 1] const yearOptions = [currentYear, currentYear - 1]
const weekOptions = Array.from({ length: 53 }, (_, i) => i + 1)
const selectedYear = ref(currentWeek.year) const selectedYear = ref(currentWeek.year)
const selectedWeek = ref(currentWeek.week) 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>({ const stats = ref<any>({
summary: { summary: {
activeEmployees: 0, activeEmployees: 0,

View File

@@ -152,8 +152,9 @@
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td> <td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
<td><code>{{ h.logoutIp || '-' }}</code></td> <td><code>{{ h.logoutIp || '-' }}</code></td>
<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.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> <span v-else class="badge bg-warning text-dark">세션만료</span>
</td> </td>
</tr> </tr>

View File

@@ -190,7 +190,7 @@
import { ref, watch, onMounted } from 'vue' import { ref, watch, onMounted } from 'vue'
const { fetchCurrentUser } = useAuth() const { fetchCurrentUser } = useAuth()
const { getCurrentWeekInfo } = useWeekCalc() const { getCurrentWeekInfo, getWeekDates } = useWeekCalc()
const router = useRouter() const router = useRouter()
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
@@ -296,19 +296,8 @@ watch(showAggregateModal, (val) => {
}) })
function getWeekDateRange(year: number, week: number): string { function getWeekDateRange(year: number, week: number): string {
const jan4 = new Date(year, 0, 4) const weekInfo = getWeekDates(year, week)
const jan4Day = jan4.getDay() || 7 return `${weekInfo.startDateStr}~${weekInfo.endDateStr}`
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)}`
} }
function formatDate(dateStr: string) { function formatDate(dateStr: string) {

View File

@@ -140,24 +140,143 @@
</div> </div>
</div> </div>
<!-- PMO AI 리뷰 --> <!-- PMO AI 리뷰 - 작성 품질 점수 -->
<div class="card mb-4 border-info"> <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"> <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"> <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> <span v-if="isReviewing" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-arrow-repeat me-1"></i> <i v-else class="bi bi-arrow-repeat me-1"></i>
{{ report.aiReview ? '리뷰 재요청' : '리뷰 요청' }} {{ qualityScore ? '재평가' : '품질 평가' }}
</button> </button>
</div> </div>
<div class="card-body"> <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"> <div v-if="qualityScore">
<i class="bi bi-chat-left-dots me-2"></i> <!-- 1. 총평 ( ) -->
아직 AI 리뷰가 없습니다. 리뷰 요청 버튼을 클릭하세요. <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 v-if="report.aiReviewAt" class="text-muted small mt-3 text-end"> </div>
<i class="bi bi-clock me-1"></i>리뷰 생성: {{ formatDateTime(report.aiReviewAt) }} </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-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> </div>
</div> </div>
@@ -196,10 +315,15 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong> <strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
<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"> <button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
<i class="bi bi-plus"></i> 프로젝트 추가 <i class="bi bi-plus"></i> 프로젝트 추가
</button> </button>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
<div v-for="(group, gIdx) in editProjectGroups" :key="group.projectId" class="border rounded mb-4"> <div v-for="(group, gIdx) in editProjectGroups" :key="group.projectId" class="border rounded mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
@@ -232,7 +356,8 @@
{{ task.isCompleted ? '완료' : '진행' }} {{ task.isCompleted ? '완료' : '진행' }}
</label> </label>
</div> </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"> <div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;" <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" /> v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
@@ -255,7 +380,8 @@
</div> </div>
<div v-for="(task, tIdx) in getEditPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2"> <div v-for="(task, tIdx) in getEditPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start"> <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"> <div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;" <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" /> v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
@@ -330,11 +456,242 @@
</div> </div>
</div> </div>
<div class="modal-backdrop fade show" v-if="showProjectModal" @click="showProjectModal = false"></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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { nextTick } from 'vue'
const { currentUser, fetchCurrentUser } = useAuth() const { currentUser, fetchCurrentUser } = useAuth()
const { getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
@@ -352,6 +709,17 @@ const isSubmitting = ref(false)
const isDeleting = ref(false) const isDeleting = ref(false)
const isReviewing = ref(false) const isReviewing = ref(false)
const showProjectModal = 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') 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' 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(() => { const canSubmit = computed(() => {
if (!report.value || !currentUser.value) return false if (!report.value || !currentUser.value) return false
return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus === 'DRAFT' return report.value.authorId === currentUser.value.employeeId && report.value.reportStatus === 'DRAFT'
@@ -494,9 +903,34 @@ watch(isEditing, (val) => {
vacationDescription: report.value.vacationDescription || '', vacationDescription: report.value.vacationDescription || '',
remarkDescription: report.value.remarkDescription || '' 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) { function getProjectWorkHours(proj: any) {
return proj.workTasks.reduce((sum: number, t: any) => sum + (t.hours || 0), 0) 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) { function changeEditWeek(delta: number) {
let year = editForm.value.reportYear const { year, week } = calcChangeWeek(editForm.value.reportYear, editForm.value.reportWeek, delta)
let week = editForm.value.reportWeek + delta
// 주차 범위 조정
if (week < 1) {
year--
week = getWeeksInYear(year)
} else if (week > getWeeksInYear(year)) {
year++
week = 1
}
editForm.value.reportYear = year editForm.value.reportYear = year
editForm.value.reportWeek = week editForm.value.reportWeek = week
// 해당 주차의 월요일~일요일 계산 // 해당 주차의 월요일~일요일 계산
const { monday, sunday } = getWeekDates(year, week) const weekInfo = getWeekDates(year, week)
editForm.value.weekStartDate = monday editForm.value.weekStartDate = weekInfo.startDateStr
editForm.value.weekEndDate = sunday editForm.value.weekEndDate = weekInfo.endDateStr
}
// 연도의 총 주차 수 계산
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}`
} }
// 수정 모드 함수들 // 수정 모드 함수들
@@ -675,6 +1059,16 @@ async function handleUpdate() {
} }
async function handleSubmit() { async function handleSubmit() {
// AI 리뷰가 없으면 먼저 확인
if (!report.value?.aiReview) {
showAiReviewConfirmModal.value = true
return
}
await doSubmit()
}
async function doSubmit() {
if (!confirm('제출하시겠습니까? 제출 후에는 수정할 수 없습니다.')) return if (!confirm('제출하시겠습니까? 제출 후에는 수정할 수 없습니다.')) return
isSubmitting.value = true 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() { async function handleDelete() {
const authorName = report.value?.authorName || '' const authorName = report.value?.authorName || ''
const weekInfo = `${report.value?.reportYear}${report.value?.reportWeek}주차` const weekInfo = `${report.value?.reportYear}${report.value?.reportWeek}주차`
@@ -710,14 +1123,14 @@ async function handleDelete() {
async function requestAiReview() { async function requestAiReview() {
isReviewing.value = true isReviewing.value = true
try { 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', method: 'POST',
body: { reportId: parseInt(reportId.value) } body: { reportId: parseInt(reportId.value) }
}) })
report.value.aiReview = res.review report.value.aiReview = JSON.stringify(res.qualityScore)
report.value.aiReviewAt = res.reviewedAt report.value.aiReviewAt = res.reviewedAt
} catch (e: any) { } catch (e: any) {
alert(e.data?.message || 'AI 리뷰 요청에 실패했습니다.') alert(e.data?.message || 'AI 품질 평가 요청에 실패했습니다.')
} finally { } finally {
isReviewing.value = false isReviewing.value = false
} }
@@ -748,9 +1161,9 @@ function formatDate(dateStr: string) {
function getStatusBadgeClass(status: string) { function getStatusBadgeClass(status: string) {
const classes: Record<string, string> = { const classes: Record<string, string> = {
'DRAFT': 'badge bg-secondary', 'DRAFT': 'badge bg-warning',
'SUBMITTED': 'badge bg-success', 'SUBMITTED': 'badge bg-success',
'AGGREGATED': 'badge bg-info' 'AGGREGATED': 'badge bg-success'
} }
return classes[status] || 'badge bg-secondary' return classes[status] || 'badge bg-secondary'
} }
@@ -758,11 +1171,169 @@ function getStatusBadgeClass(status: string) {
function getStatusText(status: string) { function getStatusText(status: string) {
const texts: Record<string, string> = { const texts: Record<string, string> = {
'DRAFT': '작성중', 'DRAFT': '작성중',
'SUBMITTED': '제출완료', 'SUBMITTED': '제출',
'AGGREGATED': '취합완료' 'AGGREGATED': '제출'
} }
return texts[status] || status 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> </script>
<style scoped> <style scoped>
@@ -777,4 +1348,16 @@ function getStatusText(status: string) {
color: #0d6efd; color: #0d6efd;
margin-top: 1rem; 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> </style>

View File

@@ -139,6 +139,7 @@ import { useRouter } from 'vue-router'
const router = useRouter() const router = useRouter()
const { fetchCurrentUser, isAdmin } = useAuth() const { fetchCurrentUser, isAdmin } = useAuth()
const { getWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
const isLoading = ref(false) const isLoading = ref(false)
const isLoaded = ref(false) const isLoaded = ref(false)
@@ -178,72 +179,23 @@ onMounted(async () => {
}) })
function initCurrentWeek() { function initCurrentWeek() {
const now = new Date() const weekInfo = getWeekInfo(new Date())
const jan4 = new Date(now.getFullYear(), 0, 4) selectedYear.value = weekInfo.year
const jan4Day = jan4.getDay() || 7 selectedWeek.value = weekInfo.week
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
updateWeekDates() updateWeekDates()
} }
function changeWeek(delta: number) { function changeWeek(delta: number) {
let year = selectedYear.value const { year, week } = calcChangeWeek(selectedYear.value, selectedWeek.value, delta)
let week = selectedWeek.value + delta
if (week < 1) {
year--
week = getWeeksInYear(year)
} else if (week > getWeeksInYear(year)) {
year++
week = 1
}
selectedYear.value = year selectedYear.value = year
selectedWeek.value = week selectedWeek.value = week
updateWeekDates() 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() { function updateWeekDates() {
const { monday, sunday } = getWeekDates(selectedYear.value, selectedWeek.value) const weekInfo = getWeekDates(selectedYear.value, selectedWeek.value)
weekStartDate.value = monday weekStartDate.value = weekInfo.startDateStr
weekEndDate.value = sunday weekEndDate.value = weekInfo.endDateStr
}
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}`
} }
async function loadAggregate() { async function loadAggregate() {

View File

@@ -8,10 +8,7 @@
<i class="bi bi-journal-text me-2"></i>주간보고 <i class="bi bi-journal-text me-2"></i>주간보고
</h4> </h4>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<NuxtLink v-if="isAdmin" to="/report/summary" class="btn btn-outline-primary"> <NuxtLink :to="`/report/weekly/write?year=${filters.year}&week=${filters.week}`" class="btn btn-primary">
<i class="bi bi-collection me-1"></i>취합하기
</NuxtLink>
<NuxtLink to="/report/weekly/write" class="btn btn-primary">
<i class="bi bi-plus me-1"></i>작성하기 <i class="bi bi-plus me-1"></i>작성하기
</NuxtLink> </NuxtLink>
</div> </div>
@@ -21,16 +18,8 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-body"> <div class="card-body">
<div class="row g-3 align-items-end"> <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> <label class="form-label small text-muted">작성자</label>
<select class="form-select form-select-sm" v-model="filters.authorId" @change="loadReports"> <select class="form-select form-select-sm" v-model="filters.authorId" @change="loadReports">
<option value="">전체</option> <option value="">전체</option>
@@ -40,21 +29,24 @@
</select> </select>
</div> </div>
<!-- 연도 --> <!-- 연도/주차 -->
<div class="col-md-1"> <div class="col-auto">
<label class="form-label small text-muted">연도</label> <div class="d-flex align-items-center gap-2">
<select class="form-select form-select-sm" v-model="filters.year" @change="loadReports"> <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> <option v-for="y in yearOptions" :key="y" :value="y">{{ y }}</option>
</select> </select>
</div> <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 }}
<div class="col-md-1"> </option>
<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> </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>
<!-- 초기화 --> <!-- 초기화 -->
@@ -76,20 +68,22 @@
<tr> <tr>
<th style="width: 120px">주차</th> <th style="width: 120px">주차</th>
<th style="width: 180px">기간</th> <th style="width: 180px">기간</th>
<th v-if="isAdmin" style="width: 120px">작성자</th>
<th>프로젝트</th> <th>프로젝트</th>
<th v-if="isAdmin" style="width: 80px">작성자</th>
<th style="width: 90px">상태</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> </tr>
</thead> </thead>
<tbody> <tbody>
<tr v-if="isLoading"> <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>로딩 ... <span class="spinner-border spinner-border-sm me-2"></span>로딩 ...
</td> </td>
</tr> </tr>
<tr v-else-if="reports.length === 0"> <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> <i class="bi bi-inbox display-4"></i>
<p class="mt-2 mb-0">조회된 주간보고가 없습니다.</p> <p class="mt-2 mb-0">조회된 주간보고가 없습니다.</p>
</td> </td>
@@ -103,21 +97,33 @@
<td class="small"> <td class="small">
{{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }} {{ formatDate(r.weekStartDate) }} ~ {{ formatDate(r.weekEndDate) }}
</td> </td>
<td v-if="isAdmin">
<span class="badge bg-secondary">{{ r.authorName }}</span>
</td>
<td> <td>
<span class="text-truncate d-inline-block" style="max-width: 400px;" :title="r.projectNames"> <span class="text-truncate d-inline-block" style="max-width: 400px;" :title="r.projectNames">
{{ r.projectNames || '-' }} {{ r.projectNames || '-' }}
</span> </span>
<span class="badge bg-light text-dark ms-1">{{ r.projectCount }}</span> <span class="badge bg-light text-dark ms-1">{{ r.projectCount }}</span>
</td> </td>
<td v-if="isAdmin">{{ r.authorName }}</td>
<td> <td>
<span :class="getStatusBadgeClass(r.reportStatus)"> <span :class="getStatusBadgeClass(r.reportStatus)">
{{ getStatusText(r.reportStatus) }} {{ getStatusText(r.reportStatus) }}
</span> </span>
</td> </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> </tr>
</tbody> </tbody>
</table> </table>
@@ -133,24 +139,74 @@
<script setup lang="ts"> <script setup lang="ts">
const { fetchCurrentUser } = useAuth() const { fetchCurrentUser } = useAuth()
const { getCurrentWeekInfo, getActualCurrentWeekInfo, getWeekDates, getWeeksInYear, changeWeek: calcChangeWeek } = useWeekCalc()
const router = useRouter() const router = useRouter()
const route = useRoute()
const reports = ref<any[]>([]) const reports = ref<any[]>([])
const employees = ref<any[]>([]) const employees = ref<any[]>([])
const projects = ref<any[]>([])
const isLoading = ref(true) const isLoading = ref(true)
const isAdmin = ref(false) const isAdmin = ref(false)
const currentWeek = getCurrentWeekInfo()
const actualCurrentWeek = getActualCurrentWeekInfo() // 실제 현재 주차
const currentYear = new Date().getFullYear() const currentYear = new Date().getFullYear()
const yearOptions = [currentYear, currentYear - 1, currentYear - 2] 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({ const filters = ref({
viewAll: false,
authorId: '', authorId: '',
year: currentYear, year: currentWeek.year,
week: '' 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 () => { onMounted(async () => {
const user = await fetchCurrentUser() const user = await fetchCurrentUser()
if (!user) { if (!user) {
@@ -160,11 +216,15 @@ onMounted(async () => {
isAdmin.value = user.employeeEmail === 'coziny@gmail.com' isAdmin.value = user.employeeEmail === 'coziny@gmail.com'
// 직원, 프로젝트 목록 로드 (관리자용) // URL 쿼리 파라미터가 있으면 필터에 적용
if (isAdmin.value) { if (route.query.year && route.query.week) {
await loadFilterOptions() filters.value.year = parseInt(route.query.year as string)
filters.value.week = parseInt(route.query.week as string)
} }
// 직원 목록 로드
await loadFilterOptions()
loadReports() loadReports()
}) })
@@ -187,7 +247,6 @@ async function loadReports() {
try { try {
const params = new URLSearchParams() 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.authorId) params.append('authorId', filters.value.authorId)
if (filters.value.year) params.append('year', String(filters.value.year)) if (filters.value.year) params.append('year', String(filters.value.year))
if (filters.value.week) params.append('week', String(filters.value.week)) if (filters.value.week) params.append('week', String(filters.value.week))
@@ -203,10 +262,9 @@ async function loadReports() {
function resetFilters() { function resetFilters() {
filters.value = { filters.value = {
viewAll: false,
authorId: '', authorId: '',
year: currentYear, year: currentWeek.year,
week: '' week: currentWeek.week
} }
loadReports() loadReports()
} }
@@ -222,11 +280,25 @@ function formatDateTime(dateStr: string) {
return `${d.getMonth() + 1}/${d.getDate()}` 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) { function getStatusBadgeClass(status: string) {
const classes: Record<string, string> = { const classes: Record<string, string> = {
'DRAFT': 'badge bg-secondary', 'DRAFT': 'badge bg-warning',
'SUBMITTED': 'badge bg-success', 'SUBMITTED': 'badge bg-success',
'AGGREGATED': 'badge bg-info' 'AGGREGATED': 'badge bg-success'
} }
return classes[status] || 'badge bg-secondary' return classes[status] || 'badge bg-secondary'
} }
@@ -234,11 +306,49 @@ function getStatusBadgeClass(status: string) {
function getStatusText(status: string) { function getStatusText(status: string) {
const texts: Record<string, string> = { const texts: Record<string, string> = {
'DRAFT': '작성중', 'DRAFT': '작성중',
'SUBMITTED': '제출완료', 'SUBMITTED': '제출',
'AGGREGATED': '취합완료' 'AGGREGATED': '제출'
} }
return texts[status] || status 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> </script>
<style scoped> <style scoped>

View File

@@ -12,31 +12,12 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header"><strong>보고 주차</strong></div> <div class="card-header"><strong>보고 주차</strong></div>
<div class="card-body"> <div class="card-body">
<div class="row align-items-end"> <div class="d-flex align-items-center">
<div class="col-auto"> <h5 class="mb-0">
<div class="input-group"> <i class="bi bi-calendar-week me-2 text-primary"></i>
<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> <strong>{{ form.reportYear }} {{ form.reportWeek }}주차</strong>
</span> <span class="text-muted ms-2">({{ form.weekStartDate }} ~ {{ form.weekEndDate }})</span>
<button type="button" class="btn btn-outline-secondary" @click="changeWeek(1)"> </h5>
<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> </div>
</div> </div>
</div> </div>
@@ -45,10 +26,15 @@
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
<strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong> <strong><i class="bi bi-folder me-2"></i>프로젝트별 실적/계획</strong>
<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"> <button type="button" class="btn btn-sm btn-outline-primary" @click="showProjectModal = true">
<i class="bi bi-plus"></i> 프로젝트 추가 <i class="bi bi-plus"></i> 프로젝트 추가
</button> </button>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
<div v-if="projectGroups.length === 0" class="text-center text-muted py-4"> <div v-if="projectGroups.length === 0" class="text-center text-muted py-4">
프로젝트를 추가해주세요. 프로젝트를 추가해주세요.
@@ -88,7 +74,8 @@
{{ task.isCompleted ? '완료' : '진행' }} {{ task.isCompleted ? '완료' : '진행' }}
</label> </label>
</div> </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"> <div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;" <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" /> v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
@@ -114,7 +101,8 @@
</div> </div>
<div v-for="(task, tIdx) in getPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2"> <div v-for="(task, tIdx) in getPlanTasks(group.projectId)" :key="'plan-'+tIdx" class="mb-2">
<div class="d-flex gap-2 align-items-start"> <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"> <div class="text-nowrap">
<input type="number" class="form-control form-control-sm text-end mb-1" style="width: 70px;" <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" /> v-model.number="task.hours" min="0" step="0.5" placeholder="h" />
@@ -205,11 +193,269 @@
</div> </div>
</div> </div>
<div class="modal-backdrop fade show" v-if="showProjectModal" @click="showProjectModal = false"></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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { nextTick } from 'vue'
const { fetchCurrentUser } = useAuth() const { fetchCurrentUser } = useAuth()
const route = useRoute()
const { getWeekInfo, getWeekDates, getLastWeekInfo, getActualCurrentWeekInfo, getMonday, changeWeek: calcChangeWeek } = useWeekCalc()
const router = useRouter() const router = useRouter()
interface TaskItem { interface TaskItem {
@@ -230,6 +476,37 @@ const allProjects = ref<any[]>([])
const showProjectModal = ref(false) const showProjectModal = ref(false)
const isSaving = 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({ const form = ref({
reportYear: new Date().getFullYear(), reportYear: new Date().getFullYear(),
reportWeek: 1, 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) 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 () => { onMounted(async () => {
const user = await fetchCurrentUser() const user = await fetchCurrentUser()
@@ -276,9 +553,60 @@ onMounted(async () => {
} }
await loadProjects() 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() { async function loadProjects() {
try { try {
const res = await $fetch<any>('/api/project/list') const res = await $fetch<any>('/api/project/list')
@@ -288,67 +616,150 @@ async function loadProjects() {
} }
} }
// 주차 관련 함수들 // 주차 관련 함수들 (useWeekCalc 사용)
function getMonday(date: Date): Date { function setWeekFromInfo(info: { year: number; week: number; startDateStr: string; endDateStr: string }) {
const d = new Date(date) form.value.reportYear = info.year
const day = d.getDay() form.value.reportWeek = info.week
const diff = d.getDate() - day + (day === 0 ? -6 : 1) form.value.weekStartDate = info.startDateStr
d.setDate(diff) form.value.weekEndDate = info.endDateStr
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
} }
function changeWeek(delta: number) { function changeWeek(delta: number) {
const currentMonday = new Date(form.value.weekStartDate) const { year, week } = calcChangeWeek(form.value.reportYear, form.value.reportWeek, delta)
currentMonday.setDate(currentMonday.getDate() + (delta * 7)) const weekInfo = getWeekDates(year, week)
setWeekDates(currentMonday) setWeekFromInfo(weekInfo)
} }
function setLastWeek() { function setLastWeek() {
const today = new Date() const lastWeek = getLastWeekInfo()
const lastWeekMonday = getMonday(today) setWeekFromInfo(lastWeek)
lastWeekMonday.setDate(lastWeekMonday.getDate() - 7)
setWeekDates(lastWeekMonday)
} }
function setThisWeek() { function setThisWeek() {
const today = new Date() const thisWeek = getActualCurrentWeekInfo()
const thisWeekMonday = getMonday(today) setWeekFromInfo(thisWeek)
setWeekDates(thisWeekMonday) }
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() { function updateWeekFromDate() {
const startDate = new Date(form.value.weekStartDate) const startDate = new Date(form.value.weekStartDate)
const monday = getMonday(startDate) const weekInfo = getWeekInfo(startDate)
setWeekDates(monday) setWeekFromInfo(weekInfo)
} }
// Task 관련 함수들 // Task 관련 함수들
@@ -464,10 +875,281 @@ async function handleSubmit() {
isSaving.value = false 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> </script>
<style scoped> <style scoped>
.modal.show { .modal.show {
background-color: rgba(0, 0, 0, 0.5); 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> </style>

1810
package-lock.json generated

File diff suppressed because it is too large Load Diff