ㅋㅓ밋
This commit is contained in:
3
.env
3
.env
@@ -12,3 +12,6 @@ SESSION_SECRET=dev-secret-key-change-in-production
|
||||
# GOOGLE_CLIENT_ID=
|
||||
# GOOGLE_CLIENT_SECRET=
|
||||
# GOOGLE_REDIRECT_URI=http://localhost:3000/api/auth/callback
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
|
||||
204
backend/api/admin/bulk-register.post.ts
Normal file
204
backend/api/admin/bulk-register.post.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import { query, queryOne, insertReturning, execute } from '../../utils/db'
|
||||
import { getClientIp } from '../../utils/ip'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
interface ProjectInput {
|
||||
projectId: number | null // null이면 신규 생성
|
||||
projectName: string
|
||||
workDescription: string | null
|
||||
planDescription: string | null
|
||||
}
|
||||
|
||||
interface ReportInput {
|
||||
employeeId: number
|
||||
projects: ProjectInput[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
interface BulkRegisterBody {
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ReportInput[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 일괄 등록
|
||||
* POST /api/admin/bulk-register
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const currentUser = await queryOne<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
|
||||
if (!currentUser || currentUser.employee_email !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 사용할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<BulkRegisterBody>(event)
|
||||
const clientIp = getClientIp(event)
|
||||
|
||||
if (!body.reports || body.reports.length === 0) {
|
||||
throw createError({ statusCode: 400, message: '등록할 보고서가 없습니다.' })
|
||||
}
|
||||
|
||||
const results: any[] = []
|
||||
|
||||
for (const report of body.reports) {
|
||||
try {
|
||||
// 1. 프로젝트 처리 (신규 생성 또는 기존 사용)
|
||||
const projectIds: number[] = []
|
||||
|
||||
for (const proj of report.projects) {
|
||||
let projectId = proj.projectId
|
||||
|
||||
if (!projectId) {
|
||||
// 신규 프로젝트 생성
|
||||
const year = new Date().getFullYear()
|
||||
const lastProject = await queryOne<any>(`
|
||||
SELECT project_code FROM wr_project_info
|
||||
WHERE project_code LIKE $1
|
||||
ORDER BY project_code DESC LIMIT 1
|
||||
`, [`${year}-%`])
|
||||
|
||||
let nextNum = 1
|
||||
if (lastProject?.project_code) {
|
||||
const lastNum = parseInt(lastProject.project_code.split('-')[1]) || 0
|
||||
nextNum = lastNum + 1
|
||||
}
|
||||
const newCode = `${year}-${String(nextNum).padStart(3, '0')}`
|
||||
|
||||
const newProject = await insertReturning(`
|
||||
INSERT INTO wr_project_info (
|
||||
project_code, project_name, project_type,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, 'SI', $3, $4, $3, $4)
|
||||
RETURNING project_id
|
||||
`, [newCode, proj.projectName, clientIp, ADMIN_EMAIL])
|
||||
|
||||
projectId = newProject.project_id
|
||||
}
|
||||
|
||||
projectIds.push(projectId)
|
||||
}
|
||||
|
||||
// 2. 기존 주간보고 확인 (덮어쓰기)
|
||||
const existingReport = await queryOne<any>(`
|
||||
SELECT report_id FROM wr_weekly_report
|
||||
WHERE author_id = $1 AND report_year = $2 AND report_week = $3
|
||||
`, [report.employeeId, body.reportYear, body.reportWeek])
|
||||
|
||||
let reportId: number
|
||||
|
||||
if (existingReport) {
|
||||
// 기존 보고서 업데이트
|
||||
reportId = existingReport.report_id
|
||||
|
||||
// 기존 프로젝트 실적 삭제
|
||||
await execute(`DELETE FROM wr_weekly_report_project WHERE report_id = $1`, [reportId])
|
||||
|
||||
// 마스터 업데이트
|
||||
await execute(`
|
||||
UPDATE wr_weekly_report SET
|
||||
issue_description = $1,
|
||||
vacation_description = $2,
|
||||
remark_description = $3,
|
||||
report_status = 'SUBMITTED',
|
||||
submitted_at = NOW(),
|
||||
updated_at = NOW(),
|
||||
updated_ip = $4,
|
||||
updated_email = $5
|
||||
WHERE report_id = $6
|
||||
`, [
|
||||
report.issueDescription,
|
||||
report.vacationDescription,
|
||||
report.remarkDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL,
|
||||
reportId
|
||||
])
|
||||
} else {
|
||||
// 신규 보고서 생성
|
||||
const newReport = await insertReturning(`
|
||||
INSERT INTO wr_weekly_report (
|
||||
author_id, report_year, report_week,
|
||||
week_start_date, week_end_date,
|
||||
issue_description, vacation_description, remark_description,
|
||||
report_status, submitted_at,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'SUBMITTED', NOW(), $9, $10, $9, $10)
|
||||
RETURNING report_id
|
||||
`, [
|
||||
report.employeeId,
|
||||
body.reportYear,
|
||||
body.reportWeek,
|
||||
body.weekStartDate,
|
||||
body.weekEndDate,
|
||||
report.issueDescription,
|
||||
report.vacationDescription,
|
||||
report.remarkDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL
|
||||
])
|
||||
reportId = newReport.report_id
|
||||
}
|
||||
|
||||
// 3. 프로젝트별 실적 등록
|
||||
for (let i = 0; i < report.projects.length; i++) {
|
||||
const proj = report.projects[i]
|
||||
const projectId = projectIds[i]
|
||||
|
||||
await execute(`
|
||||
INSERT INTO wr_weekly_report_project (
|
||||
report_id, project_id, work_description, plan_description,
|
||||
created_ip, created_email, updated_ip, updated_email
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $5, $6)
|
||||
`, [
|
||||
reportId,
|
||||
projectId,
|
||||
proj.workDescription,
|
||||
proj.planDescription,
|
||||
clientIp,
|
||||
ADMIN_EMAIL
|
||||
])
|
||||
}
|
||||
|
||||
// 직원 정보 조회
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT employee_name FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [report.employeeId])
|
||||
|
||||
results.push({
|
||||
success: true,
|
||||
employeeId: report.employeeId,
|
||||
employeeName: employee?.employee_name,
|
||||
reportId,
|
||||
isUpdate: !!existingReport
|
||||
})
|
||||
|
||||
} catch (err: any) {
|
||||
results.push({
|
||||
success: false,
|
||||
employeeId: report.employeeId,
|
||||
error: err.message
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
totalCount: body.reports.length,
|
||||
successCount: results.filter(r => r.success).length,
|
||||
results
|
||||
}
|
||||
})
|
||||
142
backend/api/admin/parse-report.post.ts
Normal file
142
backend/api/admin/parse-report.post.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { query } from '../../utils/db'
|
||||
import { callOpenAI, buildParseReportPrompt } from '../../utils/openai'
|
||||
|
||||
const ADMIN_EMAIL = 'coziny@gmail.com'
|
||||
|
||||
interface ParsedProject {
|
||||
projectName: string
|
||||
workDescription: string | null
|
||||
planDescription: string | null
|
||||
}
|
||||
|
||||
interface ParsedReport {
|
||||
employeeName: string
|
||||
employeeEmail: string | null
|
||||
projects: ParsedProject[]
|
||||
issueDescription: string | null
|
||||
vacationDescription: string | null
|
||||
remarkDescription: string | null
|
||||
}
|
||||
|
||||
interface ParsedResult {
|
||||
reportYear: number
|
||||
reportWeek: number
|
||||
weekStartDate: string
|
||||
weekEndDate: string
|
||||
reports: ParsedReport[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 텍스트 분석 (OpenAI)
|
||||
* POST /api/admin/parse-report
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 관리자 권한 체크
|
||||
const userId = getCookie(event, 'user_id')
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
|
||||
const currentUser = await query<any>(`
|
||||
SELECT employee_email FROM wr_employee_info WHERE employee_id = $1
|
||||
`, [userId])
|
||||
|
||||
if (!currentUser[0] || currentUser[0].employee_email !== ADMIN_EMAIL) {
|
||||
throw createError({ statusCode: 403, message: '관리자만 사용할 수 있습니다.' })
|
||||
}
|
||||
|
||||
const body = await readBody<{ rawText: string }>(event)
|
||||
|
||||
if (!body.rawText || body.rawText.trim().length < 10) {
|
||||
throw createError({ statusCode: 400, message: '분석할 텍스트를 입력해주세요.' })
|
||||
}
|
||||
|
||||
// OpenAI 분석
|
||||
const messages = buildParseReportPrompt(body.rawText)
|
||||
const aiResponse = await callOpenAI(messages, true)
|
||||
|
||||
let parsed: ParsedResult
|
||||
try {
|
||||
parsed = JSON.parse(aiResponse)
|
||||
} catch (e) {
|
||||
throw createError({ statusCode: 500, message: 'AI 응답 파싱 실패' })
|
||||
}
|
||||
|
||||
// 기존 직원 목록 조회
|
||||
const employees = await query<any>(`
|
||||
SELECT employee_id, employee_name, employee_email
|
||||
FROM wr_employee_info
|
||||
WHERE is_active = true
|
||||
`)
|
||||
|
||||
// 기존 프로젝트 목록 조회
|
||||
const projects = await query<any>(`
|
||||
SELECT project_id, project_code, project_name
|
||||
FROM wr_project_info
|
||||
WHERE project_status != 'COMPLETED'
|
||||
`)
|
||||
|
||||
// 직원 매칭
|
||||
const matchedReports = parsed.reports.map(report => {
|
||||
// 이메일로 정확 매칭 시도
|
||||
let matchedEmployee = null
|
||||
if (report.employeeEmail) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_email.toLowerCase() === report.employeeEmail?.toLowerCase()
|
||||
)
|
||||
}
|
||||
// 이메일 매칭 실패시 이름으로 매칭
|
||||
if (!matchedEmployee) {
|
||||
matchedEmployee = employees.find(
|
||||
(e: any) => e.employee_name === report.employeeName
|
||||
)
|
||||
}
|
||||
|
||||
// 프로젝트 매칭
|
||||
const matchedProjects = report.projects.map(proj => {
|
||||
const existingProject = projects.find((p: any) =>
|
||||
p.project_name.includes(proj.projectName) ||
|
||||
proj.projectName.includes(p.project_name)
|
||||
)
|
||||
|
||||
return {
|
||||
...proj,
|
||||
matchedProjectId: existingProject?.project_id || null,
|
||||
matchedProjectCode: existingProject?.project_code || null,
|
||||
matchedProjectName: existingProject?.project_name || null,
|
||||
isNewProject: !existingProject
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
...report,
|
||||
matchedEmployeeId: matchedEmployee?.employee_id || null,
|
||||
matchedEmployeeName: matchedEmployee?.employee_name || null,
|
||||
matchedEmployeeEmail: matchedEmployee?.employee_email || null,
|
||||
isEmployeeMatched: !!matchedEmployee,
|
||||
projects: matchedProjects
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
parsed: {
|
||||
reportYear: parsed.reportYear,
|
||||
reportWeek: parsed.reportWeek,
|
||||
weekStartDate: parsed.weekStartDate,
|
||||
weekEndDate: parsed.weekEndDate,
|
||||
reports: matchedReports
|
||||
},
|
||||
// 선택용 목록
|
||||
employees: employees.map((e: any) => ({
|
||||
employeeId: e.employee_id,
|
||||
employeeName: e.employee_name,
|
||||
employeeEmail: e.employee_email
|
||||
})),
|
||||
projects: projects.map((p: any) => ({
|
||||
projectId: p.project_id,
|
||||
projectCode: p.project_code,
|
||||
projectName: p.project_name
|
||||
}))
|
||||
}
|
||||
})
|
||||
@@ -6,6 +6,8 @@ import { query } from '../../utils/db'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const userId = getCookie(event, 'user_id')
|
||||
const currentHistoryId = getCookie(event, 'login_history_id')
|
||||
|
||||
if (!userId) {
|
||||
throw createError({ statusCode: 401, message: '로그인이 필요합니다.' })
|
||||
}
|
||||
@@ -31,7 +33,8 @@ export default defineEventHandler(async (event) => {
|
||||
loginIp: h.login_ip,
|
||||
logoutAt: h.logout_at,
|
||||
logoutIp: h.logout_ip,
|
||||
lastActiveAt: h.last_active_at
|
||||
lastActiveAt: h.last_active_at,
|
||||
isCurrentSession: currentHistoryId && h.history_id === parseInt(currentHistoryId)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { queryOne } from '../../../utils/db'
|
||||
import { queryOne, query } from '../../../utils/db'
|
||||
|
||||
/**
|
||||
* 직원 상세 조회
|
||||
@@ -6,6 +6,7 @@ import { queryOne } from '../../../utils/db'
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const employeeId = getRouterParam(event, 'id')
|
||||
const currentHistoryId = getCookie(event, 'login_history_id')
|
||||
|
||||
const employee = await queryOne<any>(`
|
||||
SELECT * FROM wr_employee_info WHERE employee_id = $1
|
||||
@@ -15,6 +16,20 @@ export default defineEventHandler(async (event) => {
|
||||
throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' })
|
||||
}
|
||||
|
||||
// 로그인 이력 조회 (최근 20건)
|
||||
const loginHistory = await query<any>(`
|
||||
SELECT
|
||||
history_id,
|
||||
login_at,
|
||||
login_ip,
|
||||
logout_at,
|
||||
logout_ip
|
||||
FROM wr_login_history
|
||||
WHERE employee_id = $1
|
||||
ORDER BY login_at DESC
|
||||
LIMIT 20
|
||||
`, [employeeId])
|
||||
|
||||
return {
|
||||
employee: {
|
||||
employeeId: employee.employee_id,
|
||||
@@ -27,6 +42,15 @@ export default defineEventHandler(async (event) => {
|
||||
isActive: employee.is_active,
|
||||
createdAt: employee.created_at,
|
||||
updatedAt: employee.updated_at
|
||||
}
|
||||
},
|
||||
loginHistory: loginHistory.map(h => ({
|
||||
historyId: h.history_id,
|
||||
loginAt: h.login_at,
|
||||
loginIp: h.login_ip,
|
||||
logoutAt: h.logout_at,
|
||||
logoutIp: h.logout_ip,
|
||||
// 현재 세션인지 여부
|
||||
isCurrentSession: currentHistoryId && h.history_id === parseInt(currentHistoryId)
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
@@ -24,19 +24,21 @@ export default defineEventHandler(async (event) => {
|
||||
const pl = managers.find((m: any) => m.role_type === 'PL')
|
||||
|
||||
return {
|
||||
projectId: project.project_id,
|
||||
projectCode: project.project_code,
|
||||
projectName: project.project_name,
|
||||
projectType: project.project_type || 'SI',
|
||||
clientName: project.client_name,
|
||||
projectDescription: project.project_description,
|
||||
startDate: project.start_date,
|
||||
endDate: project.end_date,
|
||||
contractAmount: project.contract_amount,
|
||||
projectStatus: project.project_status,
|
||||
createdAt: project.created_at,
|
||||
updatedAt: project.updated_at,
|
||||
currentPm: pm ? { employeeId: pm.employee_id, employeeName: pm.employee_name } : null,
|
||||
currentPl: pl ? { employeeId: pl.employee_id, employeeName: pl.employee_name } : null
|
||||
project: {
|
||||
projectId: project.project_id,
|
||||
projectCode: project.project_code,
|
||||
projectName: project.project_name,
|
||||
projectType: project.project_type || 'SI',
|
||||
clientName: project.client_name,
|
||||
projectDescription: project.project_description,
|
||||
startDate: project.start_date,
|
||||
endDate: project.end_date,
|
||||
contractAmount: project.contract_amount,
|
||||
projectStatus: project.project_status,
|
||||
createdAt: project.created_at,
|
||||
updatedAt: project.updated_at,
|
||||
currentPm: pm ? { employeeId: pm.employee_id, employeeName: pm.employee_name } : null,
|
||||
currentPl: pl ? { employeeId: pl.employee_id, employeeName: pl.employee_name } : null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
101
backend/utils/openai.ts
Normal file
101
backend/utils/openai.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* OpenAI API 유틸리티
|
||||
*/
|
||||
|
||||
const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions'
|
||||
|
||||
interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface OpenAIResponse {
|
||||
choices: {
|
||||
message: {
|
||||
content: string
|
||||
}
|
||||
}[]
|
||||
}
|
||||
|
||||
export async function callOpenAI(messages: ChatMessage[], jsonMode = true): Promise<string> {
|
||||
const apiKey = process.env.OPENAI_API_KEY
|
||||
|
||||
if (!apiKey || apiKey === 'your-openai-api-key-here') {
|
||||
throw new Error('OPENAI_API_KEY가 설정되지 않았습니다.')
|
||||
}
|
||||
|
||||
const response = await fetch(OPENAI_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'gpt-4o-mini',
|
||||
messages,
|
||||
temperature: 0.1,
|
||||
...(jsonMode && { response_format: { type: 'json_object' } })
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text()
|
||||
throw new Error(`OpenAI API 오류: ${response.status} - ${error}`)
|
||||
}
|
||||
|
||||
const data = await response.json() as OpenAIResponse
|
||||
return data.choices[0].message.content
|
||||
}
|
||||
|
||||
/**
|
||||
* 주간보고 텍스트 분석 프롬프트
|
||||
*/
|
||||
export function buildParseReportPrompt(rawText: string): ChatMessage[] {
|
||||
return [
|
||||
{
|
||||
role: 'system',
|
||||
content: `당신은 주간업무보고 텍스트를 분석하여 구조화된 JSON으로 변환하는 전문가입니다.
|
||||
|
||||
입력된 텍스트에서 다음 정보를 추출하세요:
|
||||
1. 직원 정보 (이름, 이메일)
|
||||
2. 프로젝트별 실적 (프로젝트명, 금주실적, 차주계획)
|
||||
3. 공통사항 (이슈/리스크, 휴가일정, 기타사항)
|
||||
4. 보고 주차 정보 (텍스트에서 날짜나 주차 정보 추출)
|
||||
|
||||
반드시 아래 JSON 형식으로 응답하세요:
|
||||
{
|
||||
"reportYear": 2025,
|
||||
"reportWeek": 1,
|
||||
"weekStartDate": "2025-01-06",
|
||||
"weekEndDate": "2025-01-10",
|
||||
"reports": [
|
||||
{
|
||||
"employeeName": "홍길동",
|
||||
"employeeEmail": "hong@example.com",
|
||||
"projects": [
|
||||
{
|
||||
"projectName": "프로젝트명",
|
||||
"workDescription": "금주 실적 내용",
|
||||
"planDescription": "차주 계획 내용"
|
||||
}
|
||||
],
|
||||
"issueDescription": "이슈/리스크 내용 또는 null",
|
||||
"vacationDescription": "휴가 일정 또는 null",
|
||||
"remarkDescription": "기타 사항 또는 null"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
주의사항:
|
||||
- 이메일이 없으면 employeeEmail은 null로
|
||||
- 프로젝트가 여러개면 projects 배열에 모두 포함
|
||||
- 날짜 형식은 YYYY-MM-DD
|
||||
- 주차 정보가 없으면 현재 날짜 기준으로 추정
|
||||
- 실적/계획이 명확히 구분 안되면 workDescription에 통합`
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: rawText
|
||||
}
|
||||
]
|
||||
}
|
||||
329
frontend/admin/bulk-import.vue
Normal file
329
frontend/admin/bulk-import.vue
Normal file
@@ -0,0 +1,329 @@
|
||||
<template>
|
||||
<div>
|
||||
<AppHeader />
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<h4 class="mb-4">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>주간보고 일괄등록
|
||||
</h4>
|
||||
|
||||
<!-- Step 1: 텍스트 입력 -->
|
||||
<div class="card mb-4" v-if="step === 1">
|
||||
<div class="card-header">
|
||||
<strong>1단계:</strong> 주간보고 내용 붙여넣기
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">직원들의 주간보고 내용을 붙여넣으세요</label>
|
||||
<textarea
|
||||
class="form-control font-monospace"
|
||||
v-model="rawText"
|
||||
rows="15"
|
||||
placeholder="예시:
|
||||
홍길동 (hong@turbosoft.co.kr)
|
||||
- PIMS 고도화: API 개발 완료
|
||||
- 차주: 테스트 진행
|
||||
- 이슈: 서버 메모리 부족
|
||||
|
||||
김철수 (kim@turbosoft.co.kr)
|
||||
- I-PIMS 유지보수: 버그수정 3건
|
||||
- 휴가: 1/10(금) 연차"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="parseReport"
|
||||
:disabled="isParsing || !rawText.trim()"
|
||||
>
|
||||
<span v-if="isParsing" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-robot me-1"></i>
|
||||
AI 분석
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: 분석 결과 확인 -->
|
||||
<div v-if="step === 2">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<span><strong>2단계:</strong> 분석 결과 확인 및 수정</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @click="step = 1">
|
||||
<i class="bi bi-arrow-left me-1"></i>다시 입력
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 주차 정보 -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">연도</label>
|
||||
<input type="number" class="form-control" v-model="parsedData.reportYear" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">주차</label>
|
||||
<input type="number" class="form-control" v-model="parsedData.reportWeek" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">시작일</label>
|
||||
<input type="date" class="form-control" v-model="parsedData.weekStartDate" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">종료일</label>
|
||||
<input type="date" class="form-control" v-model="parsedData.weekEndDate" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<!-- 직원별 보고서 -->
|
||||
<div v-for="(report, rIdx) in parsedData.reports" :key="rIdx" class="card mb-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center"
|
||||
:class="report.isEmployeeMatched ? 'bg-light' : 'bg-warning bg-opacity-25'">
|
||||
<div>
|
||||
<span v-if="report.isEmployeeMatched" class="badge bg-success me-2">매칭됨</span>
|
||||
<span v-else class="badge bg-warning text-dark me-2">매칭필요</span>
|
||||
<strong>{{ report.employeeName }}</strong>
|
||||
<small class="text-muted ms-2">{{ report.employeeEmail || '이메일 없음' }}</small>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" v-model="report.enabled" :id="'chk-'+rIdx" />
|
||||
<label class="form-check-label" :for="'chk-'+rIdx">등록</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body" v-if="report.enabled">
|
||||
<!-- 직원 선택 -->
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">직원 선택 <span class="text-danger">*</span></label>
|
||||
<select class="form-select" v-model="report.matchedEmployeeId"
|
||||
:class="{'is-invalid': !report.matchedEmployeeId}">
|
||||
<option :value="null">-- 선택 --</option>
|
||||
<option v-for="emp in employees" :key="emp.employeeId" :value="emp.employeeId">
|
||||
{{ emp.employeeName }} ({{ emp.employeeEmail }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트별 실적 -->
|
||||
<div v-for="(proj, pIdx) in report.projects" :key="pIdx" class="border rounded p-3 mb-2">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">프로젝트</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" v-model="proj.matchedProjectId">
|
||||
<option :value="null">➕ 신규 생성</option>
|
||||
<option v-for="p in projects" :key="p.projectId" :value="p.projectId">
|
||||
{{ p.projectCode }} - {{ p.projectName }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<input
|
||||
v-if="!proj.matchedProjectId"
|
||||
type="text"
|
||||
class="form-control mt-2"
|
||||
v-model="proj.projectName"
|
||||
placeholder="신규 프로젝트명"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">금주 실적</label>
|
||||
<textarea class="form-control" v-model="proj.workDescription" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">차주 계획</label>
|
||||
<textarea class="form-control" v-model="proj.planDescription" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 공통사항 -->
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">이슈/리스크</label>
|
||||
<textarea class="form-control" v-model="report.issueDescription" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">휴가일정</label>
|
||||
<textarea class="form-control" v-model="report.vacationDescription" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">기타사항</label>
|
||||
<textarea class="form-control" v-model="report.remarkDescription" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-outline-secondary" @click="step = 1">취소</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click="bulkRegister"
|
||||
:disabled="isRegistering || !canRegister"
|
||||
>
|
||||
<span v-if="isRegistering" class="spinner-border spinner-border-sm me-1"></span>
|
||||
<i v-else class="bi bi-check-lg me-1"></i>
|
||||
일괄 등록 ({{ enabledCount }}건)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: 결과 -->
|
||||
<div class="card" v-if="step === 3">
|
||||
<div class="card-header bg-success text-white">
|
||||
<i class="bi bi-check-circle me-2"></i>등록 완료
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="mb-3">
|
||||
총 <strong>{{ registerResult.totalCount }}</strong>건 중
|
||||
<strong class="text-success">{{ registerResult.successCount }}</strong>건 등록 완료
|
||||
</p>
|
||||
<ul class="list-group">
|
||||
<li v-for="(r, idx) in registerResult.results" :key="idx"
|
||||
class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<span>
|
||||
<i :class="r.success ? 'bi bi-check-circle text-success' : 'bi bi-x-circle text-danger'" class="me-2"></i>
|
||||
{{ r.employeeName }}
|
||||
</span>
|
||||
<span v-if="r.success" class="badge" :class="r.isUpdate ? 'bg-warning' : 'bg-success'">
|
||||
{{ r.isUpdate ? '덮어쓰기' : '신규등록' }}
|
||||
</span>
|
||||
<span v-else class="text-danger small">{{ r.error }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-4">
|
||||
<button class="btn btn-primary" @click="reset">
|
||||
<i class="bi bi-plus-lg me-1"></i>추가 등록
|
||||
</button>
|
||||
<NuxtLink to="/report/weekly" class="btn btn-outline-secondary ms-2">
|
||||
주간보고 목록
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const { fetchCurrentUser } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const step = ref(1)
|
||||
const rawText = ref('')
|
||||
const isParsing = ref(false)
|
||||
const isRegistering = ref(false)
|
||||
|
||||
const parsedData = ref<any>({
|
||||
reportYear: new Date().getFullYear(),
|
||||
reportWeek: 1,
|
||||
weekStartDate: '',
|
||||
weekEndDate: '',
|
||||
reports: []
|
||||
})
|
||||
|
||||
const employees = ref<any[]>([])
|
||||
const projects = ref<any[]>([])
|
||||
const registerResult = ref<any>({})
|
||||
|
||||
const enabledCount = computed(() =>
|
||||
parsedData.value.reports?.filter((r: any) => r.enabled).length || 0
|
||||
)
|
||||
|
||||
const canRegister = computed(() => {
|
||||
const enabledReports = parsedData.value.reports?.filter((r: any) => r.enabled) || []
|
||||
return enabledReports.length > 0 && enabledReports.every((r: any) => r.matchedEmployeeId)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const user = await fetchCurrentUser()
|
||||
if (!user) {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 관리자 체크
|
||||
if (user.employeeEmail !== 'coziny@gmail.com') {
|
||||
alert('관리자만 접근할 수 있습니다.')
|
||||
router.push('/')
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
async function parseReport() {
|
||||
isParsing.value = true
|
||||
try {
|
||||
const res = await $fetch<any>('/api/admin/parse-report', {
|
||||
method: 'POST',
|
||||
body: { rawText: rawText.value }
|
||||
})
|
||||
|
||||
parsedData.value = res.parsed
|
||||
parsedData.value.reports.forEach((r: any) => r.enabled = true)
|
||||
employees.value = res.employees
|
||||
projects.value = res.projects
|
||||
step.value = 2
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || 'AI 분석에 실패했습니다.')
|
||||
} finally {
|
||||
isParsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function bulkRegister() {
|
||||
isRegistering.value = true
|
||||
try {
|
||||
const enabledReports = parsedData.value.reports
|
||||
.filter((r: any) => r.enabled)
|
||||
.map((r: any) => ({
|
||||
employeeId: r.matchedEmployeeId,
|
||||
projects: r.projects.map((p: any) => ({
|
||||
projectId: p.matchedProjectId,
|
||||
projectName: p.projectName,
|
||||
workDescription: p.workDescription,
|
||||
planDescription: p.planDescription
|
||||
})),
|
||||
issueDescription: r.issueDescription,
|
||||
vacationDescription: r.vacationDescription,
|
||||
remarkDescription: r.remarkDescription
|
||||
}))
|
||||
|
||||
const res = await $fetch<any>('/api/admin/bulk-register', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
reportYear: parsedData.value.reportYear,
|
||||
reportWeek: parsedData.value.reportWeek,
|
||||
weekStartDate: parsedData.value.weekStartDate,
|
||||
weekEndDate: parsedData.value.weekEndDate,
|
||||
reports: enabledReports
|
||||
}
|
||||
})
|
||||
|
||||
registerResult.value = res
|
||||
step.value = 3
|
||||
} catch (e: any) {
|
||||
alert(e.data?.message || e.message || '등록에 실패했습니다.')
|
||||
} finally {
|
||||
isRegistering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
step.value = 1
|
||||
rawText.value = ''
|
||||
parsedData.value = {
|
||||
reportYear: new Date().getFullYear(),
|
||||
reportWeek: 1,
|
||||
weekStartDate: '',
|
||||
weekEndDate: '',
|
||||
reports: []
|
||||
}
|
||||
registerResult.value = {}
|
||||
}
|
||||
</script>
|
||||
@@ -37,6 +37,19 @@
|
||||
<i class="bi bi-people me-1"></i> 직원관리
|
||||
</NuxtLink>
|
||||
</li>
|
||||
<!-- 관리자 메뉴 (coziny@gmail.com 전용) -->
|
||||
<li class="nav-item dropdown" v-if="isAdmin">
|
||||
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown">
|
||||
<i class="bi bi-gear me-1"></i> 관리자
|
||||
</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li>
|
||||
<NuxtLink class="dropdown-item" to="/admin/bulk-import">
|
||||
<i class="bi bi-file-earmark-arrow-up me-2"></i>주간보고 일괄등록
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- 사용자 정보 -->
|
||||
@@ -58,6 +71,8 @@
|
||||
const { currentUser, logout } = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const isAdmin = computed(() => currentUser.value?.employeeEmail === 'coziny@gmail.com')
|
||||
|
||||
async function handleLogout() {
|
||||
await logout()
|
||||
router.push('/login')
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
<div class="row" v-if="employee">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-person me-2"></i>직원 정보
|
||||
@@ -87,22 +87,58 @@
|
||||
</div>
|
||||
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<!-- 기본 활동 정보 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history me-2"></i>활동 이력
|
||||
<i class="bi bi-info-circle me-2"></i>기본 정보
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted">최근 로그인</h6>
|
||||
<p class="mb-0">{{ employee.lastLoginAt ? formatDateTime(employee.lastLoginAt) : '-' }}</p>
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<small class="text-muted d-block">등록일</small>
|
||||
<span>{{ formatDateTime(employee.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<small class="text-muted d-block">최종 수정</small>
|
||||
<span>{{ formatDateTime(employee.updatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h6 class="text-muted">등록일</h6>
|
||||
<p class="mb-0">{{ formatDateTime(employee.createdAt) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="text-muted">최종 수정</h6>
|
||||
<p class="mb-0">{{ formatDateTime(employee.updatedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로그인 이력 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-clock-history me-2"></i>로그인 이력
|
||||
<small class="text-muted ms-2">(최근 20건)</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>로그인 시간</th>
|
||||
<th>IP</th>
|
||||
<th>로그아웃</th>
|
||||
<th style="width: 80px">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loginHistory.length === 0">
|
||||
<td colspan="4" class="text-center text-muted py-3">로그인 이력이 없습니다.</td>
|
||||
</tr>
|
||||
<tr v-for="h in loginHistory" :key="h.historyId">
|
||||
<td>{{ formatDateTime(h.loginAt) }}</td>
|
||||
<td><code class="small">{{ h.loginIp || '-' }}</code></td>
|
||||
<td>{{ h.logoutAt ? formatDateTime(h.logoutAt) : '-' }}</td>
|
||||
<td>
|
||||
<span v-if="h.logoutAt" class="badge bg-secondary">로그아웃</span>
|
||||
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속중</span>
|
||||
<span v-else class="badge bg-warning text-dark">세션만료</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -122,6 +158,7 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const employee = ref<any>(null)
|
||||
const loginHistory = ref<any[]>([])
|
||||
const isLoading = ref(true)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
@@ -148,8 +185,9 @@ onMounted(async () => {
|
||||
async function loadEmployee() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const res = await $fetch<{ employee: any }>(`/api/employee/${route.params.id}/detail`)
|
||||
const res = await $fetch<{ employee: any, loginHistory: any[] }>(`/api/employee/${route.params.id}/detail`)
|
||||
employee.value = res.employee
|
||||
loginHistory.value = res.loginHistory || []
|
||||
|
||||
const e = res.employee
|
||||
form.value = {
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
<td><strong>{{ emp.employeeName }}</strong></td>
|
||||
<td>{{ emp.employeeEmail }}</td>
|
||||
<td>{{ emp.company || '-' }}</td>
|
||||
<td>{{ emp.employeeEmail }}</td>
|
||||
<td>{{ emp.employeePosition || '-' }}</td>
|
||||
<td>
|
||||
<span :class="emp.isActive !== false ? 'badge bg-success' : 'badge bg-secondary'">
|
||||
|
||||
@@ -153,7 +153,8 @@
|
||||
<td><code>{{ h.logoutIp || '-' }}</code></td>
|
||||
<td>
|
||||
<span v-if="h.logoutAt" class="badge bg-secondary">로그아웃</span>
|
||||
<span v-else class="badge bg-success">로그인 중</span>
|
||||
<span v-else-if="h.isCurrentSession" class="badge bg-success">접속중</span>
|
||||
<span v-else class="badge bg-warning text-dark">세션만료</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
35
package-lock.json
generated
35
package-lock.json
generated
@@ -49,7 +49,6 @@
|
||||
"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",
|
||||
@@ -3142,7 +3141,6 @@
|
||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -3391,7 +3389,6 @@
|
||||
"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",
|
||||
@@ -3555,7 +3552,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -3900,7 +3896,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -4008,7 +4003,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -4074,7 +4068,6 @@
|
||||
"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"
|
||||
}
|
||||
@@ -4216,16 +4209,6 @@
|
||||
"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
||||
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/commondir": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
|
||||
@@ -6078,7 +6061,6 @@
|
||||
"integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.25.4",
|
||||
"@babel/types": "^7.25.4",
|
||||
@@ -7494,7 +7476,6 @@
|
||||
"resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.102.0.tgz",
|
||||
"integrity": "sha512-xMiyHgr2FZsphQ12ZCsXRvSYzmKXCm1ejmyG4GDZIiKOmhyt5iKtWq0klOfFsEQ6jcgbwrUdwcCVYzr1F+h5og==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "^0.102.0"
|
||||
},
|
||||
@@ -7679,7 +7660,6 @@
|
||||
"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",
|
||||
@@ -7812,7 +7792,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -8564,7 +8543,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.54.0.tgz",
|
||||
"integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -9209,6 +9187,15 @@
|
||||
"url": "https://opencollective.com/svgo"
|
||||
}
|
||||
},
|
||||
"node_modules/svgo/node_modules/commander": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
|
||||
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/system-architecture": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz",
|
||||
@@ -9416,7 +9403,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -9862,7 +9848,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.0.tgz",
|
||||
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -10225,7 +10210,6 @@
|
||||
"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",
|
||||
@@ -10262,7 +10246,6 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user