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