diff --git a/apps/frontend/app/admin/_components/GroupAdminSideBar.tsx b/apps/frontend/app/admin/_components/GroupAdminSideBar.tsx
new file mode 100644
index 0000000000..54f1fd529d
--- /dev/null
+++ b/apps/frontend/app/admin/_components/GroupAdminSideBar.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+import { Separator } from '@/components/shadcn/separator'
+import { cn } from '@/libs/utils'
+import type { Route } from 'next'
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import type { IconType } from 'react-icons'
+import { FaChartBar, FaPen } from 'react-icons/fa6'
+import { GroupLink } from './GroupLink'
+
+export function GroupAdminSideBar() {
+ const pathname = usePathname()
+
+ const navItems: { name: string; path: Route; icon: IconType }[] = [
+ { name: 'Dashboard', path: '/admin', icon: FaChartBar },
+ { name: 'Problem', path: '/admin/problem', icon: FaPen }
+ ]
+
+ return (
+
+ {navItems.map((item) => (
+
+ {item.icon && }
+ {item.name}
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/_components/GroupLink.tsx b/apps/frontend/app/admin/_components/GroupLink.tsx
new file mode 100644
index 0000000000..22f86fb457
--- /dev/null
+++ b/apps/frontend/app/admin/_components/GroupLink.tsx
@@ -0,0 +1,53 @@
+import { GET_GROUPS } from '@/graphql/group/queries'
+import { cn } from '@/libs/utils'
+import { useQuery } from '@apollo/client'
+import type { Route } from 'next'
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import { FaUserGroup } from 'react-icons/fa6'
+
+export function GroupLink() {
+ const pathname = usePathname()
+
+ const { data } = useQuery(GET_GROUPS, {
+ variables: {
+ cursor: 1,
+ take: 5
+ }
+ })
+ return (
+
+
+ { }
+ Group
+
+ {data?.getGroups.map((group) => (
+
+ {group.groupName}
+
+ ))}
+
+ )
+}
diff --git a/apps/frontend/app/admin/contest/_components/TimeForm.tsx b/apps/frontend/app/admin/_components/TimeForm.tsx
similarity index 89%
rename from apps/frontend/app/admin/contest/_components/TimeForm.tsx
rename to apps/frontend/app/admin/_components/TimeForm.tsx
index 86cc3db9f1..ffea517353 100644
--- a/apps/frontend/app/admin/contest/_components/TimeForm.tsx
+++ b/apps/frontend/app/admin/_components/TimeForm.tsx
@@ -1,6 +1,6 @@
import { DateTimePickerDemo } from '@/components/shadcn/date-time-picker-demo'
import { useController, useFormContext } from 'react-hook-form'
-import { ErrorMessage } from '../../_components/ErrorMessage'
+import { ErrorMessage } from './ErrorMessage'
export function TimeForm({ name }: { name: string }) {
const {
diff --git a/apps/frontend/app/admin/_components/table/DataTableDeleteButton.tsx b/apps/frontend/app/admin/_components/table/DataTableDeleteButton.tsx
index fc3a2e9bd9..f01896825b 100644
--- a/apps/frontend/app/admin/_components/table/DataTableDeleteButton.tsx
+++ b/apps/frontend/app/admin/_components/table/DataTableDeleteButton.tsx
@@ -17,7 +17,7 @@ import { toast } from 'sonner'
import { useDataTable } from './context'
interface DataTableDeleteButtonProps {
- target: 'problem' | 'contest'
+ target: 'problem' | 'contest' | 'assignment' | 'group'
deleteTarget: (id: number) => Promise
getCanDelete?: (selectedRows: TData[]) => Promise
onSuccess?: () => void
diff --git a/apps/frontend/app/admin/contest/[contestId]/edit/page.tsx b/apps/frontend/app/admin/contest/[contestId]/edit/page.tsx
index 985d545216..f4cc8be8d3 100644
--- a/apps/frontend/app/admin/contest/[contestId]/edit/page.tsx
+++ b/apps/frontend/app/admin/contest/[contestId]/edit/page.tsx
@@ -14,10 +14,10 @@ import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { FaAngleLeft } from 'react-icons/fa6'
import { IoIosCheckmarkCircle } from 'react-icons/io'
+import { TimeForm } from '../../../_components/TimeForm'
import { ContestProblemListLabel } from '../../_components/ContestProblemListLabel'
import { ContestProblemTable } from '../../_components/ContestProblemTable'
import { ImportDialog } from '../../_components/ImportDialog'
-import { TimeForm } from '../../_components/TimeForm'
import { type ContestProblem, editSchema } from '../../_libs/schemas'
import { EditContestForm } from './_components/EditContestForm'
diff --git a/apps/frontend/app/admin/contest/create/page.tsx b/apps/frontend/app/admin/contest/create/page.tsx
index 513da5dc74..f8ff055c41 100644
--- a/apps/frontend/app/admin/contest/create/page.tsx
+++ b/apps/frontend/app/admin/contest/create/page.tsx
@@ -10,11 +10,11 @@ import { ConfirmNavigation } from '../../_components/ConfirmNavigation'
import { DescriptionForm } from '../../_components/DescriptionForm'
import { FormSection } from '../../_components/FormSection'
import { SwitchField } from '../../_components/SwitchField'
+import { TimeForm } from '../../_components/TimeForm'
import { TitleForm } from '../../_components/TitleForm'
import { ContestProblemListLabel } from '../_components/ContestProblemListLabel'
import { ContestProblemTable } from '../_components/ContestProblemTable'
import { ImportDialog } from '../_components/ImportDialog'
-import { TimeForm } from '../_components/TimeForm'
import type { ContestProblem } from '../_libs/schemas'
import { CreateContestForm } from './_components/CreateContestForm'
diff --git a/apps/frontend/app/admin/group/[groupId]/_components/GroupSideBar.tsx b/apps/frontend/app/admin/group/[groupId]/_components/GroupSideBar.tsx
new file mode 100644
index 0000000000..5736f84968
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/_components/GroupSideBar.tsx
@@ -0,0 +1,78 @@
+'use client'
+
+import { cn } from '@/libs/utils'
+import type { Route } from 'next'
+import Link from 'next/link'
+import { usePathname } from 'next/navigation'
+import type { IconType } from 'react-icons'
+import {
+ MdAssignment,
+ MdHome,
+ MdNotifications,
+ MdRateReview,
+ MdOutlineQuestionAnswer,
+ MdEditDocument,
+ MdPeople
+} from 'react-icons/md'
+
+interface GroupSideBarProps {
+ groupId: string
+}
+
+export function GroupSideBar({ groupId }: GroupSideBarProps) {
+ const pathname = usePathname()
+
+ const navItems: { name: string; path: string; icon: IconType }[] = [
+ { name: 'Home', path: `/admin/group/${groupId}`, icon: MdHome },
+ {
+ name: 'Notice',
+ path: `/admin/group/${groupId}/notice`,
+ icon: MdNotifications
+ },
+ {
+ name: 'User',
+ path: `/admin/group/${groupId}/user`,
+ icon: MdPeople
+ },
+ {
+ name: 'Assignment',
+ path: `/admin/group/${groupId}/assignment`,
+ icon: MdAssignment
+ },
+ {
+ name: 'Exam',
+ path: `/admin/group/${groupId}/exam`,
+ icon: MdEditDocument
+ },
+ {
+ name: 'Grade',
+ path: `/admin/group/${groupId}/grade`,
+ icon: MdRateReview
+ },
+ {
+ name: 'Q&A',
+ path: `/admin/group/${groupId}/qna`,
+ icon: MdOutlineQuestionAnswer
+ }
+ ]
+
+ return (
+
+ {navItems.map((item) => (
+
+ {item.icon && }
+ {item.name}
+
+ ))}
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemColumns.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemColumns.tsx
new file mode 100644
index 0000000000..15571aa669
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemColumns.tsx
@@ -0,0 +1,175 @@
+'use client'
+
+import { OptionSelect } from '@/app/admin/_components/OptionSelect'
+import { ContainedContests } from '@/app/admin/problem/_components/ContainedContests'
+import { Badge } from '@/components/shadcn/badge'
+import { Input } from '@/components/shadcn/input'
+import type { Level } from '@/types/type'
+import type { ColumnDef } from '@tanstack/react-table'
+import { toast } from 'sonner'
+import type { AssignmentProblem } from '../_libs/schemas'
+
+export const createColumns = (
+ setProblems: React.Dispatch>,
+ disableInput: boolean
+): ColumnDef[] => [
+ // {
+ // id: 'select',
+ // header: ({ table }) => (
+ // table.toggleAllPageRowsSelected(!!value)}
+ // aria-label="Select all"
+ // className="translate-y-[2px] bg-white"
+ // />
+ // ),
+ // cell: ({ row }) => (
+ // e.stopPropagation()}
+ // checked={row.getIsSelected()}
+ // onCheckedChange={(value) => row.toggleSelected(!!value)}
+ // aria-label="Select row"
+ // className="translate-y-[2px] bg-white"
+ // />
+ // ),
+ // enableSorting: false,
+ // enableHiding: false
+ // },
+ {
+ accessorKey: 'title',
+ header: () => Title
,
+ cell: ({ row }) => (
+
+ {row.getValue('title')}
+
+ ),
+ footer: () => Score Sum
,
+ enableSorting: false,
+ enableHiding: false
+ },
+ {
+ accessorKey: 'score',
+ header: () => Score
,
+ cell: ({ row }) => (
+ {
+ if (disableInput) {
+ toast.error('Problem scoring cannot be edited')
+ }
+ }}
+ >
+ {
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ const target = e.target as HTMLInputElement
+ target.blur()
+ }
+ }}
+ onBlur={(event) => {
+ setProblems((prevProblems: AssignmentProblem[]) =>
+ prevProblems.map((problem) =>
+ problem.id === row.original.id
+ ? { ...problem, score: Number(event.target.value) }
+ : problem
+ )
+ )
+ }}
+ />
+
+ ),
+ footer: ({ table }) => (
+
+ row.original)
+ .reduce((total, problem) => total + problem.score, 0)}
+ />
+
+ ),
+ enableSorting: false,
+ enableHiding: false
+ },
+ {
+ accessorKey: 'order',
+ header: () => Order
,
+ cell: ({ table, row }) => {
+ const tableRows = table.getRowModel().rows
+ const alphabetArray = tableRows.map((_, index) =>
+ String.fromCharCode(65 + index)
+ )
+ return (
+ {
+ if (disableInput) {
+ toast.error('Problem order cannot be edited')
+ }
+ }}
+ >
+ {
+ setProblems((prevProblems: AssignmentProblem[]) =>
+ prevProblems.map((problem) =>
+ problem.id === row.original.id
+ ? { ...problem, order: selectedOrder.charCodeAt(0) - 65 }
+ : problem
+ )
+ )
+ }}
+ className="w-[70px] disabled:pointer-events-none"
+ disabled={disableInput}
+ />
+
+ )
+ }
+ },
+ {
+ accessorKey: 'difficulty',
+ header: () => Level
,
+ cell: ({ row }) => {
+ const level: string = row.getValue('difficulty')
+ const formattedLevel = `Level ${level.slice(-1)}`
+ return (
+
+
+ {formattedLevel}
+
+
+ )
+ },
+ enableSorting: false
+ },
+ {
+ accessorKey: 'included',
+ header: () => Included
,
+ cell: ({ row }) => (
+
+ {/* 백엔드 API 작업 완료 후 수정 예정 */}
+
+
+ ),
+ enableSorting: false
+ }
+]
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemListLabel.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemListLabel.tsx
new file mode 100644
index 0000000000..6060a31cc0
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemListLabel.tsx
@@ -0,0 +1,39 @@
+import { Label } from '@/app/admin/_components/Label'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger
+} from '@/components/shadcn/tooltip'
+import { MdHelpOutline } from 'react-icons/md'
+
+export function AssignmentProblemListLabel() {
+ return (
+
+
Assignment Problem List
+
+
+
+
+
+
+
+
+ {/* assignment visible 정책 결정 후 문구 확정 */}
+
+ If a problem is included in at least one ongoing, or upcoming
+ contest, it will automatically become invisible state in the ‘All
+ Problem List’. You cannot change its visibility until all the
+ ongoing or upcoming contests it is part of have ended. After the
+ contests are all over, you can manually make the problem visible
+ again.
+
+
+
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemTable.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemTable.tsx
new file mode 100644
index 0000000000..13f81c9f7d
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentProblemTable.tsx
@@ -0,0 +1,32 @@
+import { DataTable, DataTableRoot } from '@/app/admin/_components/table'
+import { useMemo, type Dispatch, type SetStateAction } from 'react'
+import type { AssignmentProblem } from '../_libs/schemas'
+import { createColumns } from './AssignmentProblemColumns'
+
+interface AssignmentProblemTableProps {
+ problems: AssignmentProblem[]
+ setProblems: Dispatch>
+ disableInput: boolean
+}
+
+export function AssignmentProblemTable({
+ problems,
+ setProblems,
+ disableInput
+}: AssignmentProblemTableProps) {
+ const columns = useMemo(
+ () => createColumns(setProblems, disableInput),
+ [setProblems, disableInput]
+ )
+
+ return (
+
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTable.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTable.tsx
new file mode 100644
index 0000000000..63eb3fad8d
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTable.tsx
@@ -0,0 +1,93 @@
+'use client'
+
+import {
+ DataTable,
+ DataTableDeleteButton,
+ DataTableFallback,
+ DataTablePagination,
+ DataTableRoot,
+ DataTableSearchBar
+} from '@/app/admin/_components/table'
+import { DELETE_ASSIGNMENT } from '@/graphql/assignment/mutations'
+import { GET_ASSIGNMENTS } from '@/graphql/assignment/queries'
+import { useApolloClient, useMutation, useSuspenseQuery } from '@apollo/client'
+import type { Route } from 'next'
+import { columns } from './AssignmentTableColumns'
+
+interface AssignmentTableProps {
+ groupId: string
+}
+
+const headerStyle = {
+ select: '',
+ title: 'w-3/5',
+ startTime: 'px-0 w-1/5',
+ participants: 'px-0 w-1/12',
+ isVisible: 'px-0 w-1/12'
+}
+
+export function AssignmentTable({ groupId }: AssignmentTableProps) {
+ const { data } = useSuspenseQuery(GET_ASSIGNMENTS, {
+ variables: {
+ groupId: Number(groupId),
+ take: 300
+ }
+ })
+
+ const assignments = data.getAssignments.map((assignment) => ({
+ ...assignment,
+ id: Number(assignment.id)
+ }))
+
+ return (
+
+
+
+ `/admin/group/${groupId}/assignment/${data.id}` as Route
+ }
+ />
+
+
+ )
+}
+
+function AssignmentsDeleteButton() {
+ const client = useApolloClient()
+ const [deleteAssignment] = useMutation(DELETE_ASSIGNMENT)
+
+ const deleteTarget = (id: number) => {
+ return deleteAssignment({
+ variables: {
+ groupId: 1,
+ assignmentId: id
+ }
+ })
+ }
+
+ const onSuccess = () => {
+ client.refetchQueries({
+ include: [GET_ASSIGNMENTS]
+ })
+ }
+
+ return (
+
+ )
+}
+
+export function AssignmentTableFallback() {
+ return
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTableColumns.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTableColumns.tsx
new file mode 100644
index 0000000000..e0ea772b2a
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/AssignmentTableColumns.tsx
@@ -0,0 +1,185 @@
+'use client'
+
+import { DataTableColumnHeader } from '@/app/admin/_components/table/DataTableColumnHeader'
+import { Checkbox } from '@/components/shadcn/checkbox'
+import { Switch } from '@/components/shadcn/switch'
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger
+} from '@/components/shadcn/tooltip'
+import { UPDATE_ASSIGNMENT_VISIBLE } from '@/graphql/assignment/mutations'
+import { cn, dateFormatter } from '@/libs/utils'
+import invisibleIcon from '@/public/icons/invisible.svg'
+import visibleIcon from '@/public/icons/visible.svg'
+import { useMutation } from '@apollo/client'
+import * as TooltipPrimitive from '@radix-ui/react-tooltip'
+import type { ColumnDef, Row } from '@tanstack/react-table'
+import Image from 'next/image'
+import { toast } from 'sonner'
+
+export interface DataTableAssignment {
+ id: number
+ title: string
+ startTime: string
+ endTime: string
+ description: string
+ participants: number
+ isVisible: boolean
+ isRankVisible: boolean
+}
+
+function VisibleCell({ row }: { row: Row }) {
+ const [updateVisible] = useMutation(UPDATE_ASSIGNMENT_VISIBLE)
+
+ return (
+
+
e.stopPropagation()}
+ id="hidden-mode"
+ checked={row.original.isVisible}
+ onCheckedChange={() => {
+ row.original.isVisible = !row.original.isVisible
+ const currentTime = dateFormatter(new Date(), 'YYYY-MM-DD HH:mm:ss')
+ const startTime = dateFormatter(
+ row.original.startTime,
+ 'YYYY-MM-DD HH:mm:ss'
+ )
+ const endTime = dateFormatter(
+ row.original.endTime,
+ 'YYYY-MM-DD HH:mm:ss'
+ )
+ if (currentTime > startTime && currentTime < endTime) {
+ toast.error('Cannot change visibility of ongoing assignment')
+ return
+ }
+ // TODO: assignment update API 수정되면 고치기
+ updateVisible({
+ variables: {
+ groupId: 1,
+ input: {
+ id: row.original.id,
+ title: row.original.title,
+ startTime: row.original.startTime,
+ endTime: row.original.endTime,
+ description: row.original.description,
+ isVisible: row.original.isVisible,
+ isRankVisible: row.original.isRankVisible
+ }
+ }
+ })
+ }}
+ />
+
+ {
+
+
+
+ e.stopPropagation()}
+ className="h-6 w-6"
+ >
+ {row.original.isVisible ? (
+
+ ) : (
+
+ )}
+
+
+
+ {row.original.isVisible ? (
+ This assignment is visible
+ ) : (
+ This assignment is not visible to users
+ )}
+
+
+
+
+ }
+
+
+ )
+}
+
+export const columns: ColumnDef[] = [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ e.stopPropagation()}
+ checked={table.getIsAllPageRowsSelected()}
+ onCheckedChange={(value) =>
+ table.toggleAllPageRowsSelected(Boolean(value))
+ }
+ aria-label="Select all"
+ className="translate-y-[2px] bg-white"
+ />
+ ),
+ cell: ({ row }) => (
+ e.stopPropagation()}
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
+ aria-label="Select row"
+ className="translate-y-[2px] bg-white"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false
+ },
+ {
+ accessorKey: 'title',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => (
+
+ {row.getValue('title')}
+
+ ),
+ enableSorting: false,
+ enableHiding: false
+ },
+ {
+ accessorKey: 'startTime',
+ header: ({ column }) => (
+
+
+
+ ),
+ cell: ({ row }) => (
+
+ {`${dateFormatter(row.original.startTime, 'YY-MM-DD HH:mm')} ~ ${dateFormatter(row.original.endTime, 'YY-MM-DD HH:mm')}`}
+
+ ),
+ size: 250
+ },
+ {
+ accessorKey: 'isVisible',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ return
+ },
+ size: 100
+ }
+]
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportDialog.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportDialog.tsx
new file mode 100644
index 0000000000..9b3030185c
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportDialog.tsx
@@ -0,0 +1,91 @@
+'use client'
+
+import { FetchErrorFallback } from '@/components/FetchErrorFallback'
+import {
+ AlertDialog,
+ AlertDialogTrigger,
+ AlertDialogContent,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogCancel,
+ AlertDialogAction
+} from '@/components/shadcn/alert-dialog'
+import { Button } from '@/components/shadcn/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle
+} from '@/components/shadcn/dialog'
+import { ErrorBoundary } from '@suspensive/react'
+import { PlusCircleIcon } from 'lucide-react'
+import { Suspense, useState } from 'react'
+import type { AssignmentProblem } from '../_libs/schemas'
+import {
+ ImportProblemTable,
+ ImportProblemTableFallback
+} from './ImportProblemTable'
+
+interface ImportDialogProps {
+ problems: AssignmentProblem[]
+ setProblems: (problems: AssignmentProblem[]) => void
+}
+
+export function ImportDialog({ problems, setProblems }: ImportDialogProps) {
+ const [showImportDialog, setShowImportDialog] = useState(false)
+ return (
+ <>
+
+
+
+
+ Import · Edit problem
+
+
+
+
+ Importing from Problem List
+ {/* 정책 결정 후 문구 확정 예정 */}
+
+ If contest problems are imported from the ‘All Problem List’, the
+ problems will automatically become invisible state.
+
+
+
+
+ Cancel
+
+
+ setShowImportDialog(true)}>
+ Ok
+
+
+
+
+
+
+
+
+ Import Problem
+
+
+ }>
+ {
+ setProblems(problems)
+ setShowImportDialog(false)
+ }}
+ />
+
+
+
+
+ >
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemButton.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemButton.tsx
new file mode 100644
index 0000000000..f26dade76b
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemButton.tsx
@@ -0,0 +1,51 @@
+import { useDataTable } from '@/app/admin/_components/table/context'
+import { Button } from '@/components/shadcn/button'
+import type { AssignmentProblem } from '../_libs/schemas'
+import type { DataTableProblem } from './ImportProblemTableColumns'
+
+interface ImportProblemButtonProps {
+ onSelectedExport: (data: AssignmentProblem[]) => void
+}
+
+export function ImportProblemButton({
+ onSelectedExport
+}: ImportProblemButtonProps) {
+ const { table } = useDataTable()
+
+ const handleImportProblems = () => {
+ const selectedRows = table
+ .getSelectedRowModel()
+ .rows.map((row) => row.original)
+
+ const problems = selectedRows
+ .map((problem) => ({
+ ...problem,
+ score: problem?.score ?? 0,
+ order: problem?.order ?? Number.MAX_SAFE_INTEGER
+ }))
+ .sort((a, b) => a.order - b.order)
+
+ let order = 0
+ const exportedProblems = problems.map((problem, index, arr) => {
+ if (
+ index > 0 &&
+ // NOTE: 만약 현재 요소가 새로 추가된 문제이거나 새로 추가된 문제가 아니라면 이전 문제와 기존 순서가 다를 때
+ (arr[index].order === Number.MAX_SAFE_INTEGER ||
+ arr[index - 1].order !== arr[index].order)
+ ) {
+ order++
+ }
+ return {
+ ...problem,
+ order
+ }
+ })
+ onSelectedExport(exportedProblems)
+ }
+
+ return (
+
+ Import / Edit
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTable.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTable.tsx
new file mode 100644
index 0000000000..97f7133259
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTable.tsx
@@ -0,0 +1,101 @@
+import {
+ DataTable,
+ DataTableFallback,
+ DataTableLangFilter,
+ DataTableLevelFilter,
+ DataTablePagination,
+ DataTableRoot,
+ DataTableSearchBar
+} from '@/app/admin/_components/table'
+import { GET_PROBLEMS } from '@/graphql/problem/queries'
+import { useSuspenseQuery } from '@apollo/client'
+import { Language, Level } from '@generated/graphql'
+import { toast } from 'sonner'
+import type { AssignmentProblem } from '../_libs/schemas'
+import { ImportProblemButton } from './ImportProblemButton'
+import {
+ columns,
+ DEFAULT_PAGE_SIZE,
+ ERROR_MESSAGE,
+ MAX_SELECTED_ROW_COUNT
+} from './ImportProblemTableColumns'
+
+export function ImportProblemTable({
+ checkedProblems,
+ onSelectedExport
+}: {
+ checkedProblems: AssignmentProblem[]
+ onSelectedExport: (selectedRows: AssignmentProblem[]) => void
+}) {
+ const { data } = useSuspenseQuery(GET_PROBLEMS, {
+ variables: {
+ groupId: 1,
+ take: 500,
+ input: {
+ difficulty: [
+ Level.Level1,
+ Level.Level2,
+ Level.Level3,
+ Level.Level4,
+ Level.Level5
+ ],
+ languages: [Language.C, Language.Cpp, Language.Java, Language.Python3]
+ }
+ }
+ })
+
+ const problems = data.getProblems.map((problem) => ({
+ ...problem,
+ id: Number(problem.id),
+ isVisible: problem.isVisible !== undefined ? problem.isVisible : null,
+ languages: problem.languages ?? [],
+ tag: problem.tag.map(({ id, tag }) => ({
+ id: Number(id),
+ tag: {
+ ...tag,
+ id: Number(tag.id)
+ }
+ })),
+ score: checkedProblems.find((item) => item.id === Number(problem.id))
+ ?.score,
+ order: checkedProblems.find((item) => item.id === Number(problem.id))?.order
+ }))
+
+ const selectedProblemIds = checkedProblems.map((problem) => problem.id)
+
+ return (
+
+
+
+
+
+
+
+ {
+ const selectedRowCount = table.getSelectedRowModel().rows.length
+ if (
+ selectedRowCount < MAX_SELECTED_ROW_COUNT ||
+ row.getIsSelected()
+ ) {
+ row.toggleSelected()
+ table.setSorting([{ id: 'select', desc: true }]) // NOTE: force to trigger sortingFn
+ } else {
+ toast.error(ERROR_MESSAGE)
+ }
+ }}
+ />
+
+
+ )
+}
+
+export function ImportProblemTableFallback() {
+ return
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTableColumns.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTableColumns.tsx
new file mode 100644
index 0000000000..f82f2f1d71
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_components/ImportProblemTableColumns.tsx
@@ -0,0 +1,149 @@
+import { DataTableColumnHeader } from '@/app/admin/_components/table/DataTableColumnHeader'
+import { Badge } from '@/components/shadcn/badge'
+import { Checkbox } from '@/components/shadcn/checkbox'
+import type { Level } from '@/types/type'
+import type { ColumnDef } from '@tanstack/react-table'
+import { toast } from 'sonner'
+
+export interface DataTableProblem {
+ id: number
+ title: string
+ updateTime: string
+ difficulty: string
+ submissionCount: number
+ acceptedRate: number
+ languages: string[]
+ score?: number
+ order?: number
+}
+
+export const DEFAULT_PAGE_SIZE = 5
+export const MAX_SELECTED_ROW_COUNT = 20
+export const ERROR_MESSAGE = `You can only import up to ${MAX_SELECTED_ROW_COUNT} problems in a assignment`
+export const columns: ColumnDef[] = [
+ {
+ accessorKey: 'select',
+ header: ({ table }) => (
+ {
+ const currentPageNotSelectedCount = table
+ .getRowModel()
+ .rows.filter((row) => !row.getIsSelected()).length
+ const selectedRowCount = table.getSelectedRowModel().rows.length
+
+ if (
+ selectedRowCount + currentPageNotSelectedCount <=
+ MAX_SELECTED_ROW_COUNT
+ ) {
+ table.toggleAllPageRowsSelected()
+ table.setSorting([{ id: 'select', desc: true }]) // NOTE: force to trigger sortingFn
+ } else {
+ toast.error(ERROR_MESSAGE)
+ }
+ }}
+ aria-label="Select all"
+ className="translate-y-[2px]"
+ />
+ ),
+ cell: ({ row }) => (
+
+ ),
+ sortingFn: (rowA, rowB) => {
+ const aSelected = rowA.getIsSelected()
+ const bSelected = rowB.getIsSelected()
+
+ if (aSelected === bSelected) {
+ return 0
+ }
+ return aSelected ? 1 : -1
+ },
+ enableHiding: false
+ },
+ {
+ accessorKey: 'title',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ return (
+
+ {row.getValue('title')}
+
+ )
+ },
+ enableHiding: false
+ },
+ {
+ accessorKey: 'languages',
+ header: () => {},
+ cell: () => {},
+ filterFn: (row, id, value) => {
+ const languages = row.original.languages
+ if (!languages?.length) {
+ return false
+ }
+
+ const langValue: string[] = row.getValue(id)
+ const valueArray = value as string[]
+ const result = langValue.some((language) => valueArray.includes(language))
+ return result
+ }
+ },
+ {
+ accessorKey: 'updateTime',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ return {row.original.updateTime.substring(2, 10)}
+ }
+ },
+ {
+ accessorKey: 'difficulty',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const level: string = row.getValue('difficulty')
+ const formattedLevel = `Level ${level.slice(-1)}`
+ return (
+
+
+ {formattedLevel}
+
+
+ )
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id))
+ }
+ },
+ {
+ accessorKey: 'submissionCount',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ return {row.getValue('submissionCount')}
+ }
+ },
+ {
+ accessorKey: 'acceptedRate',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const acceptedRate: number = row.getValue('acceptedRate')
+ const acceptedRateFloat = (acceptedRate * 100).toFixed(2)
+ return {acceptedRateFloat}%
+ }
+ }
+]
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/_libs/schemas.ts b/apps/frontend/app/admin/group/[groupId]/assignment/_libs/schemas.ts
new file mode 100644
index 0000000000..f09d265be2
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/_libs/schemas.ts
@@ -0,0 +1,87 @@
+import * as v from 'valibot'
+
+export const createSchema = v.object({
+ title: v.pipe(
+ v.string(),
+ v.minLength(1, 'The title must contain at least 1 character(s)'),
+ v.maxLength(200, 'The title can only be up to 200 characters long')
+ ),
+
+ isRankVisible: v.boolean(),
+ isVisible: v.boolean(),
+ description: v.pipe(
+ v.string(),
+ v.minLength(1),
+ v.check((value) => value !== '
')
+ ),
+ startTime: v.date(),
+ endTime: v.date(),
+ enableCopyPaste: v.boolean(),
+ isJudgeResultVisible: v.boolean(),
+ invitationCode: v.nullable(
+ v.pipe(
+ v.string(),
+ v.regex(/^\d{6}$/, 'The invitation code must be a 6-digit number')
+ )
+ )
+})
+
+export const editSchema = v.object({
+ id: v.number(),
+ ...createSchema.entries
+})
+
+export interface AssignmentProblem {
+ id: number
+ title: string
+ order: number
+ difficulty: string
+ score: number
+}
+
+export interface ScoreSummary {
+ studentId: string
+ realName?: string | null
+ username: string
+ submittedProblemCount: number
+ totalProblemCount: number
+ assignmentScore: number
+ assignmentPerfectScore: number
+ problemScores: {
+ problemId: number
+ score: number
+ maxScore: number
+ }[]
+}
+
+export interface ProblemData {
+ order: number
+ score: number
+ problemId: number
+}
+
+export interface OverallSubmission {
+ title: string
+ studentId: string
+ realname?: string | null
+ username: string
+ result: string
+ language: string
+ submissionTime: string
+ codeSize?: number | null
+ ip?: string | null
+ id: number
+ order?: number | null
+ problemId: number
+}
+
+export interface UserSubmission {
+ problemTitle: string
+ submissionResult: string
+ language: string
+ submissionTime: string
+ codeSize?: number | null
+ ip?: string | null
+ id: number
+ order?: number | null
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/create/_components/CreateAssignmentForm.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/create/_components/CreateAssignmentForm.tsx
new file mode 100644
index 0000000000..0210c7c083
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/create/_components/CreateAssignmentForm.tsx
@@ -0,0 +1,129 @@
+'use client'
+
+import { useConfirmNavigationContext } from '@/app/admin/_components/ConfirmNavigation'
+import {
+ CREATE_ASSIGNMENT,
+ IMPORT_PROBLEMS_TO_ASSIGNMENT
+} from '@/graphql/assignment/mutations'
+// import { UPDATE_ASSIGNMENT_PROBLEMS_ORDER } from '@/graphql/problem/mutations'
+import { useMutation } from '@apollo/client'
+import type { CreateAssignmentInput } from '@generated/graphql'
+import { valibotResolver } from '@hookform/resolvers/valibot'
+import type { Route } from 'next'
+import { useRouter } from 'next/navigation'
+import type { ReactNode } from 'react'
+import { FormProvider, useForm } from 'react-hook-form'
+import { toast } from 'sonner'
+import type { AssignmentProblem } from '../../_libs/schemas'
+import { createSchema } from '../../_libs/schemas'
+
+interface CreateAssignmentFormProps {
+ groupId: string
+ children: ReactNode
+ problems: AssignmentProblem[]
+ setIsCreating: (isCreating: boolean) => void
+}
+
+export function CreateAssignmentForm({
+ groupId,
+ children,
+ problems,
+ setIsCreating
+}: CreateAssignmentFormProps) {
+ const methods = useForm({
+ resolver: valibotResolver(createSchema),
+ defaultValues: {
+ invitationCode: null,
+ isRankVisible: true,
+ isVisible: true,
+ enableCopyPaste: false,
+ isJudgeResultVisible: false
+ }
+ })
+
+ const { setShouldSkipWarning } = useConfirmNavigationContext()
+ const router = useRouter()
+
+ const [createAssignment, { error }] = useMutation(CREATE_ASSIGNMENT)
+ const [importProblemsToAssignment] = useMutation(
+ IMPORT_PROBLEMS_TO_ASSIGNMENT
+ )
+ // const [updateAssignmentProblemsOrder] = useMutation(
+ // UPDATE_ASSIGNMENT_PROBLEMS_ORDER
+ // )
+
+ const isSubmittable = (input: CreateAssignmentInput) => {
+ if (input.startTime >= input.endTime) {
+ toast.error('Start time must be less than end time')
+ return
+ }
+
+ if (
+ new Set(problems.map((problem) => problem.order)).size !== problems.length
+ ) {
+ toast.error('Duplicate problem order found')
+ return
+ }
+ onSubmit()
+ }
+
+ const onSubmit = async () => {
+ const input = methods.getValues()
+ setIsCreating(true)
+
+ const { data } = await createAssignment({
+ variables: {
+ groupId: Number(groupId),
+ input
+ }
+ })
+
+ const assignmentId = Number(data?.createAssignment.id)
+
+ if (error) {
+ toast.error('Failed to create assignment')
+ setIsCreating(false)
+ return
+ }
+
+ await importProblemsToAssignment({
+ variables: {
+ groupId: Number(groupId),
+ assignmentId,
+ problemIdsWithScore: problems.map((problem) => {
+ return {
+ problemId: problem.id,
+ score: problem.score
+ }
+ })
+ }
+ })
+
+ // TODO: 백엔드 작업 완료되면 Assignment problem order 변경 기능 추가
+ // const orderArray = problems
+ // .sort((a, b) => a.order - b.order)
+ // .map((problem) => problem.id)
+
+ // await updateAssignmentProblemsOrder({
+ // variables: {
+ // groupId: 1,
+ // assignmentId,
+ // orders: orderArray
+ // }
+ // })
+
+ setShouldSkipWarning(true)
+ toast.success('Assignment created successfully')
+ router.push(`/admin/group/${groupId}/assignment` as Route)
+ router.refresh()
+ }
+
+ return (
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/create/error.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/create/error.tsx
new file mode 100644
index 0000000000..4c8d7d9857
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/create/error.tsx
@@ -0,0 +1,23 @@
+'use client'
+
+import { ErrorDetail } from '@/components/ErrorDetail'
+import { captureError } from '@/libs/captureError'
+import { useEffect } from 'react'
+
+interface Props {
+ error: Error & { digest?: string }
+ reset: () => void
+}
+
+export default function Error({ error }: Props) {
+ useEffect(() => {
+ captureError(error)
+ }, [error])
+
+ return (
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/create/page.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/create/page.tsx
new file mode 100644
index 0000000000..63accbffac
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/create/page.tsx
@@ -0,0 +1,103 @@
+'use client'
+
+import { ConfirmNavigation } from '@/app/admin/_components/ConfirmNavigation'
+import { DescriptionForm } from '@/app/admin/_components/DescriptionForm'
+import { FormSection } from '@/app/admin/_components/FormSection'
+import { SwitchField } from '@/app/admin/_components/SwitchField'
+import { TimeForm } from '@/app/admin/_components/TimeForm'
+import { TitleForm } from '@/app/admin/_components/TitleForm'
+import { Button } from '@/components/shadcn/button'
+import { ScrollArea } from '@/components/shadcn/scroll-area'
+import type { Route } from 'next'
+import Link from 'next/link'
+import { useState } from 'react'
+import { FaAngleLeft } from 'react-icons/fa6'
+import { IoMdCheckmarkCircleOutline } from 'react-icons/io'
+import { AssignmentProblemListLabel } from '../_components/AssignmentProblemListLabel'
+import { AssignmentProblemTable } from '../_components/AssignmentProblemTable'
+import { ImportDialog } from '../_components/ImportDialog'
+import type { AssignmentProblem } from '../_libs/schemas'
+import { CreateAssignmentForm } from './_components/CreateAssignmentForm'
+
+export default function Page({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+ const [problems, setProblems] = useState([])
+ const [isCreating, setIsCreating] = useState(false)
+
+ return (
+
+
+
+
+
+
+
+ Create Assignment
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Create
+
+
+
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/assignment/page.tsx b/apps/frontend/app/admin/group/[groupId]/assignment/page.tsx
new file mode 100644
index 0000000000..7072896bdb
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/assignment/page.tsx
@@ -0,0 +1,38 @@
+import { FetchErrorFallback } from '@/components/FetchErrorFallback'
+import { Button } from '@/components/shadcn/button'
+import { ErrorBoundary } from '@suspensive/react'
+import { PlusCircleIcon } from 'lucide-react'
+import type { Route } from 'next'
+import Link from 'next/link'
+import { Suspense } from 'react'
+import {
+ AssignmentTable,
+ AssignmentTableFallback
+} from './_components/AssignmentTable'
+
+export const dynamic = 'force-dynamic'
+
+export default function Page({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+ return (
+
+
+
+
Assignment List
+
Here's a list you made
+
+
+
+
+ Create
+
+
+
+
+ }>
+
+
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/edit/page.tsx b/apps/frontend/app/admin/group/[groupId]/edit/page.tsx
new file mode 100644
index 0000000000..122f78b7ef
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/edit/page.tsx
@@ -0,0 +1,4 @@
+export default function Page({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+ return {groupId} Edit Page
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/error.tsx b/apps/frontend/app/admin/group/[groupId]/error.tsx
new file mode 100644
index 0000000000..bc069a85aa
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/error.tsx
@@ -0,0 +1,20 @@
+'use client'
+
+import { ErrorDetail } from '@/components/ErrorDetail'
+import { captureError } from '@/libs/captureError'
+import { useEffect } from 'react'
+
+interface Props {
+ error: Error & { digest?: string }
+ reset: () => void
+}
+
+export default function Error({ error }: Props) {
+ useEffect(() => {
+ captureError(error)
+ }, [error])
+
+ return (
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/exam/page.tsx b/apps/frontend/app/admin/group/[groupId]/exam/page.tsx
new file mode 100644
index 0000000000..c6d18896f3
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/exam/page.tsx
@@ -0,0 +1,4 @@
+export default function Page({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+ return {groupId} Exam Page
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/grade/page.tsx b/apps/frontend/app/admin/group/[groupId]/grade/page.tsx
new file mode 100644
index 0000000000..9634d9a55b
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/grade/page.tsx
@@ -0,0 +1,4 @@
+export default function Page({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+ return {groupId} Grade Page
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/layout.tsx b/apps/frontend/app/admin/group/[groupId]/layout.tsx
new file mode 100644
index 0000000000..c0b653ce78
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/layout.tsx
@@ -0,0 +1,51 @@
+'use client'
+
+import { Button } from '@/components/shadcn/button'
+import { cn } from '@/libs/utils'
+import { useState } from 'react'
+import { MdClose, MdMenu } from 'react-icons/md'
+import { GroupSideBar } from './_components/GroupSideBar'
+
+export default function Layout({
+ children,
+ params
+}: {
+ children: React.ReactNode
+ params: { groupId: string }
+}) {
+ const { groupId } = params
+ const [isSideBarOpen, setIsSideBarOpen] = useState(true)
+ return (
+
+ {isSideBarOpen && (
+
+ setIsSideBarOpen(false)}
+ >
+
+
+
+
+ )}
+
+ {!isSideBarOpen && (
+ setIsSideBarOpen(true)}
+ >
+
+
+ )}
+ {children}
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/notice/page.tsx b/apps/frontend/app/admin/group/[groupId]/notice/page.tsx
new file mode 100644
index 0000000000..856d67e786
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/notice/page.tsx
@@ -0,0 +1,4 @@
+export default function Page({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+ return {groupId} Notice Page
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/page.tsx b/apps/frontend/app/admin/group/[groupId]/page.tsx
new file mode 100644
index 0000000000..cb92db38d5
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/page.tsx
@@ -0,0 +1,52 @@
+'use client'
+
+import { KatexContent } from '@/components/KatexContent'
+import { Button } from '@/components/shadcn/button'
+import { GET_GROUP } from '@/graphql/group/queries'
+import periodIcon from '@/public/icons/period.svg'
+import { useSuspenseQuery } from '@apollo/client'
+import type { Route } from 'next'
+import Image from 'next/image'
+import Link from 'next/link'
+import { FaAngleLeft, FaPencil } from 'react-icons/fa6'
+
+export default function Page({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+
+ const { data } = useSuspenseQuery(GET_GROUP, {
+ variables: {
+ groupId: Number(groupId)
+ }
+ })
+ const group = data.getGroup
+
+ return (
+
+
+
+
+
+
+ {group.groupName}
+
+
+
+
+ Edit
+
+
+
+
+
Invitation code: 000409
+
+
+ 2025 Spring
+
+
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/qna/page.tsx b/apps/frontend/app/admin/group/[groupId]/qna/page.tsx
new file mode 100644
index 0000000000..d8ec3b3d4c
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/qna/page.tsx
@@ -0,0 +1,4 @@
+export default function Page({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+ return {groupId} Q&A Page
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/user/_components/Columns.tsx b/apps/frontend/app/admin/group/[groupId]/user/_components/Columns.tsx
new file mode 100644
index 0000000000..a31ef99e12
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/user/_components/Columns.tsx
@@ -0,0 +1,34 @@
+import type { ColumnDef } from '@tanstack/react-table'
+
+interface DataTableGroupUser {
+ id: number
+ studentId: string
+ name: string
+ email: string
+ role: string
+}
+
+export const columns: ColumnDef[] = [
+ {
+ accessorKey: 'studentId',
+ header: 'Student ID',
+ cell: ({ row }) => {row.getValue('studentId')}
+ },
+ {
+ accessorKey: 'name',
+ header: 'Name',
+ cell: ({ row }) => {
+ return {row.getValue('name') || '-'}
+ }
+ },
+ {
+ accessorKey: 'email',
+ header: 'Email',
+ cell: ({ row }) => {row.getValue('email')}
+ },
+ {
+ accessorKey: 'role',
+ header: 'Role',
+ cell: ({ row }) => {row.getValue('role')}
+ }
+]
diff --git a/apps/frontend/app/admin/group/[groupId]/user/_components/GroupUserTable.tsx b/apps/frontend/app/admin/group/[groupId]/user/_components/GroupUserTable.tsx
new file mode 100644
index 0000000000..cd29f8ff9f
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/user/_components/GroupUserTable.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+import {
+ DataTable,
+ DataTableFallback,
+ DataTablePagination,
+ DataTableRoot
+} from '@/app/admin/_components/table'
+import { GET_GROUP_MEMBERS } from '@/graphql/user/queries'
+import { useSuspenseQuery } from '@apollo/client'
+import { columns } from './Columns'
+
+interface GroupUserTableProps {
+ groupId: string
+}
+
+export function GroupUserTable({ groupId }: GroupUserTableProps) {
+ const { data } = useSuspenseQuery(GET_GROUP_MEMBERS, {
+ variables: {
+ groupId: Number(groupId),
+ cursor: 1,
+ take: 1000,
+ leaderOnly: false
+ }
+ })
+ const users = data.getGroupMembers.map((member) => ({
+ ...member,
+ id: member.userId
+ }))
+
+ return (
+
+ {/* 유저를 눌렀을 때 해당 학생의 여러 assignment 점수들을 통합한 overall 페이지로 이동하게 만들 예정*/}
+
+
+
+ )
+}
+
+export function GroupUserTableFallback() {
+ return
+}
diff --git a/apps/frontend/app/admin/group/[groupId]/user/page.tsx b/apps/frontend/app/admin/group/[groupId]/user/page.tsx
new file mode 100644
index 0000000000..c3af830ee9
--- /dev/null
+++ b/apps/frontend/app/admin/group/[groupId]/user/page.tsx
@@ -0,0 +1,31 @@
+import { FetchErrorFallback } from '@/components/FetchErrorFallback'
+import { Button } from '@/components/shadcn/button'
+import { ErrorBoundary } from '@suspensive/react'
+import { PlusCircleIcon } from 'lucide-react'
+import { Suspense } from 'react'
+import {
+ GroupUserTable,
+ GroupUserTableFallback
+} from './_components/GroupUserTable'
+
+export const dynamic = 'force-dynamic'
+
+export default function User({ params }: { params: { groupId: string } }) {
+ const { groupId } = params
+ return (
+
+
+
User List
+
+
+ Invite
+
+
+
+ }>
+
+
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/group/_components/Columns.tsx b/apps/frontend/app/admin/group/_components/Columns.tsx
new file mode 100644
index 0000000000..ee2c987230
--- /dev/null
+++ b/apps/frontend/app/admin/group/_components/Columns.tsx
@@ -0,0 +1,84 @@
+import { DataTableColumnHeader } from '@/app/admin/_components/table/DataTableColumnHeader'
+import { Badge } from '@/components/shadcn/badge'
+import { Checkbox } from '@/components/shadcn/checkbox'
+import type { SemesterSeason } from '@/types/type'
+import type { ColumnDef } from '@tanstack/react-table'
+
+interface DataTableGroup {
+ id: number
+ groupName: string
+ description: string
+}
+
+export const columns: ColumnDef[] = [
+ {
+ id: 'select',
+ header: ({ table }) => (
+ e.stopPropagation()}
+ checked={table.getIsAllPageRowsSelected()}
+ onCheckedChange={(value) =>
+ table.toggleAllPageRowsSelected(Boolean(value))
+ }
+ aria-label="Select all"
+ className="translate-y-[2px] bg-white"
+ />
+ ),
+ cell: ({ row }) => (
+ e.stopPropagation()}
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(Boolean(value))}
+ aria-label="Select row"
+ className="translate-y-[2px] bg-white"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false
+ },
+ {
+ accessorKey: 'groupName',
+ header: () => Name
,
+ cell: ({ row }) => (
+
+ {row.getValue('groupName')}
+
+ )
+ },
+ {
+ accessorKey: 'courseNum',
+ header: ({ column }) => (
+
+
+
+ ),
+ cell: () => DAS0000_00
+ },
+ {
+ accessorKey: 'semester',
+ header: ({ column }) => (
+
+
+
+ ),
+ cell: () => {
+ const yearSeason = '2025 Spring'
+ const season = yearSeason.split(' ')[1]
+ return (
+
+
+ {yearSeason}
+
+
+ )
+ }
+ },
+ {
+ accessorKey: 'members',
+ header: 'Members',
+ cell: () => 49
+ }
+]
diff --git a/apps/frontend/app/admin/group/_components/GroupTable.tsx b/apps/frontend/app/admin/group/_components/GroupTable.tsx
new file mode 100644
index 0000000000..7bf9ce2455
--- /dev/null
+++ b/apps/frontend/app/admin/group/_components/GroupTable.tsx
@@ -0,0 +1,83 @@
+'use client'
+
+import {
+ DataTable,
+ DataTableDeleteButton,
+ DataTableFallback,
+ DataTablePagination,
+ DataTableRoot,
+ DataTableSearchBar
+} from '@/app/admin/_components/table'
+import { DELETE_GROUP } from '@/graphql/group/mutation'
+import { GET_GROUPS } from '@/graphql/group/queries'
+import { useApolloClient, useMutation, useSuspenseQuery } from '@apollo/client'
+import type { Route } from 'next'
+import { columns } from './Columns'
+
+const headerStyle = {
+ select: '',
+ groupName: 'w-2/5',
+ courseNum: 'px-0 w-1/5',
+ semester: 'px-0 w-1/5',
+ members: 'px-0 w-1/6'
+}
+
+export function GroupTable() {
+ const { data } = useSuspenseQuery(GET_GROUPS, {
+ variables: {
+ cursor: 1,
+ take: 5
+ }
+ })
+ const groups = data.getGroups.map((group) => ({
+ id: Number(group.id),
+ groupName: group.groupName,
+ description: group.description
+ }))
+
+ return (
+
+
+
+ {/* TODO: 백엔드 구현 이후 Duplicate 버튼 추가 예정 */}
+
+
+ `/admin/group/${data.id}` as Route}
+ />
+
+
+ )
+}
+
+function ContestsDeleteButton() {
+ const client = useApolloClient()
+ const [deleteGroup] = useMutation(DELETE_GROUP)
+
+ const deleteTarget = (id: number) => {
+ return deleteGroup({
+ variables: {
+ groupId: id
+ }
+ })
+ }
+
+ const onSuccess = () => {
+ client.refetchQueries({
+ include: [GET_GROUPS]
+ })
+ }
+
+ return (
+
+ )
+}
+
+export function GroupTableFallback() {
+ return
+}
diff --git a/apps/frontend/app/admin/group/error.tsx b/apps/frontend/app/admin/group/error.tsx
new file mode 100644
index 0000000000..9c7781e690
--- /dev/null
+++ b/apps/frontend/app/admin/group/error.tsx
@@ -0,0 +1,18 @@
+'use client'
+
+import { ErrorDetail } from '@/components/ErrorDetail'
+import { captureError } from '@/libs/captureError'
+import { useEffect } from 'react'
+
+interface Props {
+ error: Error & { digest?: string }
+ reset: () => void
+}
+
+export default function Error({ error }: Props) {
+ useEffect(() => {
+ captureError(error)
+ }, [error])
+
+ return
+}
diff --git a/apps/frontend/app/admin/group/page.tsx b/apps/frontend/app/admin/group/page.tsx
new file mode 100644
index 0000000000..d32295bb7b
--- /dev/null
+++ b/apps/frontend/app/admin/group/page.tsx
@@ -0,0 +1,21 @@
+import { FetchErrorFallback } from '@/components/FetchErrorFallback'
+import { ErrorBoundary } from '@suspensive/react'
+import { Suspense } from 'react'
+import { GroupTable, GroupTableFallback } from './_components/GroupTable'
+
+export const dynamic = 'force-dynamic'
+
+export default function Page() {
+ return (
+
+
+
Group List
+
+
+ }>
+
+
+
+
+ )
+}
diff --git a/apps/frontend/app/admin/layout.tsx b/apps/frontend/app/admin/layout.tsx
index d6cc87bb80..7376aceee4 100644
--- a/apps/frontend/app/admin/layout.tsx
+++ b/apps/frontend/app/admin/layout.tsx
@@ -3,6 +3,7 @@ import codedangLogo from '@/public/logos/codedang-with-text.svg'
import Image from 'next/image'
import Link from 'next/link'
import { ClientApolloProvider } from './_components/ApolloProvider'
+import { GroupAdminSideBar } from './_components/GroupAdminSideBar'
import { SideBar } from './_components/SideBar'
export default function Layout({ children }: { children: React.ReactNode }) {
@@ -20,9 +21,10 @@ export default function Layout({ children }: { children: React.ReactNode }) {
height={28}
/>
-
-
+
+ {/* */}
+ {/*TODO: role이 groupAdmin인지 확인하고 아니면 그냥 SideBar를 보여주도록 할 예정 */}
{/*