From 71cc6e30fa3652b0b589c6fc5498894180e7ec74 Mon Sep 17 00:00:00 2001 From: jspark2000 Date: Tue, 12 Mar 2024 12:11:24 +0000 Subject: [PATCH] feat: implements submit survey pages --- .../app/src/attendance/attendance.module.ts | 3 +- .../app/src/attendance/attendance.service.ts | 86 +++++++ .../app/src/attendance/dto/attendance.dto.ts | 62 +++++ backend/app/src/roster/roster.controller.ts | 12 + backend/app/src/roster/roster.module.ts | 3 +- backend/app/src/roster/roster.service.ts | 18 ++ backend/app/src/survey/dto/surveyGroup.dto.ts | 14 +- backend/app/src/survey/survey.controller.ts | 16 +- backend/app/src/survey/survey.module.ts | 3 + backend/app/src/survey/survey.service.ts | 103 +++++++- .../survey/[id]/_components/StudentIdForm.tsx | 81 ++++++ .../[id]/_components/SubmitSurveyForm.tsx | 236 ++++++++++++++++++ .../[id]/_components/SubmitSurveySection.tsx | 28 +++ .../[id]/_components/SurveyGroupCard.tsx | 56 +++++ .../src/app/(public)/survey/[id]/page.tsx | 42 ++++ .../_components/SurveyGroupCardSection.tsx | 82 ++++++ frontend/src/app/(public)/survey/page.tsx | 17 ++ .../_components/DeleteSurveyGroupForm.tsx | 1 + .../new/_components/CreateSurveyForm.tsx | 2 + frontend/src/components/ui/card.tsx | 75 ++++++ frontend/src/lib/actions.ts | 20 +- frontend/src/lib/enums.ts | 12 + frontend/src/lib/forms.ts | 21 +- frontend/src/lib/types/schedule.ts | 12 + frontend/src/lib/types/survey.ts | 7 + 25 files changed, 1002 insertions(+), 10 deletions(-) create mode 100644 backend/app/src/attendance/dto/attendance.dto.ts create mode 100644 frontend/src/app/(public)/survey/[id]/_components/StudentIdForm.tsx create mode 100644 frontend/src/app/(public)/survey/[id]/_components/SubmitSurveyForm.tsx create mode 100644 frontend/src/app/(public)/survey/[id]/_components/SubmitSurveySection.tsx create mode 100644 frontend/src/app/(public)/survey/[id]/_components/SurveyGroupCard.tsx create mode 100644 frontend/src/app/(public)/survey/[id]/page.tsx create mode 100644 frontend/src/app/(public)/survey/_components/SurveyGroupCardSection.tsx create mode 100644 frontend/src/app/(public)/survey/page.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/lib/types/schedule.ts diff --git a/backend/app/src/attendance/attendance.module.ts b/backend/app/src/attendance/attendance.module.ts index cf98c48..4c8b461 100644 --- a/backend/app/src/attendance/attendance.module.ts +++ b/backend/app/src/attendance/attendance.module.ts @@ -4,6 +4,7 @@ import { AttendanceService } from './attendance.service' @Module({ controllers: [AttendanceController], - providers: [AttendanceService] + providers: [AttendanceService], + exports: [AttendanceService] }) export class AttendanceModule {} diff --git a/backend/app/src/attendance/attendance.service.ts b/backend/app/src/attendance/attendance.service.ts index 5de316d..4cba8c4 100644 --- a/backend/app/src/attendance/attendance.service.ts +++ b/backend/app/src/attendance/attendance.service.ts @@ -1,7 +1,93 @@ import { Service } from '@libs/decorator' +import { EntityNotExistException, UnexpectedException } from '@libs/exception' import { PrismaService } from '@libs/prisma' +import { calculatePaginationOffset } from '@libs/utils' +import { Prisma } from '@prisma/client' +import type { + AttendanceWithRoster, + CreateAttendanceDTO +} from './dto/attendance.dto' @Service() export class AttendanceService { constructor(private readonly prisma: PrismaService) {} + + async createAttendances( + rosterId: number, + attendances: CreateAttendanceDTO[] + ): Promise<{ count: number }> { + try { + const attendancesWithRosterId = attendances.map((attendance) => { + return { + ...attendance, + rosterId + } + }) + return await this.prisma.attendance.createMany({ + data: attendancesWithRosterId + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new EntityNotExistException('로스터가 존재하지 않습니다') + } + throw new UnexpectedException(error) + } + } + + async getAttendances( + scheduleId: number, + searchTerm: string, + page: number, + limit = 10 + ): Promise<{ attendances: AttendanceWithRoster[] }> { + try { + const attendances = await this.prisma.attendance.findMany({ + where: { + scheduleId, + Roster: { + OR: [ + { + offPosition: { + contains: searchTerm + }, + defPosition: { + contains: searchTerm + }, + splPosition: { + contains: searchTerm + } + } + ] + } + }, + include: { + Roster: { + select: { + id: true, + name: true, + type: true, + registerYear: true, + offPosition: true, + defPosition: true, + splPosition: true + } + } + }, + take: limit, + skip: calculatePaginationOffset(page, limit), + orderBy: { + Roster: { + name: 'asc' + } + } + }) + + return { attendances } + } catch (error) { + throw new UnexpectedException(error) + } + } } diff --git a/backend/app/src/attendance/dto/attendance.dto.ts b/backend/app/src/attendance/dto/attendance.dto.ts new file mode 100644 index 0000000..e4c119b --- /dev/null +++ b/backend/app/src/attendance/dto/attendance.dto.ts @@ -0,0 +1,62 @@ +import { + AttendanceLocation, + AttendanceResponse, + type Attendance, + type RosterType +} from '@prisma/client' +import { + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsString +} from 'class-validator' + +export class CreateAttendanceDTO { + @IsNumber() + @IsNotEmpty() + scheduleId: number + + @IsEnum(AttendanceResponse) + @IsNotEmpty() + response: AttendanceResponse + + @IsString() + @IsOptional() + reason?: string + + @IsEnum(AttendanceLocation) + @IsOptional() + location?: AttendanceLocation +} + +export class UpdateAttendanceDTO { + @IsEnum(AttendanceResponse) + @IsOptional() + response: AttendanceResponse + + @IsString() + @IsOptional() + reason?: string + + @IsEnum(AttendanceLocation) + @IsOptional() + location?: AttendanceLocation + + @IsEnum(AttendanceResponse) + @IsOptional() + result: AttendanceResponse +} + +export interface AttendanceWithRoster extends Attendance { + // eslint-disable-next-line @typescript-eslint/naming-convention + Roster: { + id: number + name: string + type: RosterType + registerYear: number + offPosition?: string + defPosition?: string + splPosition?: string + } +} diff --git a/backend/app/src/roster/roster.controller.ts b/backend/app/src/roster/roster.controller.ts index 79bb387..80170e7 100644 --- a/backend/app/src/roster/roster.controller.ts +++ b/backend/app/src/roster/roster.controller.ts @@ -50,6 +50,18 @@ export class RosterController { } } + @Public() + @Get('studentId/:studentId') + async getRostersByStudentId( + @Param('studentId') studentId: string + ): Promise { + try { + return await this.rosterService.getRosterByStudentId(studentId) + } catch (error) { + BusinessExceptionHandler(error) + } + } + @Roles(Role.Admin) @Put(':rosterId') async updateRoster( diff --git a/backend/app/src/roster/roster.module.ts b/backend/app/src/roster/roster.module.ts index b4bc3af..079c304 100644 --- a/backend/app/src/roster/roster.module.ts +++ b/backend/app/src/roster/roster.module.ts @@ -4,6 +4,7 @@ import { RosterService } from './roster.service' @Module({ providers: [RosterService], - controllers: [RosterController] + controllers: [RosterController], + exports: [RosterService] }) export class RosterModule {} diff --git a/backend/app/src/roster/roster.service.ts b/backend/app/src/roster/roster.service.ts index 1a9a3ac..d2efb02 100644 --- a/backend/app/src/roster/roster.service.ts +++ b/backend/app/src/roster/roster.service.ts @@ -38,6 +38,24 @@ export class RosterService { } } + async getRosterByStudentId(studentId: string): Promise { + try { + return await this.prisma.roster.findUniqueOrThrow({ + where: { + studentId + } + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new EntityNotExistException('로스터가 존재하지 않습니다') + } + throw new UnexpectedException(error) + } + } + async getRosters( page: number, limit = 10, diff --git a/backend/app/src/survey/dto/surveyGroup.dto.ts b/backend/app/src/survey/dto/surveyGroup.dto.ts index 68fd972..2e21abd 100644 --- a/backend/app/src/survey/dto/surveyGroup.dto.ts +++ b/backend/app/src/survey/dto/surveyGroup.dto.ts @@ -1,10 +1,12 @@ +import { CreateAttendanceDTO } from '@/attendance/dto/attendance.dto' import { Type } from 'class-transformer' import { IsBoolean, IsDate, IsNotEmpty, IsOptional, - IsString + IsString, + ValidateNested } from 'class-validator' export class CreateSurveyGroupDTO { @@ -46,3 +48,13 @@ export class UpdateSurveyGroupDTO { @IsOptional() required?: boolean } + +export class SubmitSurveyDTO { + @IsString() + @IsNotEmpty() + studentId: string + + @ValidateNested({ each: true }) + @Type(() => CreateAttendanceDTO) + attendances: CreateAttendanceDTO[] +} diff --git a/backend/app/src/survey/survey.controller.ts b/backend/app/src/survey/survey.controller.ts index 22c4867..990f138 100644 --- a/backend/app/src/survey/survey.controller.ts +++ b/backend/app/src/survey/survey.controller.ts @@ -15,7 +15,8 @@ import { Role, type Schedule, type SurveyGroup } from '@prisma/client' import { CreateScheduleDTO, UpdateScheduleDTO } from './dto/schedule.dto' import { CreateSurveyGroupDTO, - UpdateSurveyGroupDTO + UpdateSurveyGroupDTO, + SubmitSurveyDTO } from './dto/surveyGroup.dto' import { SurveyService } from './survey.service' @@ -78,6 +79,19 @@ export class SurveyController { } } + @Public() + @Post('/groups/:surveyGroupId/submit') + async submitSurvey( + @Param('surveyGroupId', ParseIntPipe) surveyGroupId: number, + @Body() surveyDTO: SubmitSurveyDTO + ): Promise<{ count: number }> { + try { + return await this.surveyService.submitSurvey(surveyGroupId, surveyDTO) + } catch (error) { + BusinessExceptionHandler(error) + } + } + @Roles(Role.Manager) @Put('/groups/:surveyGroupId') async updateSurveyGroup( diff --git a/backend/app/src/survey/survey.module.ts b/backend/app/src/survey/survey.module.ts index 26cbaa5..695f8b5 100644 --- a/backend/app/src/survey/survey.module.ts +++ b/backend/app/src/survey/survey.module.ts @@ -1,8 +1,11 @@ import { Module } from '@nestjs/common' +import { AttendanceModule } from '@/attendance/attendance.module' +import { RosterModule } from '@/roster/roster.module' import { SurveyController } from './survey.controller' import { SurveyService } from './survey.service' @Module({ + imports: [RosterModule, AttendanceModule], controllers: [SurveyController], providers: [SurveyService] }) diff --git a/backend/app/src/survey/survey.service.ts b/backend/app/src/survey/survey.service.ts index 66f6893..85e69a1 100644 --- a/backend/app/src/survey/survey.service.ts +++ b/backend/app/src/survey/survey.service.ts @@ -1,17 +1,33 @@ +import { AttendanceService } from '@/attendance/attendance.service' +import { RosterService } from '@/roster/roster.service' import { Service } from '@libs/decorator' -import { EntityNotExistException, UnexpectedException } from '@libs/exception' +import { + BusinessException, + EntityNotExistException, + UnexpectedException +} from '@libs/exception' import { PrismaService } from '@libs/prisma' import { calculatePaginationOffset } from '@libs/utils' -import { Prisma, type Schedule, type SurveyGroup } from '@prisma/client' +import { + Prisma, + RosterStatus, + type Schedule, + type SurveyGroup +} from '@prisma/client' import type { CreateScheduleDTO, UpdateScheduleDTO } from './dto/schedule.dto' import type { CreateSurveyGroupDTO, + SubmitSurveyDTO, UpdateSurveyGroupDTO } from './dto/surveyGroup.dto' @Service() export class SurveyService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly rosterService: RosterService, + private readonly attendanceService: AttendanceService + ) {} async getSurveyGroup(surveyGroupId: number): Promise { try { @@ -91,10 +107,18 @@ export class SurveyService { surveyGroupDTO: CreateSurveyGroupDTO ): Promise { try { - return await this.prisma.surveyGroup.create({ + const surveyGroup = await this.prisma.surveyGroup.create({ data: surveyGroupDTO }) + + if (surveyGroupDTO.required) + await this.createSurveyTargets(surveyGroup.id) + + return surveyGroup } catch (error) { + if (error instanceof BusinessException) { + throw error + } throw new UnexpectedException(error) } } @@ -194,4 +218,75 @@ export class SurveyService { throw new UnexpectedException(error) } } + + async submitSurvey( + surveyGroupId: number, + surveyDTO: SubmitSurveyDTO + ): Promise<{ count: number }> { + try { + const { studentId, attendances } = surveyDTO + const roster = await this.rosterService.getRosterByStudentId(studentId) + + await this.prisma.surveyTarget.update({ + where: { + // eslint-disable-next-line @typescript-eslint/naming-convention + rosterId_surveyGroupId: { + rosterId: roster.id, + surveyGroupId + } + }, + data: { + submit: true + } + }) + + return await this.attendanceService.createAttendances( + roster.id, + attendances + ) + } catch (error) { + if (error instanceof BusinessException) { + throw error + } + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2025' + ) { + throw new EntityNotExistException('출석조사 대상이 아닙니다') + } + throw new UnexpectedException(error) + } + } + + private async createSurveyTargets(surveyGroupId: number): Promise { + try { + const surveyTargetRosters = await this.prisma.roster.findMany({ + where: { + status: RosterStatus.Enable + }, + select: { + id: true + } + }) + + const surveyTargets = surveyTargetRosters.map((surveyTargetRoster) => { + return { + rosterId: surveyTargetRoster.id, + surveyGroupId, + submit: false + } + }) + + await this.prisma.surveyTarget.createMany({ + data: surveyTargets + }) + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2003' + ) { + throw new EntityNotExistException('출석조사 그룹이 존재하지 않습니다') + } + } + } } diff --git a/frontend/src/app/(public)/survey/[id]/_components/StudentIdForm.tsx b/frontend/src/app/(public)/survey/[id]/_components/StudentIdForm.tsx new file mode 100644 index 0000000..279bce5 --- /dev/null +++ b/frontend/src/app/(public)/survey/[id]/_components/StudentIdForm.tsx @@ -0,0 +1,81 @@ +'use client' + +import { Button } from '@/components/ui/button' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form' +import { Input } from '@/components/ui/input' +import fetcher from '@/lib/fetcher' +import { StudentForm } from '@/lib/forms' +import { zodResolver } from '@hookform/resolvers/zod' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import type { z } from 'zod' + +export default function StudentIdForm() { + const [isFetching, setIsFetching] = useState(false) + const StudentIdFormSchema = StudentForm.pick({ studentId: true }) + const router = useRouter() + + const form = useForm>({ + resolver: zodResolver(StudentIdFormSchema), + defaultValues: { + studentId: '' + } + }) + + const onSubmit = async (data: z.infer) => { + try { + setIsFetching(true) + await fetcher.get(`/rosters/studentId/${data.studentId}`, false) + router.push(`?studentId=${data.studentId}`) + } catch (error) { + toast.error('학번을 확인해주세요') + } finally { + setIsFetching(false) + } + } + + return ( +
+ + ( + + 학번 + + + + + + )} + /> + + + + + ) +} diff --git a/frontend/src/app/(public)/survey/[id]/_components/SubmitSurveyForm.tsx b/frontend/src/app/(public)/survey/[id]/_components/SubmitSurveyForm.tsx new file mode 100644 index 0000000..d4f57df --- /dev/null +++ b/frontend/src/app/(public)/survey/[id]/_components/SubmitSurveyForm.tsx @@ -0,0 +1,236 @@ +'use client' + +import LocalTime from '@/components/Localtime' +import { Button } from '@/components/ui/button' +import { + Card, + CardContent, + CardDescription, + CardTitle +} from '@/components/ui/card' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from '@/components/ui/form' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { AttendanceLocation, AttendanceStatus, ScheduleType } from '@/lib/enums' +import fetcher from '@/lib/fetcher' +import { AttendanceFormSchema } from '@/lib/forms' +import type { Schedule } from '@/lib/types/schedule' +import { zodResolver } from '@hookform/resolvers/zod' +import { useRouter } from 'next/navigation' +import { useState } from 'react' +import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import { z } from 'zod' + +export default function SubmitSurveyForm({ + schedules, + studentId, + surveyGroupId +}: { + schedules: Schedule[] + studentId: string + surveyGroupId: number +}) { + const router = useRouter() + const [isFetching, setIsFetching] = useState(false) + + const CreateAttendanceFormSchema = z.object({ + attendances: z.array( + AttendanceFormSchema.omit({ result: true, studentId: true }) + ) + }) + + const form = useForm>({ + resolver: zodResolver(CreateAttendanceFormSchema), + defaultValues: { + attendances: schedules.map((schedule) => { + return { + scheduleId: schedule.id, + response: AttendanceStatus.Present, + location: + schedule.type === ScheduleType.IntegratedExercise + ? AttendanceLocation.Other + : AttendanceLocation.Seoul, + reason: '' + } + }) + } + }) + + const onSubmit = async (data: z.infer) => { + data.attendances.forEach((attendance, index) => { + if ( + attendance.response !== AttendanceStatus.Present && + !attendance.reason + ) { + toast.warning( + `[#${index + 1} ${schedules[index].name}]의 불참 또는 부분참석 사유를 입력하지 않았습니다` + ) + return + } + }) + + try { + setIsFetching(true) + + data.attendances.forEach((attendance) => { + if (attendance.response !== AttendanceStatus.Present) { + attendance.location = AttendanceLocation.Other + } + }) + + await fetcher.post( + `/surveys/groups/${surveyGroupId}/submit`, + { + studentId, + attendances: data.attendances + }, + false + ) + + toast.success('출석조사 제출 완료') + router.push('/survey') + } catch (error) { + toast.error('출석조사를 제출하지 못했습니다') + } finally { + setIsFetching(false) + } + } + + return ( +
+ +
출석조사 항목
+
+ {form.getValues('attendances').map((attendance, index) => { + return ( + + + #{index + 1} {schedules[index].name} + + + { + + }{' '} + ~{' '} + {} + + + ( + + 출석여부 + + + )} + /> + {schedules[index].type === ScheduleType.SeperatedExercise && ( + ( + + 출석캠퍼스 + + + )} + /> + )} + ( + + 불참 또는 부분참석 사유 + +