diff --git a/backend/src/modules/task/dto/checkpoint-report.dto.ts b/backend/src/modules/task/dto/checkpoint-report.dto.ts index 2c95e71..25d99d9 100644 --- a/backend/src/modules/task/dto/checkpoint-report.dto.ts +++ b/backend/src/modules/task/dto/checkpoint-report.dto.ts @@ -1,11 +1,46 @@ -import { IsOptional, IsString, MaxLength } from 'class-validator'; +import { + ArrayMaxSize, + IsArray, + IsIn, + IsOptional, + IsString, + IsUUID, + MaxLength, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; +import { + CHECKLIST_WORK_STATUSES, + type ChecklistWorkStatus, +} from '../entities/checklist-item.entity'; -// 중간 점검 보고 — 보고 시점의 현재 진행 상태(서버가 스냅샷)와 함께 선택 메모를 전송 +// 보고서 항목 — 항목별 진행 상태 +export class CheckpointReportItemInput { + @ApiProperty({ description: '체크리스트 항목 ID' }) + @IsUUID('4') + id!: string; + + @ApiProperty({ description: '진행 상태', enum: CHECKLIST_WORK_STATUSES }) + @IsIn(CHECKLIST_WORK_STATUSES) + workStatus!: ChecklistWorkStatus; +} + +// 중간 점검 보고 — 점검일 당일에 작업자가 항목별 상태 + 메모를 보고서로 제출 export class CheckpointReportDto { @ApiProperty({ description: '보고 메모(선택)', required: false }) @IsOptional() @IsString() @MaxLength(2000, { message: '메모가 너무 깁니다.' }) note?: string; + + @ApiProperty({ + description: '항목별 진행 상태', + type: [CheckpointReportItemInput], + }) + @IsArray() + @ArrayMaxSize(100, { message: '항목이 너무 많습니다.' }) + @ValidateNested({ each: true }) + @Type(() => CheckpointReportItemInput) + items!: CheckpointReportItemInput[]; } diff --git a/backend/src/modules/task/dto/update-work-status.dto.ts b/backend/src/modules/task/dto/update-work-status.dto.ts deleted file mode 100644 index 768b025..0000000 --- a/backend/src/modules/task/dto/update-work-status.dto.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { - ArrayMaxSize, - ArrayNotEmpty, - IsArray, - IsIn, - IsUUID, - ValidateNested, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import { ApiProperty } from '@nestjs/swagger'; -import { - CHECKLIST_WORK_STATUSES, - type ChecklistWorkStatus, -} from '../entities/checklist-item.entity'; - -// 항목별 진행 상태 입력(작업자 자가보고) -export class WorkStatusItemInput { - @ApiProperty({ description: '체크리스트 항목 ID' }) - @IsUUID('4') - id!: string; - - @ApiProperty({ description: '진행 상태', enum: CHECKLIST_WORK_STATUSES }) - @IsIn(CHECKLIST_WORK_STATUSES) - workStatus!: ChecklistWorkStatus; -} - -// 진행 상태 일괄 갱신 — 담당자만 호출 -export class UpdateWorkStatusDto { - @ApiProperty({ description: '갱신할 항목 목록', type: [WorkStatusItemInput] }) - @IsArray() - @ArrayNotEmpty({ message: '갱신할 항목이 없습니다.' }) - @ArrayMaxSize(100, { message: '항목이 너무 많습니다.' }) - @ValidateNested({ each: true }) - @Type(() => WorkStatusItemInput) - items!: WorkStatusItemInput[]; -} diff --git a/backend/src/modules/task/task.controller.ts b/backend/src/modules/task/task.controller.ts index c6b8d10..07b28c8 100644 --- a/backend/src/modules/task/task.controller.ts +++ b/backend/src/modules/task/task.controller.ts @@ -38,7 +38,6 @@ import { } from './task.service'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; -import { UpdateWorkStatusDto } from './dto/update-work-status.dto'; import { CheckpointReportDto } from './dto/checkpoint-report.dto'; import { ChangeStatusDto } from './dto/change-status.dto'; import { CreateCommentDto, CreateReplyDto } from './dto/create-comment.dto'; @@ -164,23 +163,9 @@ export class TaskController { /* ===================== 중간 점검(체크포인트) ===================== */ - @Patch(':taskId/work-status') - @HttpCode(HttpStatus.OK) - @ApiOperation({ summary: '체크리스트 진행 상태 갱신 (담당자)' }) - @ApiResponse({ status: 200, description: '갱신 성공' }) - @ApiResponse({ status: 403, description: '담당자 아님 (AUTH_003)' }) - updateWorkStatus( - @Param('projectId') projectId: string, - @Param('taskId', ParseIntPipe) taskId: number, - @Body() dto: UpdateWorkStatusDto, - @CurrentUser() user: PublicUser, - ): Promise { - return this.taskService.updateWorkStatus(projectId, user.id, taskId, dto); - } - @Post(':taskId/checkpoints/:checkpointId/report') @HttpCode(HttpStatus.CREATED) - @ApiOperation({ summary: '중간 점검 보고 (담당자) — 점검일~마감 구간' }) + @ApiOperation({ summary: '중간 점검 보고 (담당자) — 점검일 당일에만' }) @ApiResponse({ status: 201, description: '보고 접수' }) @ApiResponse({ status: 403, description: '담당자 아님 (AUTH_003)' }) @ApiResponse({ status: 404, description: '점검일을 찾을 수 없음 (RES_001)' }) diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index 8d9c42d..8bb8773 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -25,7 +25,6 @@ import { Comment } from './entities/comment.entity'; import { Attachment, type AttachmentKind } from './entities/attachment.entity'; import { CreateTaskDto } from './dto/create-task.dto'; import { UpdateTaskDto } from './dto/update-task.dto'; -import { UpdateWorkStatusDto } from './dto/update-work-status.dto'; import { CheckpointReportDto } from './dto/checkpoint-report.dto'; import { ActivityService, @@ -1334,39 +1333,17 @@ export class TaskService { }; } - // 업무 상세 + 부속(댓글/첨부/승인) 조립 // --- 중간 점검(체크포인트) --- - // 작업자 진행 상태 갱신(자가보고) — 담당자만. 항목별 work_status 갱신 - async updateWorkStatus( - projectId: string, - actingUserId: string, - seq: number, - dto: UpdateWorkStatusDto, - ): Promise { - const project = await this.getProjectOrThrow(projectId); - const task = await this.getTaskOrThrow(project.id, seq); - this.assertAssignee(task, actingUserId); - - const byId = new Map((task.checklist ?? []).map((c) => [c.id, c])); - const changed: ChecklistItem[] = []; - for (const it of dto.items) { - const item = byId.get(it.id); - if (item && item.workStatus !== it.workStatus) { - item.workStatus = it.workStatus; - changed.push(item); - } - } - if (changed.length) { - await this.checklistRepo.save(changed); - } - return this.buildDetail( - await this.getTaskOrThrow(project.id, seq), - project, - ); + // 날짜를 KST(UTC+9) 기준 'yyyy-MM-dd' 로 — '당일' 판정에 사용 + private kstDate(d: Date): string { + return new Date(d.getTime() + 9 * 60 * 60 * 1000) + .toISOString() + .slice(0, 10); } - // 중간 점검 보고 — 담당자만. 점검일~마감 구간에서 현재 진행 상태를 스냅샷 기록 + 지시자 알림 + // 중간 점검 보고 — 담당자만. '점검일 당일(KST)' 에만 가능. 보고서로 받은 항목 상태를 + // 체크리스트에 반영하고, 그 상태를 스냅샷으로 기록 + 지시자에게 알림. async submitCheckpointReport( projectId: string, actingUserId: string, @@ -1385,28 +1362,32 @@ export class TaskService { throw new NotFoundException(); } - // 보고 가능 시점: 점검일 당일부터 마감 전까지 - const now = Date.now(); - if (checkpoint.checkAt.getTime() > now) { + // 보고 가능 시점: 점검일 당일에만(지나면 누락 — 보고 불가) + if (this.kstDate(checkpoint.checkAt) !== this.kstDate(new Date())) { throw new BusinessException( 'BIZ_001', - '아직 중간 점검일이 되지 않았습니다.', - HttpStatus.BAD_REQUEST, - ); - } - if (task.dueDate && task.dueDate.getTime() <= now) { - throw new BusinessException( - 'BIZ_001', - '마감이 지나 중간 점검 보고를 보낼 수 없습니다.', + '중간 점검 보고는 점검일 당일에만 보낼 수 있습니다.', HttpStatus.BAD_REQUEST, ); } - // 현재 항목별 진행 상태 스냅샷(동결) - const snapshot: ReportItemSnapshot[] = (task.checklist ?? []) + // 보고서로 받은 상태를 항목에 반영 + 체크리스트 순서대로 스냅샷 구성 + const reportedById = new Map(dto.items.map((it) => [it.id, it.workStatus])); + const ordered = (task.checklist ?? []) .slice() - .sort((a, b) => a.sortOrder - b.sortOrder) - .map((c) => ({ itemId: c.id, text: c.text, workStatus: c.workStatus })); + .sort((a, b) => a.sortOrder - b.sortOrder); + const changed: ChecklistItem[] = []; + const snapshot: ReportItemSnapshot[] = ordered.map((c) => { + const ws = reportedById.get(c.id) ?? c.workStatus; + if (reportedById.has(c.id) && c.workStatus !== ws) { + c.workStatus = ws; + changed.push(c); + } + return { itemId: c.id, text: c.text, workStatus: ws }; + }); + if (changed.length) { + await this.checklistRepo.save(changed); + } const reporter = (task.assignees ?? []).find((a) => a.id === actingUserId) ?? null; diff --git a/frontend/src/composables/useTask.ts b/frontend/src/composables/useTask.ts index cbe0f9e..058fc0a 100644 --- a/frontend/src/composables/useTask.ts +++ b/frontend/src/composables/useTask.ts @@ -71,27 +71,17 @@ export function useTask() { /* ===================== 중간 점검(체크포인트) ===================== */ - // 체크리스트 진행 상태 일괄 갱신(담당자) - async function updateWorkStatus( - projectId: string, - taskId: number, - items: { id: string; workStatus: ChecklistWorkStatus }[], - ): Promise { - return (await api.patch(`/projects/${projectId}/tasks/${taskId}/work-status`, { - items, - })) as unknown as ApiTaskDetail - } - - // 중간 점검 보고(담당자) — 점검일~마감 구간 + // 중간 점검 보고(담당자) — 점검일 당일, 항목별 상태 + 메모를 보고서로 제출 async function submitCheckpointReport( projectId: string, taskId: number, checkpointId: string, + items: { id: string; workStatus: ChecklistWorkStatus }[], note?: string, ): Promise { return (await api.post( `/projects/${projectId}/tasks/${taskId}/checkpoints/${checkpointId}/report`, - { ...(note ? { note } : {}) }, + { items, ...(note ? { note } : {}) }, )) as unknown as ApiTaskDetail } @@ -183,7 +173,6 @@ export function useTask() { update, remove, changeStatus, - updateWorkStatus, submitCheckpointReport, addComment, addReply, diff --git a/frontend/src/pages/relay/TaskCreatePage.vue b/frontend/src/pages/relay/TaskCreatePage.vue index 381a163..8f73345 100644 --- a/frontend/src/pages/relay/TaskCreatePage.vue +++ b/frontend/src/pages/relay/TaskCreatePage.vue @@ -1092,7 +1092,7 @@ function goBack() {

- 담당자가 점검일부터 마감 전까지 진행 상태를 보고할 수 있습니다. + 담당자가 점검일 당일에 진행 상태를 보고서로 보낼 수 있습니다. (지나면 누락)

diff --git a/frontend/src/pages/relay/TaskDetailPage.vue b/frontend/src/pages/relay/TaskDetailPage.vue index 034d2a1..b06b98e 100644 --- a/frontend/src/pages/relay/TaskDetailPage.vue +++ b/frontend/src/pages/relay/TaskDetailPage.vue @@ -289,69 +289,90 @@ const isAssignee = computed( ) /* ============================================================ - * 중간 점검(체크포인트) — 작업자 진행 상태 자가보고 + 보고 전송 + * 중간 점검(체크포인트) — 작업자가 점검일 당일에 보고서로 전송, 지시자는 기록 열람 * ============================================================ */ const WORK_STATUS_LABEL: Record = { todo: '미착수', doing: '진행 중', done: '완료', } -const WORK_STATUS_ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done'] -const workSaving = ref(false) +const WORK_STATUS_OPTIONS: ChecklistWorkStatus[] = ['todo', 'doing', 'done'] + +// KST(UTC+9) 기준 'yyyy-MM-dd' — '당일' 판정 +function kstDate(iso: string | Date): string { + const t = typeof iso === 'string' ? new Date(iso).getTime() : iso.getTime() + return new Date(t + 9 * 60 * 60 * 1000).toISOString().slice(0, 10) +} +const todayKst = computed(() => kstDate(new Date())) + +type CheckpointState = 'future' | 'today' | 'reported' | 'missed' +const CP_STATE_LABEL: Record = { + future: '예정', + today: '오늘 점검', + reported: '보고 완료', + missed: '누락', +} +// 체크포인트 상태 — 보고됨 / 당일 / 지남(누락) / 예정 +function checkpointState(cp: TaskCheckpointView): CheckpointState { + const reported = task.value?.checkpointReports.some((r) => r.checkpointId === cp.id) ?? false + if (reported) return 'reported' + const cpDay = kstDate(cp.checkAt) + if (cpDay === todayKst.value) return 'today' + if (cpDay < todayKst.value) return 'missed' + return 'future' +} + +// --- 보고서 작성 폼(인라인, 점검일 당일·담당자만) --- +const reportingCpId = ref(null) +const reportItems = ref<{ id: string; text: string; workStatus: ChecklistWorkStatus }[]>([]) const reportNote = ref('') -const reporting = ref(false) +const reportSubmitting = ref(false) -// 작업자: 항목 진행 상태 변경(상시) — 담당자만, id 있는 항목만 -async function setItemWorkStatus(item: ChecklistItem, status: ChecklistWorkStatus) { - if (!task.value || !item.id || workSaving.value) return - if ((item.workStatus ?? 'todo') === status) return - workSaving.value = true - try { - task.value = await taskStore.updateWorkStatus(projectId.value, task.value.id, [ - { id: item.id, workStatus: status }, - ]) - } catch { - // 인터셉터가 알림 처리 - } finally { - workSaving.value = false - } +function openReportForm(cp: TaskCheckpointView) { + if (!task.value) return + reportingCpId.value = cp.id + // 현재(마지막 보고) 상태로 프리필 + reportItems.value = task.value.checklist + .filter((c) => c.id) + .map((c) => ({ + id: c.id as string, + text: c.text, + workStatus: c.workStatus ?? 'todo', + })) + reportNote.value = '' } - -// 보고 가능 구간: 점검일 당일 ~ 마감 전(마감 없으면 점검일 이후 상시). 담당자만. -function isReportable(cp: TaskCheckpointView): boolean { - if (!isAssignee.value || !task.value) return false - const now = Date.now() - if (now < new Date(cp.checkAt).getTime()) return false - if (task.value.dueAt && now >= new Date(task.value.dueAt).getTime()) return false - return true +function cancelReportForm() { + reportingCpId.value = null + reportItems.value = [] + reportNote.value = '' } -// 보고 불가 사유(버튼 대신 표시) -function checkpointStateLabel(cp: TaskCheckpointView): string { - if (!task.value) return '' - const now = Date.now() - if (now < new Date(cp.checkAt).getTime()) return '점검일 전' - if (task.value.dueAt && now >= new Date(task.value.dueAt).getTime()) return '마감 종료' - return '' -} - -// 중간 점검 보고 전송 — 현재 진행 상태를 스냅샷으로 기록 + 지시자 알림 -async function sendReport(cp: TaskCheckpointView) { - if (!task.value || reporting.value) return - reporting.value = true +async function submitReport() { + if (!task.value || !reportingCpId.value || reportSubmitting.value) return + reportSubmitting.value = true try { task.value = await taskStore.submitCheckpointReport( projectId.value, task.value.id, - cp.id, + reportingCpId.value, + reportItems.value.map((it) => ({ id: it.id, workStatus: it.workStatus })), reportNote.value.trim() || undefined, ) - reportNote.value = '' + cancelReportForm() } catch { // 인터셉터가 알림 처리 } finally { - reporting.value = false + reportSubmitting.value = false } } + +// --- 보고 기록 펼치기(지시자 열람) --- +const openReports = ref>(new Set()) +function toggleReport(id: string) { + const s = new Set(openReports.value) + if (s.has(id)) s.delete(id) + else s.add(id) + openReports.value = s +} // 담당자 액션 카드 테마(시안): changes→빨강 / review→앰버 / done→초록 / 그 외→accent const assigneeCardClass = computed(() => { switch (task.value?.status) { @@ -662,23 +683,9 @@ function badgeFor(name: string | undefined): { label: string; cls: string } { v-if="!item.done && isChangesReview" class="citem-tag" >보완 필요 - + - - - {{ WORK_STATUS_LABEL[item.workStatus ?? 'todo'] }} @@ -1232,6 +1239,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
중간 점검
+
    {{ cp.dateLabel }} + {{ checkpointStateLabel(cp) }} + v-else + class="cp-state" + :class="checkpointState(cp)" + >{{ CP_STATE_LABEL[checkpointState(cp)] }}
-