diff --git a/backend/src/migrations/1784000000000-AddTaskCompletedAt.ts b/backend/src/migrations/1784000000000-AddTaskCompletedAt.ts new file mode 100644 index 0000000..dbf8110 --- /dev/null +++ b/backend/src/migrations/1784000000000-AddTaskCompletedAt.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +// 업무 완료 시각(completed_at) 추가. +// 종전에는 목록의 '완료' 날짜를 마감일(due_date)로 표기해 실제 완료일과 달랐다. +// 기존 완료 업무는 활동 로그(task.status_changed, statusTo=done)의 시각으로 백필한다. +// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다. +export class AddTaskCompletedAt1784000000000 implements MigrationInterface { + name = 'AddTaskCompletedAt1784000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "tasks" ADD "completed_at" TIMESTAMP WITH TIME ZONE`, + ); + + // 백필 — 업무별 '가장 최근' 완료 전이 시각을 사용한다. + // (수정 요청 후 재승인된 업무는 마지막 승인 시점이 잡힌다) + // activities 는 task FK 대신 (project_id, task_seq) 를 갖고, tasks 는 (project_id, seq) 가 유일하다. + // 활동 기록이 없는 과거 업무는 null 로 남고, 프론트는 날짜 없이 '완료'만 표시한다. + // activities.created_at 은 timestamp(무시간대, UTC 저장)이므로 + // AT TIME ZONE 'UTC' 로 명시 변환해 세션 타임존과 무관하게 정확히 옮긴다. + await queryRunner.query(` + UPDATE "tasks" t SET "completed_at" = a."created_at" AT TIME ZONE 'UTC' + FROM ( + SELECT DISTINCT ON ("project_id", "task_seq") + "project_id", "task_seq", "created_at" + FROM "activities" + WHERE "type" = 'task.status_changed' + AND "payload"->>'statusTo' = 'done' + ORDER BY "project_id", "task_seq", "created_at" DESC + ) a + WHERE t."project_id" = a."project_id" + AND t."seq" = a."task_seq" + AND t."status" = 'done' + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "completed_at"`); + } +} diff --git a/backend/src/modules/task/entities/task.entity.ts b/backend/src/modules/task/entities/task.entity.ts index fbcf4ff..0c51e65 100644 --- a/backend/src/modules/task/entities/task.entity.ts +++ b/backend/src/modules/task/entities/task.entity.ts @@ -102,6 +102,11 @@ export class Task { }) approvalRequestedAt!: Date | null; + // 완료(done) 전이 시각 — 목록의 '완료' 날짜 표기용. + // done 에서 벗어나면(수정 요청 등) null 로 초기화하고, 재승인 시 최신 시각으로 갱신한다. + @Column({ name: 'completed_at', type: 'timestamptz', nullable: true }) + completedAt!: Date | null; + // 최근 수정 요청 메시지(지시자 → 담당자). 수정 요청(changes) 상태에서 노출 @Column({ name: 'changes_note', type: 'text', nullable: true }) changesNote!: string | null; diff --git a/backend/src/modules/task/task.service.ts b/backend/src/modules/task/task.service.ts index dad80df..0b7df97 100644 --- a/backend/src/modules/task/task.service.ts +++ b/backend/src/modules/task/task.service.ts @@ -59,6 +59,8 @@ export interface ProjectTaskResponse { title: string; status: TaskStatus; dueDate: string | null; + // 완료 시각(done 이 아니거나 기록이 없으면 null) + completedAt: string | null; checklist: [number, number]; // [완료, 전체] commentCount: number; fileCount: number; // 첨부 파일 수 @@ -146,6 +148,8 @@ export interface MyTaskRowResponse { title: string; status: TaskStatus; dueDate: string | null; + // 완료 시각(done 이 아니거나 기록이 없으면 null) + completedAt: string | null; checklist: [number, number]; // [완료, 전체] assignees: PublicUser[]; issuer: PublicUser | null; @@ -1252,6 +1256,7 @@ export class TaskService { title: task.title, status: task.status, dueDate: task.dueDate ? task.dueDate.toISOString() : null, + completedAt: task.completedAt ? task.completedAt.toISOString() : null, checklist: [done, checklist.length], assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)), issuer: task.issuer ? UserService.toPublic(task.issuer) : null, @@ -1303,6 +1308,13 @@ export class TaskService { if (next === 'done' || next === 'changes') { task.reviewedBy = membership.user; } + // 완료 시각 기록 — 완료로 들어가면 지금 시각, 완료에서 벗어나면(수정 요청 등) 초기화. + // 마감일(dueDate)과 별개의 값이므로 목록의 '완료' 날짜는 이 값을 쓴다. + if (next === 'done') { + task.completedAt = new Date(); + } else if (from === 'done') { + task.completedAt = null; + } await this.taskRepo.save(task); // 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속) @@ -1544,6 +1556,7 @@ export class TaskService { title: task.title, status: task.status, dueDate: task.dueDate ? task.dueDate.toISOString() : null, + completedAt: task.completedAt ? task.completedAt.toISOString() : null, checklist: [done, checklist.length], commentCount, fileCount, diff --git a/frontend/src/shared/utils/format.ts b/frontend/src/shared/utils/format.ts index 6f8ab46..31c9e4a 100644 --- a/frontend/src/shared/utils/format.ts +++ b/frontend/src/shared/utils/format.ts @@ -183,15 +183,33 @@ export interface TaskDueInfo { ddayLevel: DdayLevel } -/** dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 */ -export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInfo { - if (!dueDate) { - return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' } +/** + * dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 + * + * 완료 업무의 목록 라벨은 마감일이 아니라 실제 완료 시각(completedAt)을 쓴다. + * 완료 기록이 없는 과거 업무(백필 불가 건)는 날짜 없이 '완료'만 표시한다. + */ +export function taskDueInfo( + dueDate: string | null, + isDone: boolean, + completedAt: string | null = null, +): TaskDueInfo { + // 완료 업무의 목록 라벨 — 마감일 유무와 무관하게 완료 시각으로 결정 + const doneListLabel = (): string => { + const label = completedAt ? monthDayKo(completedAt) : '' + return label ? `${label} 완료` : '완료' } + + const empty: TaskDueInfo = { + dateLabel: '', + dday: '', + listLabel: isDone ? doneListLabel() : '', + overdue: false, + ddayLevel: '', + } + if (!dueDate) return empty const due = new Date(dueDate) - if (Number.isNaN(due.getTime())) { - return { dateLabel: '', dday: '', listLabel: '', overdue: false, ddayLevel: '' } - } + if (Number.isNaN(due.getTime())) return empty const dateLabel = `${due.getMonth() + 1}월 ${due.getDate()}일 (${WEEKDAYS_KO[due.getDay()]})` // 자정 기준 일 수 차이 @@ -204,7 +222,7 @@ export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInf return { dateLabel, dday: '', - listLabel: `${monthDayKo(due)} 완료`, + listLabel: doneListLabel(), overdue: false, ddayLevel: '', } diff --git a/frontend/src/stores/my-task.store.ts b/frontend/src/stores/my-task.store.ts index 6a415ec..d0088d1 100644 --- a/frontend/src/stores/my-task.store.ts +++ b/frontend/src/stores/my-task.store.ts @@ -42,7 +42,7 @@ const ISSUED_LABEL: Record = { // API 행 → 담당 뷰 행 (누가 지시했는지 표시) function toAssignedRow(api: ApiMyTaskRow): MyTaskRow { - const due = taskDueInfo(api.dueDate, api.status === 'done') + const due = taskDueInfo(api.dueDate, api.status === 'done', api.completedAt) const [, total] = api.checklist const isDone = api.status === 'done' return { @@ -64,7 +64,7 @@ function toAssignedRow(api: ApiMyTaskRow): MyTaskRow { // API 행 → 지시 뷰 행 (담당자 미니 아바타 + 승인 대기 강조) function toIssuedRow(api: ApiMyTaskRow): MyTaskRow { - const due = taskDueInfo(api.dueDate, api.status === 'done') + const due = taskDueInfo(api.dueDate, api.status === 'done', api.completedAt) const [, total] = api.checklist const isDone = api.status === 'done' const isReview = api.status === 'review' diff --git a/frontend/src/stores/task.store.ts b/frontend/src/stores/task.store.ts index dcf527a..afa0b32 100644 --- a/frontend/src/stores/task.store.ts +++ b/frontend/src/stores/task.store.ts @@ -56,7 +56,7 @@ const UNKNOWN_USER: UserView = { // API 업무 목록 행 → 화면용 ProjectTask function toProjectTaskView(api: ApiProjectTask): ProjectTaskView { - const due = taskDueInfo(api.dueDate, api.status === 'done') + const due = taskDueInfo(api.dueDate, api.status === 'done', api.completedAt) const [, total] = api.checklist return { id: api.id, diff --git a/frontend/src/types/my-task.ts b/frontend/src/types/my-task.ts index 3928027..8f2d057 100644 --- a/frontend/src/types/my-task.ts +++ b/frontend/src/types/my-task.ts @@ -21,6 +21,8 @@ export interface ApiMyTaskRow { title: string status: TaskStatus dueDate: string | null + // 완료 시각(done 이 아니거나 기록이 없으면 null) + completedAt: string | null checklist: [number, number] // [완료, 전체] assignees: ApiMember[] issuer: ApiMember | null diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index e43c0d6..774b892 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -18,6 +18,8 @@ export interface ApiProjectTask { title: string status: TaskStatus dueDate: string | null + // 완료 시각(done 이 아니거나 기록이 없으면 null) + completedAt: string | null checklist: [number, number] // [완료, 전체] commentCount: number fileCount: number // 첨부 파일 수