fix: 업무 완료일을 마감일이 아닌 실제 완료 시각으로 표시
목록의 "N월 N일 완료" 라벨이 완료 시각이 아니라 마감일(dueDate)을 쓰고 있어, 마감일을 지시 당일로 잡은 업무는 지시 날짜가 완료일처럼 보였다. 완료 시각을 저장하는 컬럼 자체가 없던 것이 원인이다. - tasks.completed_at 추가 — done 진입 시 기록, 이탈 시 null(재승인 시 갱신) - 목록/내 업무 응답에 completedAt 노출, taskDueInfo 가 이 값으로 라벨 생성 - 기존 완료 업무는 활동 로그(task.status_changed)의 시각으로 백필 - 백필 불가 건과 마감일 없는 완료 업무는 날짜 없이 "완료"만 표시 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "completed_at"`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: '',
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ const ISSUED_LABEL: Record<TaskStatus, string> = {
|
||||
|
||||
// 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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 // 첨부 파일 수
|
||||
|
||||
Reference in New Issue
Block a user