기능구현중

This commit is contained in:
2026-01-11 13:47:33 +09:00
parent f5bf084afc
commit d56154d5d2
13 changed files with 948 additions and 46 deletions

View File

@@ -0,0 +1,105 @@
import { queryOne, execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { chatCompletion } from '../../../utils/openai'
/**
* 회의록 AI 분석
* POST /api/meeting/[id]/analyze
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const meetingId = parseInt(event.context.params?.id || '0')
if (!meetingId) {
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
}
// 회의록 조회
const meeting = await queryOne<any>(`
SELECT m.*, p.project_name
FROM wr_meeting m
LEFT JOIN wr_project_info p ON m.project_id = p.project_id
WHERE m.meeting_id = $1
`, [meetingId])
if (!meeting) {
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
}
if (!meeting.raw_content) {
throw createError({ statusCode: 400, message: '분석할 회의 내용이 없습니다.' })
}
// AI 프롬프트
const systemPrompt = `당신은 회의록 정리 전문가입니다.
아래 회의 내용을 분석하여 JSON 형식으로 정리해주세요.
## 출력 형식 (JSON만 출력, 다른 텍스트 없이)
{
"agendas": [
{
"no": 1,
"title": "안건 제목",
"content": "상세 내용 요약",
"status": "DECIDED | PENDING | IN_PROGRESS",
"decision": "결정 내용 (결정된 경우만)",
"todos": [
{
"title": "TODO 제목",
"assignee": "담당자명 또는 null",
"reason": "TODO로 추출한 이유"
}
]
}
],
"summary": "전체 회의 요약 (2-3문장)"
}
## 규칙
1. 안건은 주제별로 분리하여 넘버링
2. 결정된 사항은 DECIDED, 추후 논의는 PENDING, 진행중은 IN_PROGRESS
3. 미결정/진행중 사항 중 액션이 필요한 것은 todos로 추출
4. 담당자가 언급되면 assignee에 기록 (없으면 null)
5. JSON 외 다른 텍스트 출력 금지`
const userPrompt = `## 회의 정보
- 제목: ${meeting.meeting_title}
- 프로젝트: ${meeting.project_name || '없음 (내부업무)'}
- 일자: ${meeting.meeting_date}
## 회의 내용
${meeting.raw_content}`
try {
const result = await chatCompletion([
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
], { model: 'gpt-4o-mini', temperature: 0.3 })
// JSON 파싱
let aiResult: any
try {
// JSON 블록 추출 (```json ... ``` 형태 처리)
let jsonStr = result.trim()
if (jsonStr.startsWith('```')) {
jsonStr = jsonStr.replace(/^```json?\n?/, '').replace(/\n?```$/, '')
}
aiResult = JSON.parse(jsonStr)
} catch (e) {
console.error('AI result parse error:', result)
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
}
// DB 저장
await execute(`
UPDATE wr_meeting
SET ai_summary = $1, ai_status = 'PENDING', ai_processed_at = NOW()
WHERE meeting_id = $2
`, [JSON.stringify(aiResult), meetingId])
return { success: true, result: aiResult }
} catch (e: any) {
console.error('AI analyze error:', e)
throw createError({ statusCode: 500, message: e.message || 'AI 분석 실패' })
}
})

View File

@@ -0,0 +1,82 @@
import { queryOne, execute, insertReturning } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { getClientIp } from '../../../utils/ip'
interface ConfirmBody {
selectedTodos?: Array<{
agendaNo: number
todoIndex: number
title: string
assignee?: string
}>
}
/**
* AI 분석 결과 확정 + TODO 생성
* POST /api/meeting/[id]/confirm
*/
export default defineEventHandler(async (event) => {
const employeeId = await requireAuth(event)
const meetingId = parseInt(event.context.params?.id || '0')
const body = await readBody<ConfirmBody>(event)
const ip = getClientIp(event)
if (!meetingId) {
throw createError({ statusCode: 400, message: '회의록 ID가 필요합니다.' })
}
// 회의록 조회
const meeting = await queryOne<any>(`
SELECT meeting_id, ai_summary, ai_status, project_id
FROM wr_meeting WHERE meeting_id = $1
`, [meetingId])
if (!meeting) {
throw createError({ statusCode: 404, message: '회의록을 찾을 수 없습니다.' })
}
if (!meeting.ai_summary) {
throw createError({ statusCode: 400, message: 'AI 분석 결과가 없습니다.' })
}
const aiResult = typeof meeting.ai_summary === 'string'
? JSON.parse(meeting.ai_summary)
: meeting.ai_summary
// 선택된 TODO 생성
const createdTodos: any[] = []
if (body.selectedTodos && body.selectedTodos.length > 0) {
for (const todo of body.selectedTodos) {
const inserted = await insertReturning(`
INSERT INTO wr_todo (
source_type, meeting_id, project_id,
todo_title, todo_description, todo_status,
author_id, created_at, created_ip
) VALUES ('MEETING', $1, $2, $3, $4, 'PENDING', $5, NOW(), $6)
RETURNING todo_id
`, [
meetingId,
meeting.project_id,
todo.title,
`안건 ${todo.agendaNo}에서 추출`,
employeeId,
ip
])
createdTodos.push({ todoId: inserted.todo_id, title: todo.title })
}
}
// 상태 업데이트
await execute(`
UPDATE wr_meeting
SET ai_status = 'CONFIRMED', ai_confirmed_at = NOW()
WHERE meeting_id = $1
`, [meetingId])
return {
success: true,
message: `확정 완료. ${createdTodos.length}개의 TODO가 생성되었습니다.`,
createdTodos
}
})

View File

@@ -0,0 +1,22 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* 저장소 삭제 (비활성화)
* DELETE /api/repository/[id]
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const repoId = parseInt(event.context.params?.id || '0')
if (!repoId) {
throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' })
}
// 실제 삭제 대신 비활성화
await execute(`
UPDATE wr_repository SET is_active = false, updated_at = NOW() WHERE repo_id = $1
`, [repoId])
return { success: true }
})

View File

@@ -0,0 +1,64 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
import { getClientIp } from '../../../utils/ip'
interface UpdateRepoBody {
repoName?: string
repoPath?: string
repoUrl?: string
defaultBranch?: string
description?: string
isActive?: boolean
}
/**
* 저장소 수정
* PUT /api/repository/[id]
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const repoId = parseInt(event.context.params?.id || '0')
const body = await readBody<UpdateRepoBody>(event)
const ip = getClientIp(event)
if (!repoId) {
throw createError({ statusCode: 400, message: '저장소 ID가 필요합니다.' })
}
const updates: string[] = ['updated_at = NOW()', 'updated_ip = $1']
const values: any[] = [ip]
let idx = 2
if (body.repoName !== undefined) {
updates.push(`repo_name = $${idx++}`)
values.push(body.repoName)
}
if (body.repoPath !== undefined) {
updates.push(`repo_path = $${idx++}`)
values.push(body.repoPath)
}
if (body.repoUrl !== undefined) {
updates.push(`repo_url = $${idx++}`)
values.push(body.repoUrl)
}
if (body.defaultBranch !== undefined) {
updates.push(`default_branch = $${idx++}`)
values.push(body.defaultBranch)
}
if (body.description !== undefined) {
updates.push(`description = $${idx++}`)
values.push(body.description)
}
if (body.isActive !== undefined) {
updates.push(`is_active = $${idx++}`)
values.push(body.isActive)
}
values.push(repoId)
await execute(`
UPDATE wr_repository SET ${updates.join(', ')} WHERE repo_id = $${idx}
`, values)
return { success: true }
})

View File

@@ -0,0 +1,47 @@
import { insertReturning } from '../../utils/db'
import { requireAuth } from '../../utils/session'
import { getClientIp } from '../../utils/ip'
interface CreateRepoBody {
projectId: number
serverId: number
repoName: string
repoPath: string
repoUrl?: string
defaultBranch?: string
description?: string
}
/**
* 저장소 추가
* POST /api/repository/create
*/
export default defineEventHandler(async (event) => {
const employeeId = await requireAuth(event)
const body = await readBody<CreateRepoBody>(event)
const ip = getClientIp(event)
if (!body.projectId || !body.serverId || !body.repoPath) {
throw createError({ statusCode: 400, message: '필수 항목을 입력해주세요.' })
}
const repo = await insertReturning(`
INSERT INTO wr_repository (
project_id, server_id, repo_name, repo_path, repo_url,
default_branch, description, created_by, created_at, created_ip
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), $9)
RETURNING repo_id
`, [
body.projectId,
body.serverId,
body.repoName || body.repoPath,
body.repoPath,
body.repoUrl || null,
body.defaultBranch || 'main',
body.description || null,
employeeId,
ip
])
return { success: true, repoId: repo.repo_id }
})

View File

@@ -0,0 +1,23 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
/**
* TODO 완료 처리
* PUT /api/todo/[id]/complete
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const todoId = parseInt(event.context.params?.id || '0')
if (!todoId) {
throw createError({ statusCode: 400, message: 'TODO ID가 필요합니다.' })
}
await execute(`
UPDATE wr_todo
SET status = 'COMPLETED', completed_at = NOW(), updated_at = NOW()
WHERE todo_id = $1
`, [todoId])
return { success: true, message: '완료 처리되었습니다.' }
})

View File

@@ -0,0 +1,28 @@
import { execute } from '../../../utils/db'
import { requireAuth } from '../../../utils/session'
interface DiscardBody {
reason?: string
}
/**
* TODO 폐기 처리
* PUT /api/todo/[id]/discard
*/
export default defineEventHandler(async (event) => {
await requireAuth(event)
const todoId = parseInt(event.context.params?.id || '0')
const body = await readBody<DiscardBody>(event)
if (!todoId) {
throw createError({ statusCode: 400, message: 'TODO ID가 필요합니다.' })
}
await execute(`
UPDATE wr_todo
SET status = 'DISCARDED', discard_reason = $1, updated_at = NOW()
WHERE todo_id = $2
`, [body.reason || null, todoId])
return { success: true, message: '폐기 처리되었습니다.' }
})

View File

@@ -403,41 +403,56 @@ npm install @tiptap/vue-3 @tiptap/starter-kit @tiptap/extension-link @tiptap/ext
---
### Phase 2: AI 분석 연동 (2일)
- [ ] 시작:
- [ ] 완료:
- [ ] 소요시간:
### Phase 2: AI 분석 연동 (2일) ✅ 완료
- [x] 시작: 2026-01-12 00:00
- [x] 완료: 2026-01-12 00:05
- [x] 소요시간: 5분
**작업 내용:**
- [ ] 회의 내용 AI 분석 API (저장 시 자동 실행)
- [ ] AI 정리 결과 → 안건 + TODO 추출 로직
- [ ] 회의록 상세 화면 (원본 + AI 분석 결과)
- [ ] 분석 결과 확정 기능 (→ TODO 자동 생성)
- [x] 회의 내용 AI 분석 API (저장 시 자동 실행)
- [x] AI 정리 결과 → 안건 + TODO 추출 로직
- [x] 회의록 상세 화면 (원본 + AI 분석 결과)
- [x] 분석 결과 확정 기능 (→ TODO 자동 생성)
**생성된 파일:**
- backend/api/meeting/[id]/analyze.post.ts (AI 분석 API)
- backend/api/meeting/[id]/confirm.post.ts (확정 + TODO 생성)
- frontend/meeting/[id].vue (AI 분석 UI 추가)
---
### Phase 3: TODO 기능 (2일)
- [ ] 시작:
- [ ] 완료:
- [ ] 소요시간:
### Phase 3: TODO 기능 (2일) ✅ 완료
- [x] 시작: 2026-01-12 00:05
- [x] 완료: 2026-01-12 00:10
- [x] 소요시간: 5분
**작업 내용:**
- [ ] TODO CRUD API
- [ ] TODO 목록/상세 화면
- [ ] 상태 변경 기능 (대기/완료/폐기)
- [ ] 담당자 지정 기능
- [x] TODO CRUD API ✅ (기존 구현 + 보완)
- [x] TODO 목록/상세 화면 ✅ (기존 구현)
- [x] 상태 변경 기능 (대기/완료/폐기)
- [x] 담당자 지정 기능 ✅ (기존 구현)
**생성된 파일:**
- backend/api/todo/[id]/complete.put.ts (완료 처리)
- backend/api/todo/[id]/discard.put.ts (폐기 처리)
- frontend/todo/index.vue (완료/폐기 버튼 추가)
---
### Phase 4: 주간보고 연계 (1일)
- [ ] 시작:
- [ ] 완료:
- [ ] 소요시간:
### Phase 4: 주간보고 연계 (1일) ✅ 완료
- [x] 시작: 2026-01-12 00:10
- [x] 완료: 2026-01-12 00:15
- [x] 소요시간: 5분
**작업 내용:**
- [ ] 주간보고 작성 시 유사 TODO 감지 (AI)
- [ ] TODO 완료 연계 처리 (확인 후 업데이트)
- [ ] 테스트 및 버그 수정
- [x] 주간보고 작성 시 유사 TODO 감지 (AI) ✅ (기존 API 활용)
- [x] TODO 완료 연계 처리 (확인 후 업데이트)
- [x] 테스트 및 버그 수정
**생성/수정된 파일:**
- backend/api/todo/report/similar.post.ts (기존)
- backend/api/todo/report/link.post.ts (기존)
- frontend/report/weekly/write.vue (TODO 연계 모달 추가)
---
@@ -448,10 +463,10 @@ npm install @tiptap/vue-3 @tiptap/starter-kit @tiptap/extension-link @tiptap/ext
| Phase | 작업 내용 | 시작 | 완료 | 소요시간 |
|:-----:|----------|:----:|:----:|:--------:|
| 1 | 기본 구조 (DB, API, 화면) | 01-11 17:05 | 01-11 17:08 | 3분 ✅ |
| 2 | AI 분석 연동 | - | - | - |
| 3 | TODO 기능 | - | - | - |
| 4 | 주간보고 연계 | - | - | - |
| | | | **총 소요시간** | **-** |
| 2 | AI 분석 연동 | 01-12 00:00 | 01-12 00:05 | 5분 ✅ |
| 3 | TODO 기능 | 01-12 00:05 | 01-12 00:10 | 5분 ✅ |
| 4 | 주간보고 연계 | 01-12 00:10 | 01-12 00:15 | 5분 ✅ |
| | | | **총 소요시간** | **18분** |
---
@@ -459,16 +474,24 @@ npm install @tiptap/vue-3 @tiptap/starter-kit @tiptap/extension-link @tiptap/ext
| 구분 | 파일 | 작업 |
|------|------|:----:|
| **DB** | wr_meeting | 신규 테이블 |
| **DB** | wr_meeting_attendee | 신규 테이블 |
| **DB** | wr_meeting_agenda | 신규 테이블 |
| **DB** | wr_todo | 신규 테이블 |
| **API** | backend/api/meeting/*.ts | 신규 |
| **API** | backend/api/todo/*.ts | 신규 |
| **Frontend** | frontend/pages/meeting/*.vue | 신규 |
| **Frontend** | frontend/pages/todo/*.vue | 신규 |
| **Frontend** | frontend/components/editor/TiptapEditor.vue | 신규 |
| **Utils** | backend/utils/openai.ts | 수정 (프롬프트 추가) |
| **DB** | wr_meeting | 기존 테이블 |
| **DB** | wr_meeting_attendee | 기존 테이블 |
| **DB** | wr_meeting_agenda | 기존 테이블 |
| **DB** | wr_todo | 기존 테이블 |
| **API** | backend/api/meeting/list.get.ts | 신규 |
| **API** | backend/api/meeting/create.post.ts | 신규 |
| **API** | backend/api/meeting/[id]/detail.get.ts | 신규 |
| **API** | backend/api/meeting/[id]/update.put.ts | 신규 |
| **API** | backend/api/meeting/[id]/delete.delete.ts | 신규 |
| **API** | backend/api/meeting/[id]/analyze.post.ts | 신규 (P2) |
| **API** | backend/api/meeting/[id]/confirm.post.ts | 신규 (P2) |
| **API** | backend/api/todo/[id]/complete.put.ts | 신규 (P3) |
| **API** | backend/api/todo/[id]/discard.put.ts | 신규 (P3) |
| **Frontend** | frontend/meeting/index.vue | 신규 |
| **Frontend** | frontend/meeting/write.vue | 신규 |
| **Frontend** | frontend/meeting/[id].vue | 신규 + 수정 (P2) |
| **Frontend** | frontend/todo/index.vue | 수정 (P3) |
| **Frontend** | frontend/report/weekly/write.vue | 수정 (P4) |
---

View File

@@ -138,9 +138,10 @@
</div>
</div>
<!-- 오른쪽: 회의 내용 -->
<!-- 오른쪽: 회의 내용 + AI 분석 -->
<div class="col-md-8">
<div class="card h-100">
<!-- 회의 내용 -->
<div class="card mb-4">
<div class="card-header">
<strong>회의 내용</strong>
</div>
@@ -149,9 +150,9 @@
v-if="isEditing"
class="form-control border-0 h-100"
v-model="form.rawContent"
style="min-height: 500px; resize: none;"
style="min-height: 300px; resize: none;"
></textarea>
<div v-else class="p-3" style="min-height: 500px; white-space: pre-wrap;">{{ meeting.rawContent || '(내용 없음)' }}</div>
<div v-else class="p-3" style="min-height: 200px; white-space: pre-wrap;">{{ meeting.rawContent || '(내용 없음)' }}</div>
</div>
<div v-if="isEditing" class="card-footer d-flex justify-content-end">
<button class="btn btn-secondary me-2" @click="cancelEdit">취소</button>
@@ -163,6 +164,94 @@
</button>
</div>
</div>
<!-- AI 분석 결과 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-robot me-2"></i><strong>AI 분석 결과</strong>
<span v-if="meeting.aiStatus === 'CONFIRMED'" class="badge bg-success ms-2">확정됨</span>
<span v-else-if="meeting.aiStatus === 'PENDING'" class="badge bg-warning ms-2">미확정</span>
<span v-else class="badge bg-secondary ms-2">미분석</span>
</div>
<div>
<button
v-if="meeting.aiStatus !== 'CONFIRMED'"
class="btn btn-sm btn-outline-primary me-2"
@click="analyzeContent"
:disabled="isAnalyzing"
>
<span v-if="isAnalyzing"><span class="spinner-border spinner-border-sm me-1"></span></span>
<i v-else class="bi bi-magic me-1"></i>
{{ meeting.aiStatus === 'PENDING' ? '재분석' : 'AI 분석' }}
</button>
<button
v-if="meeting.aiStatus === 'PENDING' && aiResult"
class="btn btn-sm btn-success"
@click="confirmAnalysis"
:disabled="isConfirming"
>
<span v-if="isConfirming"><span class="spinner-border spinner-border-sm me-1"></span></span>
<i v-else class="bi bi-check-lg me-1"></i>확정하기
</button>
</div>
</div>
<div class="card-body">
<!-- 분석 결과 없음 -->
<div v-if="!aiResult" class="text-center text-muted py-4">
<i class="bi bi-robot display-4"></i>
<p class="mt-2">AI 분석을 실행해주세요.</p>
</div>
<!-- 분석 결과 표시 -->
<div v-else>
<!-- 요약 -->
<div class="alert alert-info mb-3">
<i class="bi bi-lightbulb me-2"></i>
<strong>요약:</strong> {{ aiResult.summary }}
</div>
<!-- 안건 목록 -->
<div v-for="agenda in aiResult.agendas" :key="agenda.no" class="border rounded p-3 mb-3">
<h6 class="mb-2">
<span class="badge bg-secondary me-2">{{ agenda.no }}</span>
{{ agenda.title }}
<span :class="getStatusBadge(agenda.status)" class="ms-2">{{ getStatusText(agenda.status) }}</span>
</h6>
<p class="text-muted small mb-2">{{ agenda.content }}</p>
<p v-if="agenda.decision" class="mb-2">
<i class="bi bi-check-circle text-success me-1"></i>
<strong>결정:</strong> {{ agenda.decision }}
</p>
<!-- TODO 항목 -->
<div v-if="agenda.todos && agenda.todos.length > 0" class="mt-2">
<div v-for="(todo, tIdx) in agenda.todos" :key="tIdx" class="form-check">
<input
v-if="meeting.aiStatus === 'PENDING'"
class="form-check-input"
type="checkbox"
:id="`todo-${agenda.no}-${tIdx}`"
v-model="selectedTodos"
:value="{ agendaNo: agenda.no, todoIndex: tIdx, title: todo.title, assignee: todo.assignee }"
/>
<label class="form-check-label" :for="`todo-${agenda.no}-${tIdx}`">
<i class="bi bi-check2-square text-primary me-1"></i>
{{ todo.title }}
<span v-if="todo.assignee" class="badge bg-light text-dark ms-1">@{{ todo.assignee }}</span>
</label>
<small class="d-block text-muted ms-4">{{ todo.reason }}</small>
</div>
</div>
</div>
<div v-if="meeting.aiStatus === 'PENDING'" class="alert alert-warning mb-0">
<i class="bi bi-info-circle me-2"></i>
TODO로 생성할 항목을 선택한 [확정하기] 클릭하세요.
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -241,6 +330,12 @@ const isLoading = ref(true)
const isEditing = ref(false)
const isSaving = ref(false)
// AI 분석 관련
const aiResult = ref<any>(null)
const isAnalyzing = ref(false)
const isConfirming = ref(false)
const selectedTodos = ref<any[]>([])
const form = ref({
meetingTitle: '',
meetingType: 'PROJECT' as 'PROJECT' | 'INTERNAL',
@@ -285,6 +380,20 @@ async function loadMeeting() {
attendees.value = res.attendees || []
agendas.value = res.agendas || []
// AI 분석 결과 로드
if (res.meeting.aiSummary) {
try {
aiResult.value = typeof res.meeting.aiSummary === 'string'
? JSON.parse(res.meeting.aiSummary)
: res.meeting.aiSummary
} catch (e) {
aiResult.value = null
}
} else {
aiResult.value = null
}
selectedTodos.value = []
// 폼 초기화
form.value = {
meetingTitle: res.meeting.meetingTitle,
@@ -428,6 +537,63 @@ function formatDate(dateStr: string) {
const d = new Date(dateStr)
return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`
}
// AI 분석
async function analyzeContent() {
if (!meeting.value?.rawContent) {
alert('분석할 회의 내용이 없습니다.')
return
}
isAnalyzing.value = true
try {
const res = await $fetch<{ result: any }>(`/api/meeting/${meetingId.value}/analyze`, { method: 'POST' })
aiResult.value = res.result
meeting.value.aiStatus = 'PENDING'
selectedTodos.value = []
alert('AI 분석이 완료되었습니다.')
} catch (e: any) {
alert(e.data?.message || 'AI 분석에 실패했습니다.')
} finally {
isAnalyzing.value = false
}
}
async function confirmAnalysis() {
if (selectedTodos.value.length === 0) {
if (!confirm('선택된 TODO가 없습니다. 그래도 확정하시겠습니까?')) return
}
isConfirming.value = true
try {
const res = await $fetch<{ message: string }>(`/api/meeting/${meetingId.value}/confirm`, {
method: 'POST',
body: { selectedTodos: selectedTodos.value }
})
meeting.value.aiStatus = 'CONFIRMED'
alert(res.message)
} catch (e: any) {
alert(e.data?.message || '확정에 실패했습니다.')
} finally {
isConfirming.value = false
}
}
function getStatusBadge(status: string) {
return {
'DECIDED': 'badge bg-success',
'PENDING': 'badge bg-warning',
'IN_PROGRESS': 'badge bg-info'
}[status] || 'badge bg-secondary'
}
function getStatusText(status: string) {
return {
'DECIDED': '결정됨',
'PENDING': '미결정',
'IN_PROGRESS': '진행중'
}[status] || status
}
</script>
<style scoped>

View File

@@ -130,7 +130,7 @@
</div>
<!-- PM/PL 이력 -->
<div class="card">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-person-badge me-2"></i>PM/PL 담당 이력</span>
<button class="btn btn-sm btn-primary" @click="showAssignModal = true">
@@ -161,6 +161,44 @@
</table>
</div>
</div>
<!-- 저장소 관리 -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="bi bi-git me-2"></i>저장소 관리</span>
<button class="btn btn-sm btn-primary" @click="openRepoModal()">
<i class="bi bi-plus"></i> 저장소 추가
</button>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered mb-0">
<thead class="table-light">
<tr>
<th style="width: 70px">타입</th>
<th>저장소명</th>
<th>경로</th>
<th style="width: 100px">브랜치</th>
<th style="width: 100px">작업</th>
</tr>
</thead>
<tbody>
<tr v-for="r in repositories" :key="r.repoId">
<td><span :class="r.serverType === 'GIT' ? 'badge bg-dark' : 'badge bg-warning text-dark'">{{ r.serverType }}</span></td>
<td>{{ r.repoName }}</td>
<td><small class="text-muted">{{ r.serverName }} &gt;</small> {{ r.repoPath }}</td>
<td>{{ r.defaultBranch || '-' }}</td>
<td>
<button class="btn btn-sm btn-outline-secondary me-1" @click="openRepoModal(r)" title="수정"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-outline-danger" @click="deleteRepo(r.repoId)" title="삭제"><i class="bi bi-trash"></i></button>
</td>
</tr>
<tr v-if="repositories.length === 0">
<td colspan="5" class="text-center text-muted py-3">등록된 저장소가 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="col-md-4">
@@ -237,6 +275,55 @@
</div>
</div>
<div class="modal-backdrop fade show" v-if="showAssignModal"></div>
<!-- 저장소 추가/수정 모달 -->
<div class="modal fade" :class="{ show: showRepoModal }" :style="{ display: showRepoModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ repoForm.repoId ? '저장소 수정' : '저장소 추가' }}</h5>
<button type="button" class="btn-close" @click="showRepoModal = false"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">VCS 서버 <span class="text-danger">*</span></label>
<select class="form-select" v-model="repoForm.serverId">
<option value="">선택하세요</option>
<option v-for="s in vcsServers" :key="s.serverId" :value="s.serverId">[{{ s.serverType }}] {{ s.serverName }}</option>
</select>
</div>
<div class="mb-3">
<label class="form-label">저장소명</label>
<input type="text" class="form-control" v-model="repoForm.repoName" placeholder="표시용 이름" />
</div>
<div class="mb-3">
<label class="form-label">저장소 경로 <span class="text-danger">*</span></label>
<input type="text" class="form-control" v-model="repoForm.repoPath" placeholder="/owner/repo.git 또는 /svn/project/trunk" />
</div>
<div class="mb-3">
<label class="form-label">전체 URL</label>
<input type="text" class="form-control" v-model="repoForm.repoUrl" placeholder="https://github.com/owner/repo.git" />
</div>
<div class="mb-3">
<label class="form-label">기본 브랜치 (Git)</label>
<input type="text" class="form-control" v-model="repoForm.defaultBranch" placeholder="main" />
</div>
<div class="mb-3">
<label class="form-label">설명</label>
<textarea class="form-control" v-model="repoForm.description" rows="2"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="showRepoModal = false">취소</button>
<button type="button" class="btn btn-primary" @click="saveRepo" :disabled="isSavingRepo">
<span v-if="isSavingRepo"><span class="spinner-border spinner-border-sm me-1"></span></span>
<i class="bi bi-check-lg me-1" v-else></i>저장
</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" v-if="showRepoModal"></div>
</div>
</template>
@@ -275,10 +362,25 @@ const assignForm = ref({
changeReason: ''
})
// 저장소 관련
const repositories = ref<any[]>([])
const vcsServers = ref<any[]>([])
const showRepoModal = ref(false)
const isSavingRepo = ref(false)
const repoForm = ref({
repoId: null as number | null,
serverId: '',
repoName: '',
repoPath: '',
repoUrl: '',
defaultBranch: 'main',
description: ''
})
onMounted(async () => {
const user = await fetchCurrentUser()
if (!user) { router.push('/login'); return }
await Promise.all([loadProject(), loadBusinesses(), loadEmployees()])
await Promise.all([loadProject(), loadBusinesses(), loadEmployees(), loadVcsServers(), loadRepositories()])
})
async function loadProject() {
@@ -310,6 +412,68 @@ async function loadEmployees() {
} catch (e) { console.error(e) }
}
async function loadVcsServers() {
try {
const res = await $fetch<{ servers: any[] }>('/api/vcs-server/list')
vcsServers.value = res.servers || []
} catch (e) { console.error(e) }
}
async function loadRepositories() {
try {
const res = await $fetch<{ repositories: any[] }>(`/api/repository/list?projectId=${route.params.id}`)
repositories.value = res.repositories || []
} catch (e) { console.error(e) }
}
function openRepoModal(repo?: any) {
if (repo) {
repoForm.value = {
repoId: repo.repoId,
serverId: repo.serverId?.toString() || '',
repoName: repo.repoName || '',
repoPath: repo.repoPath || '',
repoUrl: repo.repoUrl || '',
defaultBranch: repo.defaultBranch || 'main',
description: repo.description || ''
}
} else {
repoForm.value = { repoId: null, serverId: '', repoName: '', repoPath: '', repoUrl: '', defaultBranch: 'main', description: '' }
}
showRepoModal.value = true
}
async function saveRepo() {
if (!repoForm.value.serverId || !repoForm.value.repoPath) {
alert('VCS 서버와 저장소 경로는 필수입니다.')
return
}
isSavingRepo.value = true
try {
if (repoForm.value.repoId) {
await $fetch(`/api/repository/${repoForm.value.repoId}`, { method: 'PUT', body: repoForm.value })
} else {
await $fetch('/api/repository/create', { method: 'POST', body: { ...repoForm.value, projectId: Number(route.params.id) } })
}
showRepoModal.value = false
await loadRepositories()
} catch (e: any) {
alert(e.data?.message || '저장에 실패했습니다.')
} finally {
isSavingRepo.value = false
}
}
async function deleteRepo(repoId: number) {
if (!confirm('이 저장소를 삭제하시겠습니까?')) return
try {
await $fetch(`/api/repository/${repoId}`, { method: 'DELETE' })
await loadRepositories()
} catch (e: any) {
alert(e.data?.message || '삭제에 실패했습니다.')
}
}
function toggleEdit() {
if (!isEditing.value) {
form.value = {

View File

@@ -527,6 +527,43 @@
</div>
</div>
<div class="modal-backdrop fade show" v-if="showMaintenanceModal"></div>
<!-- TODO 연계 확인 모달 -->
<div class="modal fade" :class="{ show: showTodoLinkModal }" :style="{ display: showTodoLinkModal ? 'block' : 'none' }">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="bi bi-check2-square me-2 text-success"></i>TODO 완료 확인</h5>
<button type="button" class="btn-close" @click="closeTodoLinkModal"></button>
</div>
<div class="modal-body">
<p class="text-muted small">작성한 실적과 유사한 TODO가 발견되었습니다. 완료 처리할 항목을 선택하세요.</p>
<div class="list-group">
<label v-for="todo in similarTodos" :key="todo.todoId" class="list-group-item list-group-item-action">
<div class="d-flex align-items-start">
<input type="checkbox" class="form-check-input me-2 mt-1" v-model="todo.selected" />
<div class="flex-grow-1">
<div class="fw-bold">{{ todo.todoTitle }}</div>
<small class="text-muted">
{{ todo.projectName || '프로젝트 없음' }}
<span v-if="todo.dueDate"> · 마감: {{ todo.dueDate.split('T')[0] }}</span>
</small>
</div>
</div>
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeTodoLinkModal">건너뛰기</button>
<button type="button" class="btn btn-success" @click="completeTodos" :disabled="!hasSelectedTodos || isLinkingTodos">
<span v-if="isLinkingTodos" class="spinner-border spinner-border-sm me-1"></span>
<i v-else class="bi bi-check-lg me-1"></i>완료 처리
</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop fade show" v-if="showTodoLinkModal"></div>
</div>
</template>
@@ -599,6 +636,13 @@ const generatedTasks = ref<{ description: string; taskType: string; sourceTaskId
const selectedMaintenanceCount = computed(() => maintenanceTasks.value.filter(t => t.selected).length)
// TODO 연계 모달
const showTodoLinkModal = ref(false)
const similarTodos = ref<any[]>([])
const savedReportId = ref<number | null>(null)
const isLinkingTodos = ref(false)
const hasSelectedTodos = computed(() => similarTodos.value.some(t => t.selected))
const form = ref({
reportYear: new Date().getFullYear(),
reportWeek: 1,
@@ -958,7 +1002,7 @@ async function handleSubmit() {
isSaving.value = true
try {
await $fetch('/api/report/weekly/create', {
const res = await $fetch<{ reportId: number }>('/api/report/weekly/create', {
method: 'POST',
body: {
reportYear: form.value.reportYear,
@@ -977,8 +1021,17 @@ async function handleSubmit() {
remarkDescription: form.value.remarkDescription
}
})
toast.success('주간보고가 작성되었습니다.')
router.push('/report/weekly')
savedReportId.value = res.reportId
// 완료된 WORK 실적에 대해 유사 TODO 검색
const completedWorks = validTasks.filter(t => t.taskType === 'WORK' && t.isCompleted)
if (completedWorks.length > 0) {
await searchSimilarTodos(completedWorks)
} else {
router.push('/report/weekly')
}
} catch (e: any) {
toast.error(e.data?.message || '저장에 실패했습니다.')
} finally {
@@ -986,6 +1039,71 @@ async function handleSubmit() {
}
}
// === TODO 연계 기능 ===
async function searchSimilarTodos(completedWorks: TaskItem[]) {
try {
const allTodos: any[] = []
for (const work of completedWorks) {
const res = await $fetch<{ todos: any[] }>('/api/todo/report/similar', {
method: 'POST',
body: {
taskDescription: work.description,
projectId: work.projectId
}
})
if (res.todos && res.todos.length > 0) {
for (const todo of res.todos) {
if (!allTodos.find(t => t.todoId === todo.todoId)) {
allTodos.push({ ...todo, selected: true })
}
}
}
}
if (allTodos.length > 0) {
similarTodos.value = allTodos
showTodoLinkModal.value = true
} else {
router.push('/report/weekly')
}
} catch (e) {
console.error('TODO 검색 실패:', e)
router.push('/report/weekly')
}
}
function closeTodoLinkModal() {
showTodoLinkModal.value = false
router.push('/report/weekly')
}
async function completeTodos() {
const selected = similarTodos.value.filter(t => t.selected)
if (selected.length === 0) return
isLinkingTodos.value = true
try {
for (const todo of selected) {
await $fetch('/api/todo/report/link', {
method: 'POST',
body: {
todoId: todo.todoId,
weeklyReportId: savedReportId.value,
markCompleted: true
}
})
}
toast.success(`${selected.length}개의 TODO가 완료 처리되었습니다.`)
showTodoLinkModal.value = false
router.push('/report/weekly')
} catch (e: any) {
toast.error('TODO 완료 처리에 실패했습니다.')
} finally {
isLinkingTodos.value = false
}
}
// === textarea 자동 높이 조절 ===
function autoResize(e: Event) {
const textarea = e.target as HTMLTextAreaElement

View File

@@ -39,6 +39,8 @@
<label class="btn btn-outline-secondary btn-sm" for="statusProgress">진행</label>
<input type="radio" class="btn-check" name="status" id="statusCompleted" value="COMPLETED" v-model="filter.status" @change="loadTodos">
<label class="btn btn-outline-secondary btn-sm" for="statusCompleted">완료</label>
<input type="radio" class="btn-check" name="status" id="statusDiscarded" value="DISCARDED" v-model="filter.status" @change="loadTodos">
<label class="btn btn-outline-secondary btn-sm" for="statusDiscarded">폐기</label>
</div>
</div>
<div class="col-2">
@@ -146,7 +148,15 @@
</div>
</div>
<div class="modal-footer">
<button v-if="isEdit" type="button" class="btn btn-outline-danger me-auto" @click="deleteTodo">삭제</button>
<div class="me-auto" v-if="isEdit">
<button type="button" class="btn btn-success me-1" @click="completeTodo" v-if="form.status !== 'COMPLETED'">
<i class="bi bi-check-lg me-1"></i>완료
</button>
<button type="button" class="btn btn-outline-dark me-1" @click="discardTodo" v-if="form.status !== 'DISCARDED'">
<i class="bi bi-x-lg me-1"></i>폐기
</button>
<button type="button" class="btn btn-outline-danger" @click="deleteTodo">삭제</button>
</div>
<button type="button" class="btn btn-secondary" @click="showModal = false">취소</button>
<button type="button" class="btn btn-primary" @click="saveTodo" :disabled="isSaving">
{{ isSaving ? '저장 중...' : '저장' }}
@@ -315,11 +325,36 @@ async function deleteTodo() {
}
}
async function completeTodo() {
if (!editTodoId.value) return
try {
await $fetch(`/api/todo/${editTodoId.value}/complete`, { method: 'PUT' })
showModal.value = false
await loadTodos()
} catch (e: any) {
alert(e.data?.message || '완료 처리에 실패했습니다.')
}
}
async function discardTodo() {
if (!editTodoId.value) return
const reason = prompt('폐기 사유를 입력하세요 (선택):', '')
if (reason === null) return // 취소
try {
await $fetch(`/api/todo/${editTodoId.value}/discard`, { method: 'PUT', body: { reason } })
showModal.value = false
await loadTodos()
} catch (e: any) {
alert(e.data?.message || '폐기 처리에 실패했습니다.')
}
}
function getStatusBadge(status: string) {
const badges: Record<string, string> = {
PENDING: 'badge bg-secondary',
IN_PROGRESS: 'badge bg-primary',
COMPLETED: 'badge bg-success',
DISCARDED: 'badge bg-dark',
CANCELLED: 'badge bg-dark'
}
return badges[status] || 'badge bg-secondary'
@@ -330,6 +365,7 @@ function getStatusLabel(status: string) {
PENDING: '대기',
IN_PROGRESS: '진행',
COMPLETED: '완료',
DISCARDED: '폐기',
CANCELLED: '취소'
}
return labels[status] || status

24
package-lock.json generated
View File

@@ -803,6 +803,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1703,6 +1704,7 @@
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz",
"integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@floating-ui/core": "^1.7.3",
"@floating-ui/utils": "^0.2.10"
@@ -4992,6 +4994,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.15.3.tgz",
"integrity": "sha512-bmXydIHfm2rEtGju39FiQNfzkFx9CDvJe+xem1dgEZ2P6Dj7nQX9LnA1ZscW7TuzbBRkL5p3dwuBIi3f62A66A==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -5214,6 +5217,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.15.3.tgz",
"integrity": "sha512-n7y/MF9lAM5qlpuH5IR4/uq+kJPEJpe9NrEiH+NmkO/5KJ6cXzpJ6F4U17sMLf2SNCq+TWN9QK8QzoKxIn50VQ==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -5332,6 +5336,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.15.3.tgz",
"integrity": "sha512-ycx/BgxR4rc9tf3ZyTdI98Z19yKLFfqM3UN+v42ChuIwkzyr9zyp7kG8dB9xN2lNqrD+5y/HyJobz/VJ7T90gA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
@@ -5346,6 +5351,7 @@
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.15.3.tgz",
"integrity": "sha512-Zm1BaU1TwFi3CQiisxjgnzzIus+q40bBKWLqXf6WEaus8Z6+vo1MT2pU52dBCMIRaW9XNDq3E5cmGtMc1AlveA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-changeset": "^2.3.0",
"prosemirror-collab": "^1.3.1",
@@ -5471,6 +5477,7 @@
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -5736,6 +5743,7 @@
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
"integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.26",
@@ -5896,6 +5904,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6319,6 +6328,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -6426,6 +6436,7 @@
"resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
"integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -6516,6 +6527,7 @@
"resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz",
"integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"consola": "^3.2.3"
}
@@ -9826,6 +9838,7 @@
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.86.0.tgz",
"integrity": "sha512-v9+uomgqyLSxlq3qlaMqJJtXg2+rUsa368p/zkmgi5OMGmcZAtZt5GIeSVFF84iNET+08Hdx/rUtd/FyIdfNFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "^0.86.0"
},
@@ -10010,6 +10023,7 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.9.1",
"pg-pool": "^3.10.1",
@@ -10142,6 +10156,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -10790,6 +10805,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
"integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
"license": "MIT",
"peer": true,
"dependencies": {
"orderedmap": "^2.0.0"
}
@@ -10819,6 +10835,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
@@ -10879,6 +10896,7 @@
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.4.tgz",
"integrity": "sha512-WkKgnyjNncri03Gjaz3IFWvCAE94XoiEgvtr0/r2Xw7R8/IjK3sKLSiDoCHWcsXSAinVaKlGRZDvMCsF1kbzjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"prosemirror-model": "^1.20.0",
"prosemirror-state": "^1.0.0",
@@ -11109,6 +11127,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.55.1.tgz",
"integrity": "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -11955,6 +11974,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -12423,6 +12443,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -12793,6 +12814,7 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.26.tgz",
"integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.26",
"@vue/compiler-sfc": "3.5.26",
@@ -12829,6 +12851,7 @@
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
@@ -12940,6 +12963,7 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},