refactor: 중간 점검을 점검일 당일 보고서 방식으로 변경

진행 상태(미착수/진행중/완료)를 상시 토글 대신 '보고서'로 전환:
- 보고는 점검일 당일(KST)에만 가능, 지나면 누락 — 마감 전 상시 전송 제거
- 작업자가 점검일 당일 보고서 폼에서 항목별 상태 + 메모를 작성해 전송(상태는 보고에 포함)
- 지시자는 상세 화면에서 보고 기록을 접힌 목록으로 보고 클릭해 펼쳐 열람
- 체크리스트 항목은 마지막 보고 상태를 읽기 전용 배지로 표시(인라인 편집 제거)
- 상시 진행상태 갱신 엔드포인트/DTO 제거(work-status)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 15:18:58 +09:00
parent 25ca63a925
commit 4ef07f65a7
9 changed files with 342 additions and 239 deletions
@@ -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[];
}
@@ -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[];
}
+1 -16
View File
@@ -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<TaskDetailResponse> {
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)' })
+26 -45
View File
@@ -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<TaskDetailResponse> {
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;