diff --git a/backend/api/dashboard/stats.get.ts b/backend/api/dashboard/stats.get.ts new file mode 100644 index 0000000..063caf8 --- /dev/null +++ b/backend/api/dashboard/stats.get.ts @@ -0,0 +1,112 @@ +import { query } from '../../utils/db' + +/** + * 대시보드 통계 API + * GET /api/dashboard/stats + * + * - 인원별 이번 주 실적/차주 계획 시간 + * - 프로젝트별 투입 인원/시간 + * - 제출 현황 + */ +export default defineEventHandler(async (event) => { + const userId = getCookie(event, 'user_id') + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + const q = getQuery(event) + const year = parseInt(q.year as string) || new Date().getFullYear() + const week = parseInt(q.week as string) || getISOWeek(new Date()) + + // 1. 인원별 실적/계획 현황 + const employeeStats = await query(` + SELECT + e.employee_id, + e.employee_name, + e.company, + r.report_id, + r.report_status, + COALESCE(SUM(CASE WHEN t.task_type = 'WORK' THEN t.task_hours ELSE 0 END), 0) as work_hours, + COALESCE(SUM(CASE WHEN t.task_type = 'PLAN' THEN t.task_hours ELSE 0 END), 0) as plan_hours, + COUNT(DISTINCT CASE WHEN t.task_type = 'WORK' THEN t.project_id END) as work_project_count, + COUNT(DISTINCT CASE WHEN t.task_type = 'PLAN' THEN t.project_id END) as plan_project_count + FROM wr_employee_info e + LEFT JOIN wr_weekly_report r ON e.employee_id = r.author_id + AND r.report_year = $1 AND r.report_week = $2 + LEFT JOIN wr_weekly_report_task t ON r.report_id = t.report_id + WHERE e.is_active = true + GROUP BY e.employee_id, e.employee_name, e.company, r.report_id, r.report_status + ORDER BY work_hours DESC, e.employee_name + `, [year, week]) + + // 2. 프로젝트별 투입 현황 + const projectStats = await query(` + SELECT + p.project_id, + p.project_code, + p.project_name, + COUNT(DISTINCT r.author_id) as member_count, + COALESCE(SUM(CASE WHEN t.task_type = 'WORK' THEN t.task_hours ELSE 0 END), 0) as work_hours, + COALESCE(SUM(CASE WHEN t.task_type = 'PLAN' THEN t.task_hours ELSE 0 END), 0) as plan_hours, + ARRAY_AGG(DISTINCT e.employee_name) as members + FROM wr_project_info p + JOIN wr_weekly_report_task t ON p.project_id = t.project_id + 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 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) + + return { + year, + week, + summary: { + activeEmployees, + submittedCount, + notSubmittedCount: activeEmployees - submittedCount, + totalWorkHours, + totalPlanHours, + projectCount: projectStats.length + }, + employees: employeeStats.map((e: any) => ({ + employeeId: e.employee_id, + employeeName: e.employee_name, + company: e.company, + reportId: e.report_id, + reportStatus: e.report_status, + workHours: parseFloat(e.work_hours) || 0, + planHours: parseFloat(e.plan_hours) || 0, + workProjectCount: parseInt(e.work_project_count) || 0, + planProjectCount: parseInt(e.plan_project_count) || 0, + isSubmitted: !!e.report_id + })), + projects: projectStats.map((p: any) => ({ + projectId: p.project_id, + projectCode: p.project_code, + projectName: p.project_name, + memberCount: parseInt(p.member_count) || 0, + workHours: parseFloat(p.work_hours) || 0, + planHours: parseFloat(p.plan_hours) || 0, + members: p.members || [] + })) + } +}) + +function getISOWeek(date: Date): number { + const target = new Date(date) + target.setHours(0, 0, 0, 0) + const thursday = new Date(target) + thursday.setDate(target.getDate() - ((target.getDay() + 6) % 7) + 3) + const year = thursday.getFullYear() + const firstThursday = new Date(year, 0, 4) + firstThursday.setDate(firstThursday.getDate() - ((firstThursday.getDay() + 6) % 7) + 3) + return Math.ceil((thursday.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)) + 1 +} diff --git a/backend/api/employee/[id]/delete.delete.ts b/backend/api/employee/[id]/delete.delete.ts new file mode 100644 index 0000000..d019adb --- /dev/null +++ b/backend/api/employee/[id]/delete.delete.ts @@ -0,0 +1,74 @@ +import { query, execute } from '../../../utils/db' + +const ADMIN_EMAIL = 'coziny@gmail.com' + +/** + * 직원 삭제 + * DELETE /api/employee/[id] + */ +export default defineEventHandler(async (event) => { + const userId = getCookie(event, 'user_id') + if (!userId) { + throw createError({ statusCode: 401, message: '로그인이 필요합니다.' }) + } + + // 관리자 권한 체크 + const currentUser = await query(` + 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 employeeId = getRouterParam(event, 'id') + if (!employeeId) { + throw createError({ statusCode: 400, message: '직원 ID가 필요합니다.' }) + } + + // 본인 삭제 방지 + if (employeeId === userId) { + throw createError({ statusCode: 400, message: '본인은 삭제할 수 없습니다.' }) + } + + // 직원 존재 여부 확인 + const employee = await query(` + SELECT employee_id, employee_name FROM wr_employee_info WHERE employee_id = $1 + `, [employeeId]) + + if (!employee[0]) { + throw createError({ statusCode: 404, message: '직원을 찾을 수 없습니다.' }) + } + + // 주간보고 존재 여부 확인 + const reports = await query(` + SELECT COUNT(*) as cnt FROM wr_weekly_report WHERE author_id = $1 + `, [employeeId]) + + const reportCount = parseInt(reports[0].cnt) + + if (reportCount > 0) { + // 주간보고가 있으면 비활성화만 + await execute(` + UPDATE wr_employee_info + SET is_active = false, updated_at = NOW() + WHERE employee_id = $1 + `, [employeeId]) + + return { + success: true, + action: 'deactivated', + message: `${employee[0].employee_name}님이 비활성화되었습니다. (주간보고 ${reportCount}건 보존)` + } + } else { + // 주간보고가 없으면 완전 삭제 (로그인 이력 포함) + await execute(`DELETE FROM wr_login_history WHERE employee_id = $1`, [employeeId]) + await execute(`DELETE FROM wr_employee_info WHERE employee_id = $1`, [employeeId]) + + return { + success: true, + action: 'deleted', + message: `${employee[0].employee_name}님이 삭제되었습니다.` + } + } +}) diff --git a/backend/api/report/summary/weekly-list.get.ts b/backend/api/report/summary/weekly-list.get.ts index e28c49a..5776272 100644 --- a/backend/api/report/summary/weekly-list.get.ts +++ b/backend/api/report/summary/weekly-list.get.ts @@ -14,8 +14,6 @@ export default defineEventHandler(async (event) => { SELECT s.report_year, s.report_week, - MIN(s.week_start_date) as week_start_date, - MAX(s.week_end_date) as week_end_date, COUNT(DISTINCT s.project_id) as project_count, SUM(s.member_count) as total_members, SUM(COALESCE(s.total_work_hours, 0)) as total_work_hours, @@ -29,16 +27,50 @@ export default defineEventHandler(async (event) => { `, [year]) return { - weeks: rows.map((r: any) => ({ - reportYear: r.report_year, - reportWeek: r.report_week, - weekStartDate: r.week_start_date, - weekEndDate: r.week_end_date, - projectCount: parseInt(r.project_count), - totalMembers: parseInt(r.total_members), - totalWorkHours: parseFloat(r.total_work_hours) || 0, - latestAggregatedAt: r.latest_aggregated_at, - projects: r.project_names || [] - })) + weeks: rows.map((r: any) => { + const { monday, sunday } = getWeekDates(r.report_year, r.report_week) + return { + reportYear: r.report_year, + reportWeek: r.report_week, + weekStartDate: monday, + weekEndDate: sunday, + projectCount: parseInt(r.project_count), + totalMembers: parseInt(r.total_members), + totalWorkHours: parseFloat(r.total_work_hours) || 0, + latestAggregatedAt: r.latest_aggregated_at, + projects: r.project_names || [] + } + }) } }) + +// ISO 8601 주차 기준 월요일~일요일 계산 +function getWeekDates(year: number, week: number): { monday: string, sunday: string } { + // 해당 연도의 1월 4일이 속한 주가 1주차 (ISO 8601) + 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: 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}` +} diff --git a/backend/api/report/weekly/list.get.ts b/backend/api/report/weekly/list.get.ts index e9f6437..190cfa5 100644 --- a/backend/api/report/weekly/list.get.ts +++ b/backend/api/report/weekly/list.get.ts @@ -71,6 +71,12 @@ export default defineEventHandler(async (event) => { params.push(q.year) } + // 주차 필터 (단일) + if (q.week) { + conditions.push(`r.report_week = $${paramIndex++}`) + params.push(q.week) + } + // 주차 범위 필터 if (q.weekFrom) { conditions.push(`r.report_week >= $${paramIndex++}`) diff --git a/frontend/composables/useWeekCalc.ts b/frontend/composables/useWeekCalc.ts index a6ca404..0f19cf7 100644 --- a/frontend/composables/useWeekCalc.ts +++ b/frontend/composables/useWeekCalc.ts @@ -50,9 +50,29 @@ export function useWeekCalc() { } /** - * 이번 주 정보 + * 이번 주 정보 (보고서 기준) + * - 금~일: 현재 주차 + * - 월~목: 이전 주차 */ function getCurrentWeekInfo(): WeekInfo { + const today = new Date() + const dayOfWeek = today.getDay() // 0=일, 1=월, ..., 5=금, 6=토 + + // 월~목 (1~4)이면 이전 주차 기준 + if (dayOfWeek >= 1 && dayOfWeek <= 4) { + const lastWeek = new Date(today) + lastWeek.setDate(today.getDate() - 7) + return getWeekInfo(lastWeek) + } + + // 금~일 (5, 6, 0)이면 현재 주차 기준 + return getWeekInfo(today) + } + + /** + * 실제 이번 주 정보 (달력 기준, 항상 현재 주차) + */ + function getActualCurrentWeekInfo(): WeekInfo { return getWeekInfo(new Date()) } @@ -110,6 +130,7 @@ export function useWeekCalc() { return { getWeekInfo, getCurrentWeekInfo, + getActualCurrentWeekInfo, getLastWeekInfo, formatDate, parseWeekString, diff --git a/frontend/employee/index.vue b/frontend/employee/index.vue index 9daec44..f1143ca 100644 --- a/frontend/employee/index.vue +++ b/frontend/employee/index.vue @@ -47,7 +47,7 @@ 소속사 직급 상태 - 상세 + 관리 @@ -69,10 +69,18 @@ + @@ -153,6 +161,37 @@ + + + + @@ -164,7 +203,10 @@ const employees = ref([]) const searchKeyword = ref('') const filterStatus = ref('') const showCreateModal = ref(false) +const showDeleteModal = ref(false) +const deleteTarget = ref(null) const isLoading = ref(true) +const isDeleting = ref(false) const newEmployee = ref({ employeeName: '', @@ -243,6 +285,32 @@ async function createEmployee() { alert(e.data?.message || e.message || '등록에 실패했습니다.') } } + +function confirmDelete(emp: any) { + deleteTarget.value = emp + showDeleteModal.value = true +} + +async function deleteEmployee() { + if (!deleteTarget.value) return + + isDeleting.value = true + try { + const res = await $fetch<{ success: boolean; action: string; message: string }>( + `/api/employee/${deleteTarget.value.employeeId}/delete`, + { method: 'DELETE' } + ) + + alert(res.message) + showDeleteModal.value = false + deleteTarget.value = null + await loadEmployees() + } catch (e: any) { + alert(e.data?.message || e.message || '삭제에 실패했습니다.') + } finally { + isDeleting.value = false + } +} diff --git a/frontend/report/summary/index.vue b/frontend/report/summary/index.vue index b5cdb48..5b17f78 100644 --- a/frontend/report/summary/index.vue +++ b/frontend/report/summary/index.vue @@ -45,10 +45,10 @@ - {{ week.reportWeek }}주차 + {{ week.reportYear }}년 {{ week.reportWeek }}주 - - {{ formatDateRange(week.weekStartDate, week.weekEndDate) }} + + {{ formatDate(week.weekStartDate) }} ~ {{ formatDate(week.weekEndDate) }} @@ -311,6 +311,11 @@ function getWeekDateRange(year: number, week: number): string { return `${fmt(monday)}~${fmt(sunday)}` } +function formatDate(dateStr: string) { + if (!dateStr) return '' + return dateStr.split('T')[0].replace(/-/g, '.') +} + function formatDateRange(start: string, end: string) { if (!start || !end) return '-' const s = new Date(start) diff --git a/frontend/report/weekly/index.vue b/frontend/report/weekly/index.vue index 5e74a54..aafc4c5 100644 --- a/frontend/report/weekly/index.vue +++ b/frontend/report/weekly/index.vue @@ -40,44 +40,20 @@ - -
- - -
-
- -
- - -
-
- - -
- - +
- - - - - +
@@ -161,21 +137,18 @@ const router = useRouter() const reports = ref([]) const employees = ref([]) -const projects = ref([]) const isLoading = ref(true) const isAdmin = ref(false) const currentYear = new Date().getFullYear() const yearOptions = [currentYear, currentYear - 1, currentYear - 2] +const weekOptions = Array.from({ length: 53 }, (_, i) => i + 1) const filters = ref({ viewAll: false, authorId: '', - projectId: '', - year: '', - startDate: '', - endDate: '', - status: '' + year: currentYear, + week: '' }) onMounted(async () => { @@ -216,20 +189,11 @@ async function loadReports() { if (filters.value.viewAll) params.append('viewAll', 'true') if (filters.value.authorId) params.append('authorId', filters.value.authorId) - if (filters.value.projectId) params.append('projectId', filters.value.projectId) - if (filters.value.year) params.append('year', filters.value.year) - if (filters.value.startDate) params.append('startDate', filters.value.startDate) - if (filters.value.endDate) params.append('endDate', filters.value.endDate) - if (filters.value.status) params.append('status', filters.value.status) + if (filters.value.year) params.append('year', String(filters.value.year)) + if (filters.value.week) params.append('week', String(filters.value.week)) const res = await $fetch(`/api/report/weekly/list?${params.toString()}`) reports.value = res.reports || [] - - // 일반 사용자도 프로젝트 필터 사용할 수 있도록 - if (!isAdmin.value && projects.value.length === 0) { - const projRes = await $fetch('/api/project/list') - projects.value = projRes.projects || [] - } } catch (e) { console.error(e) } finally { @@ -241,11 +205,8 @@ function resetFilters() { filters.value = { viewAll: false, authorId: '', - projectId: '', - year: '', - startDate: '', - endDate: '', - status: '' + year: currentYear, + week: '' } loadReports() }