diff --git a/public/icons/gdgocIcon/mobile.svg b/public/icons/gdgocIcon/mobile.svg index c204e76..a70bc80 100644 --- a/public/icons/gdgocIcon/mobile.svg +++ b/public/icons/gdgocIcon/mobile.svg @@ -1,17 +1 @@ - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/public/icons/gdgocIcon/pc.svg b/public/icons/gdgocIcon/pc.svg index 3b8ef53..c226dc2 100644 --- a/public/icons/gdgocIcon/pc.svg +++ b/public/icons/gdgocIcon/pc.svg @@ -1,17 +1 @@ - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/public/icons/logo/google-g.svg b/public/icons/logo/google-g.svg index 27fb6a7..02e1bde 100644 --- a/public/icons/logo/google-g.svg +++ b/public/icons/logo/google-g.svg @@ -1,20 +1 @@ - + \ No newline at end of file diff --git a/public/icons/ui/download.svg b/public/icons/ui/download.svg index 381716b..b30ace9 100644 --- a/public/icons/ui/download.svg +++ b/public/icons/ui/download.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/public/icons/ui/file.svg b/public/icons/ui/file.svg index 68624d5..4cce25f 100644 --- a/public/icons/ui/file.svg +++ b/public/icons/ui/file.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/public/icons/ui/trash_can.svg b/public/icons/ui/trash_can.svg index bd2cb23..067ffdd 100644 --- a/public/icons/ui/trash_can.svg +++ b/public/icons/ui/trash_can.svg @@ -1,3 +1 @@ - - - + \ No newline at end of file diff --git a/public/images/recruit/core_pic.jpeg b/public/images/recruit/core_pic.jpeg index 1e1225c..809dd0f 100644 Binary files a/public/images/recruit/core_pic.jpeg and b/public/images/recruit/core_pic.jpeg differ diff --git a/public/images/recruit/core_pic_m.jpg b/public/images/recruit/core_pic_m.jpg index 9aa0d64..aaab611 100644 Binary files a/public/images/recruit/core_pic_m.jpg and b/public/images/recruit/core_pic_m.jpg differ diff --git a/src/app/dashboard/core/application/layout.tsx b/src/app/dashboard/core/application/layout.tsx new file mode 100644 index 0000000..3011376 --- /dev/null +++ b/src/app/dashboard/core/application/layout.tsx @@ -0,0 +1,11 @@ +import type { ReactNode } from 'react' + +import ApiCodeGuard from '@/components/auth/ApiCodeGuard' + +export default function DashboardCoreApplicationLayout({ children }: { children: ReactNode }) { + return ( + + {children} + + ) +} diff --git a/src/app/dashboard/core/application/page.tsx b/src/app/dashboard/core/application/page.tsx new file mode 100644 index 0000000..d44aaa9 --- /dev/null +++ b/src/app/dashboard/core/application/page.tsx @@ -0,0 +1 @@ +export { default } from '../../core-applications/page' diff --git a/src/app/dashboard/core/attendance/page.tsx b/src/app/dashboard/core/attendance/page.tsx new file mode 100644 index 0000000..532d5b5 --- /dev/null +++ b/src/app/dashboard/core/attendance/page.tsx @@ -0,0 +1,680 @@ +'use client' + +import { useCallback, useEffect, useMemo, useState } from 'react' +import Link from 'next/link' + +import Loader from '@/components/ui/common/Loader' +import { useAuth } from '@/hooks/useAuth' +import { useAuthenticatedApi } from '@/hooks/useAuthenticatedApi' +import { unwrapApiResponse } from '@/utils/api/unwrap' + +type AttendanceStatus = 'PRESENT' | 'LATE' | 'PRE_ARRANGED' | 'ABSENT' + +type DateListResponse = { + dates?: string[] +} + +type TeamMember = { + id: string + name: string +} + +type TeamResponse = { + id: string + name: string + members?: TeamMember[] +} + +type AttendanceMember = { + userId: string + name: string + team: string + status: AttendanceStatus + statusLabel?: string + lastModifiedAt?: string | null +} + +type DaySummaryTeam = { + teamId: string + teamName: string + present: number + late: number + preArranged: number + absent: number + total: number +} + +type DaySummaryResponse = { + date: string + perTeam: DaySummaryTeam[] + present: number + late: number + preArranged: number + absent: number + total: number +} + +const ROLE_RANK: Record = { + GUEST: 0, + MEMBER: 1, + CORE: 2, + LEAD: 3, + ORGANIZER: 4, + ADMIN: 5 +} + +const STATUS_OPTIONS: Array<{ + value: AttendanceStatus + label: string + badgeClassName: string + buttonClassName: string +}> = [ + { + value: 'PRESENT', + label: '출석', + badgeClassName: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-200', + buttonClassName: 'border-emerald-400/40 text-emerald-200 hover:border-emerald-300' + }, + { + value: 'LATE', + label: '지각', + badgeClassName: 'border-amber-400/40 bg-amber-400/10 text-amber-200', + buttonClassName: 'border-amber-400/40 text-amber-200 hover:border-amber-300' + }, + { + value: 'PRE_ARRANGED', + label: '사전 승인', + badgeClassName: 'border-sky-400/40 bg-sky-400/10 text-sky-200', + buttonClassName: 'border-sky-400/40 text-sky-200 hover:border-sky-300' + }, + { + value: 'ABSENT', + label: '결석', + badgeClassName: 'border-red/40 bg-red/10 text-red', + buttonClassName: 'border-red/40 text-red hover:border-red' + } +] + +const STATUS_META = STATUS_OPTIONS.reduce>( + (acc, option) => { + acc[option.value] = option + return acc + }, + {} as Record +) + +const formatDate = (value?: string | null) => { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleDateString('ko-KR', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'short' + }) +} + +const formatDateTime = (value?: string | null) => { + if (!value) return '-' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) +} + +const todayString = () => { + const now = new Date() + const offset = now.getTimezoneOffset() * 60_000 + return new Date(now.getTime() - offset).toISOString().slice(0, 10) +} + +export default function DashboardCoreAttendancePage() { + const { apiClient, authorizedFetch } = useAuthenticatedApi() + const { user } = useAuth() + + const myRole = user?.userRole ?? 'GUEST' + const myTeam = user?.team ?? null + const myRoleRank = ROLE_RANK[myRole] ?? 0 + const canManageAttendance = myRoleRank >= ROLE_RANK.LEAD + const canSelectTeam = myRoleRank >= ROLE_RANK.ORGANIZER || (myRole === 'LEAD' && myTeam === 'HR') + + const [dates, setDates] = useState([]) + const [selectedDate, setSelectedDate] = useState('') + const [teams, setTeams] = useState([]) + const [selectedTeam, setSelectedTeam] = useState('ALL') + const [members, setMembers] = useState([]) + const [summary, setSummary] = useState(null) + const [attendanceDraft, setAttendanceDraft] = useState>({}) + const [newDate, setNewDate] = useState(todayString()) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [creating, setCreating] = useState(false) + const [deleting, setDeleting] = useState(false) + const [downloading, setDownloading] = useState<'daily' | 'matrix' | null>(null) + const [error, setError] = useState(null) + + const effectiveTeamParam = useMemo(() => { + if (!canSelectTeam) return undefined + return selectedTeam === 'ALL' ? undefined : selectedTeam + }, [canSelectTeam, selectedTeam]) + + const fetchBaseData = useCallback(async () => { + setLoading(true) + setError(null) + + try { + const [datesResponse, teamsResponse] = await Promise.all([ + apiClient.get('/core-attendance/meetings'), + apiClient.get('/core-attendance/meetings/teams') + ]) + + const nextDates = unwrapApiResponse(datesResponse.data)?.dates ?? [] + const nextTeams = unwrapApiResponse(teamsResponse.data) ?? [] + + setDates(nextDates) + setTeams(nextTeams) + setSelectedDate((prev) => { + if (prev && nextDates.includes(prev)) return prev + return nextDates[0] ?? '' + }) + setSelectedTeam((prev) => { + if (!canSelectTeam) return myTeam ?? 'ALL' + if (prev === 'ALL') return prev + return nextTeams.some((team) => team.id === prev) ? prev : 'ALL' + }) + } catch (e: any) { + setDates([]) + setTeams([]) + setMembers([]) + setSummary(null) + setError(e?.response?.data?.message || '출석 기본 정보를 불러오지 못했습니다.') + } finally { + setLoading(false) + } + }, [apiClient, canSelectTeam, myTeam]) + + const fetchAttendanceData = useCallback( + async (date: string) => { + if (!date) { + setMembers([]) + setSummary(null) + setAttendanceDraft({}) + return + } + + setLoading(true) + setError(null) + + try { + const params = effectiveTeamParam ? { team: effectiveTeamParam } : undefined + const [membersResponse, summaryResponse] = await Promise.all([ + apiClient.get(`/core-attendance/meetings/${date}/members`, { params }), + apiClient.get(`/core-attendance/meetings/${date}/summary`, { params }) + ]) + + const nextMembers = unwrapApiResponse(membersResponse.data) ?? [] + const nextSummary = unwrapApiResponse(summaryResponse.data) + + setMembers(nextMembers) + setSummary(nextSummary) + setAttendanceDraft( + nextMembers.reduce>((acc, member) => { + acc[member.userId] = member.status ?? 'ABSENT' + return acc + }, {}) + ) + } catch (e: any) { + setMembers([]) + setSummary(null) + setAttendanceDraft({}) + setError(e?.response?.data?.message || '출석 현황을 불러오지 못했습니다.') + } finally { + setLoading(false) + } + }, + [apiClient, effectiveTeamParam] + ) + + useEffect(() => { + void fetchBaseData() + }, [fetchBaseData]) + + useEffect(() => { + void fetchAttendanceData(selectedDate) + }, [fetchAttendanceData, selectedDate]) + + const visibleTeams = useMemo(() => { + if (summary?.perTeam?.length) return summary.perTeam + return teams.map((team) => ({ + teamId: team.id, + teamName: team.name, + present: 0, + late: 0, + preArranged: 0, + absent: team.members?.length ?? 0, + total: team.members?.length ?? 0 + })) + }, [summary, teams]) + + const attendanceDirty = useMemo( + () => members.some((member) => attendanceDraft[member.userId] !== member.status), + [attendanceDraft, members] + ) + + const setMemberStatus = (userId: string, status: AttendanceStatus) => { + if (!canManageAttendance) return + setAttendanceDraft((prev) => ({ + ...prev, + [userId]: status + })) + } + + const handleSaveAttendance = async () => { + if (!selectedDate || !canManageAttendance || members.length === 0) return + + const grouped = members.reduce>( + (acc, member) => { + const status = attendanceDraft[member.userId] ?? 'ABSENT' + acc[status].push(Number(member.userId)) + return acc + }, + { + PRESENT: [], + LATE: [], + PRE_ARRANGED: [], + ABSENT: [] + } + ) + + setSaving(true) + try { + const requests = (Object.entries(grouped) as Array<[AttendanceStatus, number[]]>) + .filter(([, userIds]) => userIds.length > 0) + .map(([status, userIds]) => + apiClient.put(`/core-attendance/meetings/${selectedDate}/attendance`, { + userIds, + status + }) + ) + + if (requests.length > 0) { + await Promise.all(requests) + } + + await fetchAttendanceData(selectedDate) + alert('출석 현황을 저장했습니다.') + } catch (e: any) { + alert(e?.response?.data?.message || '출석 저장에 실패했습니다.') + } finally { + setSaving(false) + } + } + + const handleCreateDate = async () => { + if (!newDate || !canManageAttendance) return + + setCreating(true) + try { + await apiClient.post('/core-attendance/meetings', { date: newDate }) + await fetchBaseData() + setSelectedDate(newDate) + } catch (e: any) { + alert(e?.response?.data?.message || '출석 일정을 추가하지 못했습니다.') + } finally { + setCreating(false) + } + } + + const handleDeleteDate = async () => { + if (!selectedDate || !canManageAttendance) return + if (!window.confirm(`${selectedDate} 출석 일정을 삭제할까요?`)) return + + setDeleting(true) + try { + await apiClient.delete(`/core-attendance/meetings/${selectedDate}`) + await fetchBaseData() + } catch (e: any) { + alert(e?.response?.data?.message || '출석 일정을 삭제하지 못했습니다.') + } finally { + setDeleting(false) + } + } + + const downloadCsv = useCallback( + async (path: string, filename: string, mode: 'daily' | 'matrix') => { + setDownloading(mode) + try { + const response = await authorizedFetch(path) + if (!response.ok) { + throw new Error('download_failed') + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + document.body.appendChild(anchor) + anchor.click() + anchor.remove() + window.URL.revokeObjectURL(url) + } catch { + alert('CSV 다운로드에 실패했습니다.') + } finally { + setDownloading(null) + } + }, + [authorizedFetch] + ) + + const totalStatusCards = [ + { label: '출석', value: summary?.present ?? 0, tone: STATUS_META.PRESENT.badgeClassName }, + { label: '지각', value: summary?.late ?? 0, tone: STATUS_META.LATE.badgeClassName }, + { label: '사전 승인', value: summary?.preArranged ?? 0, tone: STATUS_META.PRE_ARRANGED.badgeClassName }, + { label: '결석', value: summary?.absent ?? 0, tone: STATUS_META.ABSENT.badgeClassName } + ] + + return ( +
+ + +
+
+
+

Core Attendance

+ + Dashboard + + + Core 지원서 + +
+
+ {canManageAttendance + ? 'Lead 이상은 출석, 지각, 사전 승인, 결석 상태를 기록할 수 있습니다.' + : 'Core 권한은 출석 현황 조회만 가능합니다.'} +
+
+ +
+
+
+
+

Meeting Date

+
+ {dates.map((date) => ( + + ))} + {dates.length === 0 ? ( +
+ 등록된 일정이 없습니다. +
+ ) : null} +
+
+ + {canManageAttendance ? ( +
+ +
+ setNewDate(e.target.value)} + className="h-11 rounded-lg border border-white/15 bg-black px-3 text-white outline-none focus:border-white" + /> + + +
+
+ ) : null} +
+
+ +
+
+
+

Scope

+
+ {canSelectTeam ? ( + + ) : ( +
+ {myTeam ?? '소속 팀 없음'} +
+ )} +
+
+ +
+ + +
+
+
+
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+
+
+

Summary

+

{selectedDate ? formatDate(selectedDate) : '날짜를 선택해 주세요.'}

+

총 {summary?.total ?? 0}명 기준

+
+ +
+ {totalStatusCards.map((card) => ( +
+

{card.label}

+

{card.value}명

+
+ ))} +
+ +
+ {visibleTeams.length === 0 ? ( +
+ 팀 정보가 없습니다. +
+ ) : ( + visibleTeams.map((team) => ( +
+
+
+

{team.teamName}

+

{team.total}명

+
+
+
+
+ 출석 {team.present} +
+
+ 지각 {team.late} +
+
+ 사전 승인 {team.preArranged} +
+
+ 결석 {team.absent} +
+
+
+ )) + )} +
+
+ +
+
+
+

Members

+

출석 대상 {members.length}명

+
+ {canManageAttendance ? ( + + ) : null} +
+ +
+ + + + + + + + + + + {members.length === 0 ? ( + + + + ) : ( + members.map((member) => { + const currentStatus = attendanceDraft[member.userId] ?? member.status + const currentMeta = STATUS_META[currentStatus] + return ( + + + + + + + ) + }) + )} + +
이름상태최종 수정
+ 선택한 날짜의 출석 대상이 없습니다. +
{member.name}{member.team} + {canManageAttendance ? ( +
+ {STATUS_OPTIONS.map((option) => { + const selected = currentStatus === option.value + return ( + + ) + })} +
+ ) : ( + + {member.statusLabel ?? currentMeta.label} + + )} +
+ {formatDateTime(member.lastModifiedAt)} +
+
+
+
+
+
+ ) +} diff --git a/src/app/dashboard/mbti/page.tsx b/src/app/dashboard/mbti/page.tsx index 206ab95..66c5e18 100644 --- a/src/app/dashboard/mbti/page.tsx +++ b/src/app/dashboard/mbti/page.tsx @@ -297,11 +297,17 @@ export default function DashboardMbtiPage() { Members Core 지원서 + + Core 출석 +
diff --git a/src/app/dashboard/members/page.tsx b/src/app/dashboard/members/page.tsx index 29f296a..75a2249 100644 --- a/src/app/dashboard/members/page.tsx +++ b/src/app/dashboard/members/page.tsx @@ -197,11 +197,17 @@ export default function DashboardMembersPage() { Memo 발송 Core 지원서 + + Core 출석 +
Core 지원서 + + Core 출석 +
alert('준비중입니다.') }, // { name: "공지사항", href: "#", onClick: () => alert('준비중입니다.') }, // { name: "프로젝트", href: "#", onClick: () => alert('준비중입니다.') }, @@ -61,7 +61,7 @@ export default function MenuHeader() { - + 출석 체크 diff --git a/src/components/ui/design-system/GdgSiteFooter.tsx b/src/components/ui/design-system/GdgSiteFooter.tsx index f837422..64f520c 100644 --- a/src/components/ui/design-system/GdgSiteFooter.tsx +++ b/src/components/ui/design-system/GdgSiteFooter.tsx @@ -23,7 +23,7 @@ const DEFAULT_SECTIONS: GdgFooterMenuSection[] = [ { label: '멤버관리', url: '/admin/recruit-manager' }, { label: '권한관리', url: '/admin/member-manager' }, { label: '운영자 지원관리', url: '/coreadmin' }, - { label: '출석 관리', url: '/core-attendance' } + { label: '출석 관리', url: '/dashboard/core/attendance' } ] }, { diff --git a/src/components/ui/design-system/GdgSiteHeader.tsx b/src/components/ui/design-system/GdgSiteHeader.tsx index f73430b..ae002fb 100644 --- a/src/components/ui/design-system/GdgSiteHeader.tsx +++ b/src/components/ui/design-system/GdgSiteHeader.tsx @@ -20,7 +20,7 @@ const DEFAULT_MENUS: GdgMenuLink[] = [ { label: '멤버관리', url: '/admin/recruit-manager' }, { label: '권한관리', url: '/admin/member-manager' }, { label: '운영자 지원관리', url: '/coreadmin' }, - { label: '출석 관리', url: '/core-attendance' } + { label: '출석 관리', url: '/dashboard/core/attendance' } ] export function GdgSiteHeader({ diff --git a/src/components/ui/design-system/GdgSiteNav.presets.ts b/src/components/ui/design-system/GdgSiteNav.presets.ts index db67225..e0f52e6 100644 --- a/src/components/ui/design-system/GdgSiteNav.presets.ts +++ b/src/components/ui/design-system/GdgSiteNav.presets.ts @@ -12,7 +12,7 @@ export const GDG_DEFAULT_MAIN_MENUS: GdgMenuLink[] = [ { label: '멤버관리', url: '/admin/recruit-manager' }, { label: '권한관리', url: '/admin/member-manager' }, { label: '운영자 지원관리', url: '/coreadmin' }, - { label: '출석 관리', url: '/core-attendance' } + { label: '출석 관리', url: '/dashboard/core/attendance' } ] export const GDG_DEFAULT_FOOTER_SECTIONS: GdgSiteFooterProps['sections'] = [