Compare commits

..

27 Commits

Author SHA1 Message Date
ttipo 2b534743d6 feat: 중간 점검 보고 시 활동 피드 기록 추가
보고 제출 시 알림만 보내고 활동(activity)에는 적재되지 않아
프로젝트 활동 피드·업무 활동 탭에 표시되지 않던 누락을 보완.

- ActivityType/ApiActivityType 에 task.checkpoint_reported 추가
- submitCheckpointReport 에서 activity.record 호출
- 프론트 활동 렌더링 케이스(프로젝트 피드/업무 탭) 추가

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:49:37 +09:00
ttipo af8b363f23 feat: 업무 상세 댓글·활동 빈 상태 UI 추가
댓글/활동 패널에 컨텐츠가 없을 때 아이콘 + 안내 문구의 빈 상태(thread-empty) 표시.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:39:32 +09:00
ttipo 4efe5a7a9a style: 상세 화면 할 일 탭/항목 UI를 작성 화면과 동일하게 통일
상세 체크리스트 카테고리 탭을 작성 화면과 동일한 테두리 pill + 배지형 카운트로 변경(플랫 탭 제거),
항목 행 hover/텍스트 flex 도 작성 화면과 맞춤. 미사용 ck-tabs 클래스 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:36:02 +09:00
ttipo dd7d75310e feat: 보고 완료 배지 옆 보고서 아이콘으로 모달 열기
보고 완료 상태에서 배지 클릭으로만 열리던 것을 명확히 — 배지 오른쪽에 보고서 아이콘 버튼 추가, 아이콘 클릭 시 보고 모달 오픈(열람 권한자만). 배지는 상태 표시 전용으로 변경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:13 +09:00
ttipo 32c04db9fe fix: 지시자 보고 모달 보고자 메모 상하 여백 정리
pr-memo margin을 위 0.375rem·아래 0(비대칭)에서 상하 0.875rem 균등으로, 본문 하단 패딩 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:26:41 +09:00
ttipo b8bb046733 fix: 보고 모달에서 빈 카테고리 탭에 안내 텍스트 표시
전체가 비었을 때만 안내하던 것을 현재 탭 기준으로 변경 — 특정 카테고리에 항목이 없으면 ''X' 분류에 항목이 없습니다' 안내.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:24:00 +09:00
ttipo ea83895938 chore: 디자인 시안 단독 HTML 을 gitignore 에 추가
참고용 대용량 번들 HTML(받은 진행 보고/진행 상태 보고서 단독)을 커밋 대상에서 제외.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:19:49 +09:00
ttipo 1f7d7dcebb feat: 진행 보고 작성/열람 모달 UI 개편(시안 반영)
시안(진행 상태 보고서/받은 진행 보고) 기준으로 두 모달 전면 개편:
- 전체 탭 + 카테고리 탭(색상 dot·건수), 전체 탭에서 카테고리별 그룹 헤더로 묶음
- 작성(작업자): 항목별 상태색 드롭다운(StatusSelect, body Teleport), 보고 메모, 하단 상태 요약(대기/진행/완료 N)
- 열람(지시자·담당자): '진행 보고 도착' 태그 + 보고자 아바타/이름/점검일·시각, 읽기전용 상태 배지, 보고자 메모 박스
- 신규 StatusSelect 컴포넌트 추가, 기존 cp-modal/ws-* 스타일 정리, 미사용 DropdownSelect import 제거

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:19:08 +09:00
ttipo 1df0f2e4e2 feat: 업무 수정/편집을 지시자 전용으로 + 더보기 메뉴를 삭제만 남김
- 상세 우측 상단 액션(수정 버튼 + 더보기 메뉴) 전체를 isIssuer 일 때만 노출
- 더보기 메뉴에서 업무 복제·링크 복사 제거, 업무 삭제만 유지
- 편집 화면(/edit) 진입을 지시자 본인만 허용(그 외 상세로 리다이렉트). 생성 모드는 기존대로 관리자만

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:50:21 +09:00
ttipo 69a25d46ec docs: 중간 점검일 안내 문구 복원('지나면 누락')
직전 커밋에 휩쓸려 빠진 안내 문구 복원.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:44:29 +09:00
ttipo a8d068a76c refactor: 중간 점검을 상단 상태/담당/마감 카드에 통합 + 상태 항목 제거
별도 중간 점검 카드 제거 → 상단 카드의 마감기한 아래로 이동.
해당 카드에서 '상태' 항목 제거(상태는 상단 헤더 배지로 확인).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:43:05 +09:00
ttipo ce50c3c4f5 style: 보고서 모달 버튼을 공용 .btn 크기로 통일
보고서 작성 모달의 취소/보내기 버튼을 커스텀 소형 버튼 → 전역 .btn / .btn primary(height 2.25rem)로 교체해 다른 화면과 크기 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:36:09 +09:00
ttipo 2fd19e0bec style: 진행 상태 라벨 '미착수'를 '대기'로 변경
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:33:31 +09:00
ttipo 1a6321ed49 style: 보고 모달 항목 영역 최소 높이 확대 + 빈 상태 텍스트 중앙 정렬
cp-modal-items min-height 9rem→14rem, cp-modal-empty 를 margin:auto 로 상하/좌우 중앙 배치.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:31:42 +09:00
ttipo bc99911b5f style: 보고 모달 항목 영역 최소 높이 지정 + 상하 패딩 통일
cp-modal-items: min-height 9rem(빈 상태/소수 항목에서도 답답하지 않게), 상하 패딩 1rem 으로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:29:54 +09:00
ttipo 69708b2b8b fix: 드롭다운 메뉴를 body Teleport(fixed)로 띄워 잘림 해결 + 보고 모달 높이 확대
DropdownSelect 메뉴를 absolute → body Teleport + fixed 위치 계산으로 변경.
스크롤/overflow 컨테이너(보고 모달 등) 안에서도 메뉴가 잘리지 않음(스크롤·리사이즈 시 닫힘).
보고 열람/작성 모달 max-height 를 calc(100vh - 3rem) 로 확대.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:26:57 +09:00
ttipo b00785d056 fix: 보고서 작성 상태 드롭다운 화살표를 오른쪽 끝으로 정렬
min-width 로 생긴 여백이 chevron 뒤에 남아 화살표가 가운데로 보이던 문제 수정 — 라벨 flex:1 로 화살표를 우측 끝에 고정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:22:03 +09:00
ttipo 4a31331ae1 feat: 보고서 작성 항목 상태를 내용 앞 드롭다운으로 변경
세그먼트 버튼 → 할 일 내용 앞쪽의 DropdownSelect(미착수/진행중/완료)로 선택하도록 변경.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:19:49 +09:00
ttipo 8a52ad0d0a feat: 진행 보고서 작성을 모달 + 카테고리 탭 방식으로 변경
인라인 보고서 폼 제거 → '보고서 작성' 클릭 시 모달로 띄움.
모달 내 항목은 필수/관리/보안/부가 탭으로 구분해 항목별 상태를 선택하고 메모와 함께 전송.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:15:55 +09:00
ttipo 6577550937 feat: 중간 점검 보고 내용은 지시자·담당자만 열람(상태는 전원 노출)
보고 '내용' 열람을 지시자 + 해당 업무 담당자로 제한:
- 백엔드 buildDetail: viewerId 기준으로 권한 없으면 checkpointReports 를 빈 배열로 응답(findOne 에 현재 사용자 전달). 점검일에는 reported 플래그를 두어 상태는 전원 공유
- 프론트: checkpointState 는 reported 플래그 기준, '보고 완료' 클릭(모달)은 지시자·담당자만. 그 외는 비클릭 상태 배지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 16:01:50 +09:00
ttipo b717ecd9a5 fix: 보고된 중간 점검일 삭제 방지(보고 이력 보존)
수정 시 보고가 접수된 점검일을 지우면 CASCADE 로 보고서까지 삭제되던 문제 방지:
- 백엔드 syncCheckpoints: 보고가 있는 점검일은 삭제 대상에서 제외
- 프론트 수정 폼: 보고된 점검일은 '보고됨' 잠금 배지로 표시하고 삭제 버튼 비노출

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:49:51 +09:00
ttipo 814dd7bb8b feat: 업무 상세 해야 할 일을 카테고리 탭 구조로 변경
그룹 나열 → 필수/관리/보안/부가 탭으로 전환(생성 화면·보고 모달과 동일한 탭 UI 재사용).
검토 모달은 기존 그룹 구조 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:39:54 +09:00
ttipo 98a2308c4e feat: 중간 점검 보고를 모달 + 카테고리 탭으로 열람
하단 토글 목록 제거 → 상단 '보고 완료' 체크포인트 클릭 시 모달로 보고 내용 표시.
모달은 업무 생성의 카테고리 탭(필수/관리/보안/부가) 방식으로 항목을 구분해 보여준다.
보고 스냅샷에 category 추가(백엔드 엔티티/서비스 + 프론트 타입/뷰/스토어).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:33:19 +09:00
ttipo 31704cc95b style: 상세 체크리스트 진행상태 배지 제거 + 중간 점검 목록/배지/버튼 크기 조정
- 해야 할 일 항목의 진행상태 배지 제거(진행상태는 중간 점검 보고/기록에서만 확인)
- 중간 점검 점검일 목록 간격 확대, 상태 배지·보고서 작성 버튼 크기 소폭 확대

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:25:39 +09:00
ttipo 4ef07f65a7 refactor: 중간 점검을 점검일 당일 보고서 방식으로 변경
진행 상태(미착수/진행중/완료)를 상시 토글 대신 '보고서'로 전환:
- 보고는 점검일 당일(KST)에만 가능, 지나면 누락 — 마감 전 상시 전송 제거
- 작업자가 점검일 당일 보고서 폼에서 항목별 상태 + 메모를 작성해 전송(상태는 보고에 포함)
- 지시자는 상세 화면에서 보고 기록을 접힌 목록으로 보고 클릭해 펼쳐 열람
- 체크리스트 항목은 마지막 보고 상태를 읽기 전용 배지로 표시(인라인 편집 제거)
- 상시 진행상태 갱신 엔드포인트/DTO 제거(work-status)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 15:18:58 +09:00
ttipo 25ca63a925 feat: 중간 점검(체크포인트) 프론트 — 진행상태 토글·점검일 입력·보고
작성/수정 화면: 중간 점검일 여러 개 입력(추가/삭제, 편집 모드 로드).
상세 화면: 체크리스트 항목별 작업자 진행상태(미착수/진행중/완료) 토글(담당자) + 비담당자 배지,
중간 점검 카드(점검일 목록, 점검일~마감 구간 '보고 보내기', 보고 메모, 보고 이력 스냅샷).
타입/컴포저블/스토어/뷰모델 + 알림(task.checkpoint_reported) 렌더 연동.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:57:52 +09:00
ttipo 20664dab0f feat: 중간 점검(체크포인트) 백엔드 — 진행상태/점검일/보고
체크리스트에 작업자 진행상태(work_status: 미착수/진행중/완료) 필드 추가(지시자 검토와 분리).
업무당 중간 점검일(task_checkpoints) 여러 개 + 작업자 보고 스냅샷(checkpoint_reports) 도입.
- 생성/수정 DTO에 checkpoints(날짜 목록) 추가, 날짜 기준 동기화(보고 보존)
- PATCH .../work-status(담당자 진행상태 갱신), POST .../checkpoints/:id/report(점검일~마감 구간, 스냅샷+지시자 알림 task.checkpoint_reported)
- 상세 응답에 checkpoints/checkpointReports/항목 workStatus 포함
- 운영용 마이그레이션 추가(dev 는 synchronize 자동 반영)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:46:07 +09:00
26 changed files with 2222 additions and 157 deletions
+3
View File
@@ -50,3 +50,6 @@ Thumbs.db
.cache/
.eslintcache
.npm/
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
frontend/*단독*.html
@@ -0,0 +1,58 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 중간 점검(체크포인트) 기능 — 체크리스트 작업자 진행상태 + 점검일/보고 테이블 추가.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
export class AddTaskCheckpoints1782200000000 implements MigrationInterface {
name = 'AddTaskCheckpoints1782200000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 체크리스트 작업자 진행상태(자가보고) — 미착수/진행중/완료
await queryRunner.query(
`ALTER TABLE "checklist_items" ADD "work_status" character varying NOT NULL DEFAULT 'todo'`,
);
// 중간 점검일
await queryRunner.query(
`CREATE TABLE "task_checkpoints" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "check_at" TIMESTAMP WITH TIME ZONE NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, CONSTRAINT "PK_task_checkpoints" PRIMARY KEY ("id"))`,
);
// 중간 점검 보고(스냅샷)
await queryRunner.query(
`CREATE TABLE "checkpoint_reports" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "note" text, "items_snapshot" jsonb NOT NULL DEFAULT '[]', "created_at" TIMESTAMP NOT NULL DEFAULT now(), "task_id" uuid, "checkpoint_id" uuid, "reporter_id" uuid, CONSTRAINT "PK_checkpoint_reports" PRIMARY KEY ("id"))`,
);
// FK
await queryRunner.query(
`ALTER TABLE "task_checkpoints" ADD CONSTRAINT "FK_task_checkpoints_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_checkpoint" FOREIGN KEY ("checkpoint_id") REFERENCES "task_checkpoints"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" ADD CONSTRAINT "FK_checkpoint_reports_reporter" FOREIGN KEY ("reporter_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_reporter"`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_checkpoint"`,
);
await queryRunner.query(
`ALTER TABLE "checkpoint_reports" DROP CONSTRAINT "FK_checkpoint_reports_task"`,
);
await queryRunner.query(
`ALTER TABLE "task_checkpoints" DROP CONSTRAINT "FK_task_checkpoints_task"`,
);
await queryRunner.query(`DROP TABLE "checkpoint_reports"`);
await queryRunner.query(`DROP TABLE "task_checkpoints"`);
await queryRunner.query(
`ALTER TABLE "checklist_items" DROP COLUMN "work_status"`,
);
}
}
@@ -28,6 +28,7 @@ export type ActivityType =
| 'task.assignees_changed'
| 'task.due_changed'
| 'task.edited'
| 'task.checkpoint_reported'
| 'task.file_removed'
| 'task.removed';
@@ -18,6 +18,7 @@ export type NotificationType =
| 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨
| 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
| 'task.removed' // 내 관련 업무가 삭제됨
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
| 'member.invited' // 프로젝트에 멤버로 초대됨
@@ -0,0 +1,46 @@
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[];
}
@@ -93,4 +93,20 @@ export class CreateTaskDto {
@ValidateNested({ each: true })
@Type(() => ChecklistInputDto)
checklist?: ChecklistInputDto[];
@ApiProperty({
description:
'중간 점검일 목록(ISO8601, 선택) — 작업자가 진행 상태를 보고하는 기준일',
type: [String],
required: false,
example: ['2026-06-15T00:00:00Z'],
})
@IsOptional()
@IsArray()
@ArrayMaxSize(20, { message: '중간 점검일이 너무 많습니다.' })
@IsISO8601(
{ strict: false },
{ each: true, message: '올바른 날짜 형식을 입력해 주세요.' },
)
checkpoints?: string[];
}
@@ -101,4 +101,19 @@ export class UpdateTaskDto {
@ValidateNested({ each: true })
@Type(() => UpdateChecklistItemInput)
checklist?: UpdateChecklistItemInput[];
@ApiProperty({
description:
'중간 점검일 목록(ISO8601). 제공 시 전체 교체, 빈 배열이면 모두 삭제',
type: [String],
required: false,
})
@IsOptional()
@IsArray()
@ArrayMaxSize(20, { message: '중간 점검일이 너무 많습니다.' })
@IsISO8601(
{ strict: false },
{ each: true, message: '올바른 날짜 형식을 입력해 주세요.' },
)
checkpoints?: string[];
}
@@ -12,6 +12,11 @@ import { Task } from './task.entity';
export const CHECKLIST_CATEGORIES = ['필수', '관리', '보안', '부가'] as const;
export type ChecklistCategory = (typeof CHECKLIST_CATEGORIES)[number];
// 작업자 진행 상태(자가보고) — 지시자의 검토(done/reviewNote)와는 별개.
// 작업자가 항목별로 직접 설정하며, 중간 점검 보고 시 스냅샷으로 기록된다.
export const CHECKLIST_WORK_STATUSES = ['todo', 'doing', 'done'] as const;
export type ChecklistWorkStatus = (typeof CHECKLIST_WORK_STATUSES)[number];
// 업무 체크리스트 항목
@Entity('checklist_items')
export class ChecklistItem {
@@ -31,10 +36,14 @@ export class ChecklistItem {
@Column({ type: 'varchar', default: '필수' })
category!: ChecklistCategory;
// 완료 여부
// 완료 여부(지시자 검토 = 승인). 보완거절은 done:false + reviewNote 로 표현
@Column({ default: false })
done!: boolean;
// 작업자 진행 상태(자가보고) — 미착수/진행중/완료. 검토(done)와 독립
@Column({ name: 'work_status', type: 'varchar', default: 'todo' })
workStatus!: ChecklistWorkStatus;
// 보완 내용(수정 요청 시 항목별로 입력) — 완료 처리 시 비워짐
@Column({ name: 'review_note', type: 'text', nullable: true })
reviewNote!: string | null;
@@ -0,0 +1,57 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { Task } from './task.entity';
import { TaskCheckpoint } from './task-checkpoint.entity';
import type {
ChecklistCategory,
ChecklistWorkStatus,
} from './checklist-item.entity';
// 보고 스냅샷의 항목 단위(보고 시점의 항목별 진행 상태를 동결 보관)
export interface ReportItemSnapshot {
itemId: string;
text: string;
category: ChecklistCategory;
workStatus: ChecklistWorkStatus;
}
// 중간 점검 보고 — 작업자가 점검일에 현재 진행 상태를 지시자에게 보낸 기록(스냅샷).
@Entity('checkpoint_reports')
export class CheckpointReport {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'task_id' })
task!: Relation<Task>;
// 대상 체크포인트 (점검일 삭제 시 보고도 함께 삭제)
@ManyToOne(() => TaskCheckpoint, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'checkpoint_id' })
checkpoint!: Relation<TaskCheckpoint>;
// 보고한 작업자 (사용자 삭제 시에도 보고는 유지 — SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'reporter_id' })
reporter!: User | null;
// 보고 메모(선택)
@Column({ type: 'text', nullable: true })
note!: string | null;
// 보고 시점의 항목별 진행 상태 스냅샷
@Column({ name: 'items_snapshot', type: 'jsonb', default: () => "'[]'" })
itemsSnapshot!: ReportItemSnapshot[];
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,34 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
} from 'typeorm';
import { Task } from './task.entity';
// 중간 점검일 — 지시자가 업무 배정 시 지정. 한 업무에 여러 개 가능.
// 작업자는 점검일~마감 구간에 진행 상태를 보고할 수 있다.
@Entity('task_checkpoints')
export class TaskCheckpoint {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 업무 (업무 삭제 시 함께 삭제)
@ManyToOne(() => Task, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'task_id' })
task!: Relation<Task>;
// 점검 예정일
@Column({ name: 'check_at', type: 'timestamptz' })
checkAt!: Date;
// 표시 순서(입력 순)
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
+27 -1
View File
@@ -38,6 +38,7 @@ import {
} from './task.service';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { CheckpointReportDto } from './dto/checkpoint-report.dto';
import { ChangeStatusDto } from './dto/change-status.dto';
import { CreateCommentDto, CreateReplyDto } from './dto/create-comment.dto';
import { ApprovalChangesDto, ApprovalRequestDto } from './dto/approval.dto';
@@ -106,8 +107,9 @@ export class TaskController {
findOne(
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.findOne(projectId, taskId);
return this.taskService.findOne(projectId, taskId, user.id);
}
@Patch(':taskId')
@@ -160,6 +162,30 @@ export class TaskController {
);
}
/* ===================== 중간 점검(체크포인트) ===================== */
@Post(':taskId/checkpoints/:checkpointId/report')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '중간 점검 보고 (담당자) — 점검일 당일에만' })
@ApiResponse({ status: 201, description: '보고 접수' })
@ApiResponse({ status: 403, description: '담당자 아님 (AUTH_003)' })
@ApiResponse({ status: 404, description: '점검일을 찾을 수 없음 (RES_001)' })
submitCheckpointReport(
@Param('projectId') projectId: string,
@Param('taskId', ParseIntPipe) taskId: number,
@Param('checkpointId', ParseUUIDPipe) checkpointId: string,
@Body() dto: CheckpointReportDto,
@CurrentUser() user: PublicUser,
): Promise<TaskDetailResponse> {
return this.taskService.submitCheckpointReport(
projectId,
user.id,
taskId,
checkpointId,
dto,
);
}
/* ===================== 댓글 / 답글 (5단계) ===================== */
@Post(':taskId/comments')
+4
View File
@@ -4,6 +4,8 @@ import { Project } from '../project/entities/project.entity';
import { ProjectMember } from '../project/entities/project-member.entity';
import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
import { CheckpointReport } from './entities/checkpoint-report.entity';
import { Comment } from './entities/comment.entity';
import { Attachment } from './entities/attachment.entity';
import { ActivityModule } from '../activity/activity.module';
@@ -18,6 +20,8 @@ import { MyTaskController } from './my-task.controller';
TypeOrmModule.forFeature([
Task,
ChecklistItem,
TaskCheckpoint,
CheckpointReport,
Comment,
Attachment,
Project,
@@ -8,6 +8,8 @@ import { Task } from './entities/task.entity';
import { ChecklistItem } from './entities/checklist-item.entity';
import { Comment } from './entities/comment.entity';
import { Attachment } from './entities/attachment.entity';
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
import { CheckpointReport } from './entities/checkpoint-report.entity';
import { ActivityService } from '../activity/activity.service';
import { NotificationService } from '../notification/notification.service';
@@ -26,6 +28,19 @@ function makeService() {
// 상세 조립(buildDetail)에서 댓글/첨부는 빈 목록으로 모킹
const commentRepo = { find: jest.fn().mockResolvedValue([]) };
const attachmentRepo = { find: jest.fn().mockResolvedValue([]) };
// 중간 점검(체크포인트)·보고 — buildDetail 에서 빈 목록으로 모킹
const checkpointRepo = {
find: jest.fn().mockResolvedValue([]),
findOne: jest.fn(),
save: jest.fn(),
remove: jest.fn(),
create: jest.fn(),
};
const reportRepo = {
find: jest.fn().mockResolvedValue([]),
save: jest.fn(),
create: jest.fn(),
};
// 활동 서비스 — 적재는 no-op, 업무 활동 조회는 빈 목록
const activity = {
record: jest.fn().mockResolvedValue(undefined),
@@ -40,6 +55,8 @@ function makeService() {
memberRepo as unknown as Repository<ProjectMember>,
commentRepo as unknown as Repository<Comment>,
attachmentRepo as unknown as Repository<Attachment>,
checkpointRepo as unknown as Repository<TaskCheckpoint>,
reportRepo as unknown as Repository<CheckpointReport>,
activity as unknown as ActivityService,
notification as unknown as NotificationService,
);
+254 -8
View File
@@ -14,11 +14,18 @@ import { Task, type TaskStatus } from './entities/task.entity';
import {
ChecklistItem,
type ChecklistCategory,
type ChecklistWorkStatus,
} from './entities/checklist-item.entity';
import { TaskCheckpoint } from './entities/task-checkpoint.entity';
import {
CheckpointReport,
type ReportItemSnapshot,
} from './entities/checkpoint-report.entity';
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 { CheckpointReportDto } from './dto/checkpoint-report.dto';
import {
ActivityService,
type ActivityResponse,
@@ -58,6 +65,27 @@ export interface ChecklistItemResponse {
category: ChecklistCategory;
// 보완 내용(수정 요청 시 항목별 입력) — 없으면 null
reviewNote: string | null;
// 작업자 진행 상태(자가보고) — 미착수/진행중/완료
workStatus: ChecklistWorkStatus;
}
// 중간 점검일 응답
export interface CheckpointResponse {
id: string;
checkAt: string;
// 보고 접수 여부(상태 표시용) — 내용 열람 권한과 무관하게 전원에게 노출
reported: boolean;
}
// 중간 점검 보고 응답(스냅샷 포함)
export interface CheckpointReportResponse {
id: string;
checkpointId: string;
checkAt: string | null;
reporter: PublicUser | null;
note: string | null;
items: ReportItemSnapshot[];
createdAt: string;
}
// 프로젝트 업무 목록의 세그먼트별 카운트(전체/진행/대기/완료) — 검색어 반영
@@ -140,6 +168,9 @@ export interface TaskDetailResponse {
files: AttachmentResponse[];
approval: ApprovalInfo | null;
changesNote: string | null;
// 중간 점검일 + 작업자 보고 이력
checkpoints: CheckpointResponse[];
checkpointReports: CheckpointReportResponse[];
}
// 상태 머신 전이 규칙(허용 목적지)
@@ -176,6 +207,10 @@ export class TaskService {
private readonly commentRepo: Repository<Comment>,
@InjectRepository(Attachment)
private readonly attachmentRepo: Repository<Attachment>,
@InjectRepository(TaskCheckpoint)
private readonly checkpointRepo: Repository<TaskCheckpoint>,
@InjectRepository(CheckpointReport)
private readonly reportRepo: Repository<CheckpointReport>,
private readonly activity: ActivityService,
private readonly notification: NotificationService,
) {}
@@ -310,10 +345,14 @@ export class TaskService {
}
// 업무 단건 상세 — project 내 순번(seq)으로 조회
async findOne(projectId: string, seq: number): Promise<TaskDetailResponse> {
async findOne(
projectId: string,
seq: number,
viewerId: string,
): Promise<TaskDetailResponse> {
const project = await this.getProjectOrThrow(projectId);
const task = await this.getTaskOrThrow(project.id, seq);
return this.buildDetail(task, project);
return this.buildDetail(task, project, viewerId);
}
// 업무 생성 — admin(지시자) 권한. 담당자는 프로젝트 멤버만 지정 가능
@@ -357,6 +396,9 @@ export class TaskService {
});
const saved = await this.taskRepo.save(task);
// 중간 점검일 생성(있으면)
await this.syncCheckpoints(saved, dto.checkpoints);
// 활동 적재 — 업무 생성
await this.activity.record({
projectId: project.id,
@@ -377,6 +419,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, saved.seq),
project,
actingUserId,
);
}
@@ -417,6 +460,11 @@ export class TaskService {
checklistChanged = await this.syncChecklist(task, dto.checklist);
}
// 중간 점검일 동기화(제공된 경우에만) — 날짜 기준 추가/삭제(미변경 항목·보고 유지)
if (dto.checkpoints !== undefined) {
await this.syncCheckpoints(task, dto.checkpoints);
}
// 변경 항목별 활동 적재(담당자/마감/일반 수정)
await this.recordUpdateActivities(project, actingUserId, task, {
dto,
@@ -469,6 +517,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -647,6 +696,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -879,6 +929,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -896,6 +947,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -937,6 +989,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -1292,16 +1345,176 @@ export class TaskService {
};
}
// 업무 상세 + 부속(댓글/첨부/승인) 조립
// --- 중간 점검(체크포인트) ---
// 날짜를 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,
seq: number,
checkpointId: string,
dto: CheckpointReportDto,
): Promise<TaskDetailResponse> {
const project = await this.getProjectOrThrow(projectId);
const task = await this.getTaskOrThrow(project.id, seq);
this.assertAssignee(task, actingUserId);
const checkpoint = await this.checkpointRepo.findOne({
where: { id: checkpointId, task: { id: task.id } },
});
if (!checkpoint) {
throw new NotFoundException();
}
// 보고 가능 시점: 점검일 당일에만(지나면 누락 — 보고 불가)
if (this.kstDate(checkpoint.checkAt) !== this.kstDate(new Date())) {
throw new BusinessException(
'BIZ_001',
'중간 점검 보고는 점검일 당일에만 보낼 수 있습니다.',
HttpStatus.BAD_REQUEST,
);
}
// 보고서로 받은 상태를 항목에 반영 + 체크리스트 순서대로 스냅샷 구성
const reportedById = new Map(dto.items.map((it) => [it.id, it.workStatus]));
const ordered = (task.checklist ?? [])
.slice()
.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,
category: c.category,
workStatus: ws,
};
});
if (changed.length) {
await this.checklistRepo.save(changed);
}
const reporter =
(task.assignees ?? []).find((a) => a.id === actingUserId) ?? null;
const note = dto.note?.trim() ? dto.note.trim() : null;
await this.reportRepo.save(
this.reportRepo.create({
task: { id: task.id } as Task,
checkpoint: { id: checkpoint.id } as TaskCheckpoint,
reporter,
note,
itemsSnapshot: snapshot,
}),
);
// 활동 적재 — 중간 점검 보고
await this.activity.record({
projectId: project.id,
actorId: actingUserId,
type: 'task.checkpoint_reported',
payload: { taskTitle: task.title },
taskSeq: task.seq,
});
// 지시자에게 알림(행위자=작업자는 자동 제외)
await this.notification.notify({
recipientIds: task.issuer ? [task.issuer.id] : [],
actorId: actingUserId,
type: 'task.checkpoint_reported',
payload: {
...this.taskNotifyPayload(project, task, reporter?.name ?? '담당자'),
checkpointAt: checkpoint.checkAt.toISOString(),
},
});
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
// 중간 점검일 동기화 — 날짜(시각) 기준 추가/삭제. 미변경 날짜는 그대로 두어 보고를 보존.
private async syncCheckpoints(task: Task, dates?: string[]): Promise<void> {
if (dates === undefined) return;
const existing = await this.checkpointRepo.find({
where: { task: { id: task.id } },
});
const existingTimes = existing.map((c) => c.checkAt.getTime());
const wantedTimes = [...new Set(dates.map((d) => new Date(d).getTime()))];
// 보고가 있는 점검일은 삭제하지 않는다 — 보고 이력(스냅샷) 보존
// (점검일 삭제 시 checkpoint_reports 가 CASCADE 로 함께 사라지므로 명시적으로 보호)
const reports = await this.reportRepo.find({
where: { task: { id: task.id } },
relations: ['checkpoint'],
});
const reportedIds = new Set(
reports.map((r) => r.checkpoint?.id).filter((id): id is string => !!id),
);
const toRemove = existing.filter(
(c) =>
!wantedTimes.includes(c.checkAt.getTime()) && !reportedIds.has(c.id),
);
if (toRemove.length) {
await this.checkpointRepo.remove(toRemove);
}
const toAdd = wantedTimes
.filter((t) => !existingTimes.includes(t))
.map((t, i) =>
this.checkpointRepo.create({
task: { id: task.id } as Task,
checkAt: new Date(t),
sortOrder: i,
}),
);
if (toAdd.length) {
await this.checkpointRepo.save(toAdd);
}
}
// 담당자(작업자) 확인 — 아니면 403
private assertAssignee(task: Task, userId: string): void {
const isAssignee = (task.assignees ?? []).some((a) => a.id === userId);
if (!isAssignee) {
throw new ForbiddenException();
}
}
private async buildDetail(
task: Task,
project: Project,
viewerId: string,
): Promise<TaskDetailResponse> {
const [comments, files, activities] = await Promise.all([
this.loadComments(task.id, project, task.seq),
this.loadFiles(task.id, project, task.seq),
this.activity.listForTask(project.id, task.seq),
]);
const [comments, files, activities, checkpointRows, reportRows] =
await Promise.all([
this.loadComments(task.id, project, task.seq),
this.loadFiles(task.id, project, task.seq),
this.activity.listForTask(project.id, task.seq),
this.checkpointRepo.find({
where: { task: { id: task.id } },
order: { checkAt: 'ASC' },
}),
this.reportRepo.find({
where: { task: { id: task.id } },
order: { createdAt: 'DESC' },
relations: ['reporter', 'checkpoint'],
}),
]);
const checklist = (task.checklist ?? [])
.slice()
@@ -1312,8 +1525,39 @@ export class TaskService {
done: c.done,
category: c.category,
reviewNote: c.reviewNote ?? null,
workStatus: c.workStatus,
}));
// 점검일별 보고 접수 여부(상태 배지용) — 전원 노출
const reportedCheckpointIds = new Set(
reportRows
.map((r) => r.checkpoint?.id)
.filter((id): id is string => !!id),
);
const checkpoints: CheckpointResponse[] = checkpointRows.map((c) => ({
id: c.id,
checkAt: c.checkAt.toISOString(),
reported: reportedCheckpointIds.has(c.id),
}));
// 보고 '내용'은 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
const canViewReports =
task.issuer?.id === viewerId ||
(task.assignees ?? []).some((a) => a.id === viewerId);
const checkpointReports: CheckpointReportResponse[] = canViewReports
? reportRows.map((r) => ({
id: r.id,
checkpointId: r.checkpoint?.id ?? '',
checkAt: r.checkpoint?.checkAt
? r.checkpoint.checkAt.toISOString()
: null,
reporter: r.reporter ? UserService.toPublic(r.reporter) : null,
note: r.note,
items: r.itemsSnapshot ?? [],
createdAt: r.createdAt.toISOString(),
}))
: [];
// 승인 정보는 승인 대기(review) 상태이고 요청 메타가 있을 때만 노출
const approval: ApprovalInfo | null =
task.status === 'review' && task.approvalRequestedAt
@@ -1343,6 +1587,8 @@ export class TaskService {
files,
approval,
changesNote: task.status === 'changes' ? task.changesNote : null,
checkpoints,
checkpointReports,
};
}
+71 -44
View File
@@ -1,7 +1,7 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 사용처: 프로젝트 목록·업무 목록 정렬, 내 업무 프로젝트/정렬, 활동 멤버 필터.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
interface Option {
value: T
@@ -25,36 +25,67 @@ const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
// 메뉴 fixed 위치(트리거 기준 계산)
const menuStyle = ref<Record<string, string>>({})
// 현재 선택된 옵션 라벨(트리거에 표시)
const selectedLabel = computed(
() => props.options.find((o) => o.value === props.modelValue)?.label ?? '',
)
// 트리거 위치 기준으로 메뉴 좌표 계산(뷰포트 fixed)
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
const style: Record<string, string> = {
top: `${r.bottom + 6}px`,
minWidth: `${r.width}px`,
}
if (props.align === 'right') {
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
} else {
style.left = `${r.left}px`
}
menuStyle.value = style
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function select(value: T): void {
emit('update:modelValue', value)
open.value = false
}
// 바깥 클릭 / Esc 로 닫기
// 바깥 클릭(트리거/메뉴 외부) / Esc 로 닫기
function onDocClick(e: MouseEvent): void {
if (open.value && root.value && !root.value.contains(e.target as Node)) {
open.value = false
}
if (!open.value) return
const t = e.target as Node
if (root.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onKeydown(e: KeyboardEvent): void {
if (open.value && e.key === 'Escape') open.value = false
}
// 스크롤/리사이즈 시 위치가 어긋나므로 닫는다(간단·안전)
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDocClick)
document.addEventListener('keydown', onKeydown)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
document.removeEventListener('keydown', onKeydown)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
})
</script>
@@ -65,6 +96,7 @@ onBeforeUnmount(() => {
:class="{ push: pushRight, block }"
>
<button
ref="triggerRef"
type="button"
class="dd-trigger"
:class="{ open }"
@@ -90,34 +122,37 @@ onBeforeUnmount(() => {
><path d="m6 9 6 6 6-6" /></svg>
</button>
<ul
v-if="open"
class="dd-menu"
:class="align === 'right' ? 'right' : 'left'"
role="listbox"
>
<li
v-for="opt in options"
:key="opt.value"
class="dd-opt"
:class="{ active: opt.value === modelValue }"
role="option"
:aria-selected="opt.value === modelValue"
@click="select(opt.value)"
<Teleport to="body">
<ul
v-if="open"
ref="menuRef"
class="dd-menu"
:style="menuStyle"
role="listbox"
>
<span class="dd-opt-label">{{ opt.label }}</span>
<svg
v-if="opt.value === modelValue"
class="dd-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</li>
</ul>
<li
v-for="opt in options"
:key="opt.value"
class="dd-opt"
:class="{ active: opt.value === modelValue }"
role="option"
:aria-selected="opt.value === modelValue"
@click="select(opt.value)"
>
<span class="dd-opt-label">{{ opt.label }}</span>
<svg
v-if="opt.value === modelValue"
class="dd-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</li>
</ul>
</Teleport>
</div>
</template>
@@ -191,12 +226,10 @@ onBeforeUnmount(() => {
transform: rotate(180deg);
}
/* 메뉴 */
/* 메뉴 — body 로 Teleport, fixed 위치(좌표는 인라인 style) */
.dd-menu {
position: absolute;
top: calc(100% + 0.375rem);
z-index: 60;
min-width: 100%;
position: fixed;
z-index: 1000;
margin: 0;
padding: 0.25rem;
list-style: none;
@@ -205,12 +238,6 @@ onBeforeUnmount(() => {
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(20, 24, 33, 0.14);
}
.dd-menu.left {
left: 0;
}
.dd-menu.right {
right: 0;
}
.dd-opt {
display: flex;
align-items: center;
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
// 진행 상태(대기/진행 중/완료) 선택 드롭다운 — 상태색 테마. 메뉴는 body Teleport(스크롤 클리핑 방지).
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
const props = defineProps<{ modelValue: ChecklistWorkStatus }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: ChecklistWorkStatus): void }>()
const META: Record<ChecklistWorkStatus, { label: string; color: string; weak: string }> = {
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
}
const ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
const cur = computed(() => META[props.modelValue])
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const menuStyle = ref<Record<string, string>>({})
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
menuStyle.value = {
top: `${r.bottom + 5}px`,
right: `${Math.max(0, window.innerWidth - r.right)}px`,
}
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function pick(v: ChecklistWorkStatus): void {
emit('update:modelValue', v)
open.value = false
}
function onDoc(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (triggerRef.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDoc)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDoc)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
})
</script>
<template>
<div class="ss">
<button
ref="triggerRef"
type="button"
class="ss-btn"
:class="{ open }"
:style="{ '--c': cur.color, '--w': cur.weak }"
@click="toggle"
>
<span class="ss-dot" />
<span class="ss-lbl">{{ cur.label }}</span>
<svg
class="ss-chev"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m6 9 6 6 6-6" /></svg>
</button>
<Teleport to="body">
<div
v-if="open"
ref="menuRef"
class="ss-menu"
:style="menuStyle"
>
<button
v-for="k in ORDER"
:key="k"
type="button"
class="ss-opt"
:class="{ on: k === modelValue }"
@click="pick(k)"
>
<span
class="ss-dot"
:style="{ background: META[k].color }"
/>{{ META[k].label }}
<svg
v-if="k === modelValue"
class="ss-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</button>
</div>
</Teleport>
</div>
</template>
<style scoped>
.ss {
position: relative;
flex-shrink: 0;
}
.ss-btn {
display: inline-flex;
align-items: center;
gap: 7px;
height: 30px;
min-width: 100px;
padding: 0 9px 0 11px;
border: 1px solid var(--border-strong);
border-radius: 8px;
background: var(--w);
color: var(--c);
font-family: inherit;
font-size: 0.781rem;
font-weight: 700;
cursor: pointer;
white-space: nowrap;
}
.ss-btn.open {
border-color: var(--c);
}
.ss-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--c);
flex-shrink: 0;
}
.ss-btn .ss-lbl {
flex: 1;
text-align: left;
}
.ss-chev {
width: 14px;
height: 14px;
}
.ss-menu {
position: fixed;
z-index: 1000;
width: 9rem;
background: #fff;
border: 1px solid var(--border);
border-radius: 9px;
box-shadow: 0 8px 24px rgba(20, 24, 33, 0.16);
padding: 5px;
}
.ss-opt {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
border: none;
background: none;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
padding: 8px;
border-radius: 6px;
cursor: pointer;
text-align: left;
}
.ss-opt:hover {
background: #f4f5f7;
}
.ss-opt.on {
color: var(--text);
}
.ss-opt .ss-dot {
width: 9px;
height: 9px;
}
.ss-opt .ss-check {
margin-left: auto;
color: var(--text);
width: 15px;
height: 15px;
}
</style>
+18
View File
@@ -6,6 +6,7 @@ import type {
ApiProjectTaskListResult,
ApiTaskDetail,
ChecklistReviewItem,
ChecklistWorkStatus,
CreateTaskPayload,
TaskListQuery,
UpdateTaskPayload,
@@ -68,6 +69,22 @@ export function useTask() {
})) as unknown as ApiTaskDetail
}
/* ===================== 중간 점검(체크포인트) ===================== */
// 중간 점검 보고(담당자) — 점검일 당일, 항목별 상태 + 메모를 보고서로 제출
async function submitCheckpointReport(
projectId: string,
taskId: number,
checkpointId: string,
items: { id: string; workStatus: ChecklistWorkStatus }[],
note?: string,
): Promise<ApiTaskDetail> {
return (await api.post(
`/projects/${projectId}/tasks/${taskId}/checkpoints/${checkpointId}/report`,
{ items, ...(note ? { note } : {}) },
)) as unknown as ApiTaskDetail
}
/* ===================== 댓글 / 답글 (5단계) ===================== */
async function addComment(
@@ -156,6 +173,7 @@ export function useTask() {
update,
remove,
changeStatus,
submitCheckpointReport,
addComment,
addReply,
uploadFile,
+35
View File
@@ -36,6 +36,9 @@ export const CHECKLIST_CATEGORIES: ChecklistCategory[] = [
'부가',
]
/** 작업자 진행 상태(자가보고) — 대기/진행중/완료 */
export type ChecklistWorkStatus = 'todo' | 'doing' | 'done'
/** 체크리스트 항목 */
export interface ChecklistItem {
/** 항목 ID(API 연동 시) */
@@ -45,6 +48,32 @@ export interface ChecklistItem {
category: ChecklistCategory
/** 보완 내용(수정 요청 시 항목별 입력) — 없으면 null */
reviewNote?: string | null
/** 작업자 진행 상태(자가보고) */
workStatus?: ChecklistWorkStatus
}
/** 중간 점검일(표시용) */
export interface TaskCheckpointView {
id: string
/** ISO — 점검일 당일 판정용 */
checkAt: string
/** 표시 라벨 */
dateLabel: string
/** 보고 접수 여부(상태 배지용) */
reported: boolean
}
/** 중간 점검 보고(스냅샷, 표시용) */
export interface CheckpointReportView {
id: string
checkpointId: string
reporterName: string
note: string | null
items: { text: string; category: ChecklistCategory; workStatus: ChecklistWorkStatus }[]
/** 점검일 표시 */
dateLabel: string
/** 보고 시각(상대) */
timeLabel: string
}
/** 프로젝트 — 작업의 기본 단위(우선) */
@@ -138,6 +167,8 @@ export interface TaskDetail {
issuer: User
dueLabel: string
dday: string
/** 원본 마감 ISO(중간 점검 보고 가능 구간 계산용) — 없으면 null */
dueAt: string | null
files: Attachment[]
/** 승인 요청 정보(승인 대기 상태일 때) */
approval?: {
@@ -148,6 +179,10 @@ export interface TaskDetail {
/** 수정 요청 메시지(수정 요청 상태일 때) */
changesNote?: string
issuedLabel: string
/** 중간 점검일 목록 */
checkpoints: TaskCheckpointView[]
/** 중간 점검 보고 이력(최신순) */
checkpointReports: CheckpointReportView[]
}
/** D-day 강조 단계 */
+160 -3
View File
@@ -64,9 +64,8 @@ const repo = ref<Project | null>(null)
async function loadProject() {
try {
repo.value = await projectStore.fetchOne(projectId.value)
// 업무 작성/수정은 프로젝트 관리자만 가능 — 일반 멤버는 진입 즉시 상세로 돌려보낸다
// (백엔드도 admin 전용이지만, 폼 화면 자체를 열지 못하게 막는다)
if (repo.value && !repo.value.canManage) {
// 생성 모드: 프로젝트 관리자만 진입 가능. (편집 모드 권한은 지시자 기준으로 loadTaskForEdit 에서 검사)
if (!isEdit.value && repo.value && !repo.value.canManage) {
void dialog.alert('업무 작성은 프로젝트 관리자만 가능합니다.', { variant: 'warning' })
await router.replace(`/projects/${projectId.value}`)
}
@@ -87,6 +86,12 @@ async function loadTaskForEdit() {
if (taskId.value === null) return
try {
const t = await taskApi.get(projectId.value, taskId.value)
// 편집은 지시자 본인만 — 그 외에는 상세로 돌려보낸다
if (t.issuer?.id !== authStore.user?.id) {
void dialog.alert('업무 수정은 지시자만 가능합니다.', { variant: 'warning' })
await router.replace(`/projects/${projectId.value}/tasks/${taskId.value}`)
return
}
title.value = t.title
contentText.value = t.content.join('\n')
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
@@ -99,6 +104,14 @@ async function loadTaskForEdit() {
category: c.category,
}))
existingFiles.value = t.files
checkpoints.value = t.checkpoints.map((c) => toDateInput(c.checkAt)).sort()
// 보고가 접수된 점검일은 잠금(삭제 불가) — 보고 이력 보존
const reportedCpIds = new Set(t.checkpointReports.map((r) => r.checkpointId))
reportedCheckpointDates.value = new Set(
t.checkpoints
.filter((c) => reportedCpIds.has(c.id))
.map((c) => toDateInput(c.checkAt)),
)
} catch {
// 404 등은 인터셉터가 알림 처리
}
@@ -159,6 +172,26 @@ function onAssigneeBlur() {
// --- 마감기한 ---
const dueDate = ref('') // yyyy-MM-dd (input[type=date])
// --- 중간 점검일(여러 개) — 작업자가 진행 상태를 보고하는 기준일 ---
const checkpoints = ref<string[]>([]) // yyyy-MM-dd 목록
const newCheckpoint = ref('')
// 이미 보고가 접수된 점검일(yyyy-MM-dd) — 삭제 불가(보고 이력 보존)
const reportedCheckpointDates = ref<Set<string>>(new Set())
function addCheckpoint() {
const d = newCheckpoint.value
if (!d) return
if (!checkpoints.value.includes(d)) {
checkpoints.value.push(d)
checkpoints.value.sort()
}
newCheckpoint.value = ''
}
function removeCheckpoint(d: string) {
// 보고가 접수된 날짜는 삭제하지 않는다(서버도 거부/보존)
if (reportedCheckpointDates.value.has(d)) return
checkpoints.value = checkpoints.value.filter((x) => x !== d)
}
// 체크리스트 (생성 시 항목만 등록 — 완료여부는 항상 false 로 생성)
// 항목은 카테고리(탭: 필수/관리/보안/부가)별로 추가한다.
const checklist = ref<ChecklistItem[]>([])
@@ -414,6 +447,7 @@ async function submitTask() {
.filter((p) => p.length > 0)
const assigneeIds = assignees.value.map((a) => a.id)
const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null
const checkpointIsos = checkpoints.value.map((d) => new Date(d).toISOString())
let tid: number
if (isEdit.value && taskId.value !== null) {
@@ -428,6 +462,7 @@ async function submitTask() {
? { id: c.id, text: c.text, category: c.category }
: { text: c.text, category: c.category },
),
checkpoints: checkpointIsos,
})
tid = updated.id
} else {
@@ -439,6 +474,7 @@ async function submitTask() {
checklist: checklist.value.length
? checklist.value.map((c) => ({ text: c.text, category: c.category }))
: undefined,
checkpoints: checkpointIsos.length ? checkpointIsos : undefined,
})
tid = created.id
}
@@ -1032,6 +1068,56 @@ function goBack() {
>
</div>
<div class="side-field">
<div class="side-label">
중간 점검일 <span class="muted">· 선택 · 여러 </span>
</div>
<div class="cp-add">
<input
v-model="newCheckpoint"
class="input date-input"
type="date"
>
<button
class="cp-add-btn"
type="button"
:disabled="!newCheckpoint"
@click="addCheckpoint"
>
추가
</button>
</div>
<ul
v-if="checkpoints.length"
class="cp-list"
>
<li
v-for="d in checkpoints"
:key="d"
class="cp-item"
>
<span>{{ d }}</span>
<span
v-if="reportedCheckpointDates.has(d)"
class="cp-locked"
title="보고가 접수되어 삭제할 수 없습니다"
>보고됨</span>
<button
v-else
class="x"
type="button"
title="삭제"
@click="removeCheckpoint(d)"
>
×
</button>
</li>
</ul>
<p class="cp-hint">
담당자가 점검일 당일에 진행 상태를 보고서로 보낼 있습니다. (지나면 누락)
</p>
</div>
<div class="side-field">
<div class="side-label">
첨부파일 <span
@@ -2141,6 +2227,77 @@ function goBack() {
display: grid;
place-items: center;
}
/* 중간 점검일 입력/목록 */
.cp-add {
display: flex;
gap: 0.5rem;
}
.cp-add .date-input {
flex: 1;
}
.cp-add-btn {
flex-shrink: 0;
border: 1px solid var(--border-strong);
background: #fff;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
padding: 0 0.75rem;
border-radius: var(--radius);
cursor: pointer;
}
.cp-add-btn:hover:not(:disabled) {
background: #f5f6f8;
}
.cp-add-btn:disabled {
opacity: 0.5;
cursor: default;
}
.cp-list {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cp-item {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.8125rem;
color: var(--text);
background: #f5f6f8;
border-radius: var(--radius-sm);
padding: 0.3125rem 0.5rem 0.3125rem 0.625rem;
}
.cp-item .x {
cursor: pointer;
color: var(--text-3);
border: none;
background: transparent;
font-size: 1rem;
line-height: 1;
padding: 0 0.25rem;
}
.cp-locked {
font-size: 0.6875rem;
font-weight: 700;
color: #1b7a3d;
background: var(--green-weak);
padding: 0.0625rem 0.4375rem;
border-radius: 20px;
}
.cp-item .x:hover {
color: var(--red);
}
.cp-hint {
font-size: 0.75rem;
color: var(--text-3);
margin-top: 0.4375rem;
line-height: 1.5;
}
.input .ico svg {
width: 1rem;
height: 1rem;
File diff suppressed because it is too large Load Diff
+2
View File
@@ -100,6 +100,8 @@ function toProjectActivityItem(api: ApiActivity): ProjectActivityItem {
}
case 'task.edited':
return { ...base, tone: 'task', icon: 'pencil', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무를 수정했습니다` }
case 'task.checkpoint_reported':
return { ...base, tone: 'task', icon: 'check', html: `<b>${actor}</b>님이 <span class="tgt">${title}</span> 업무의 중간 점검을 보고했습니다` }
case 'task.file_removed':
return {
...base,
@@ -53,6 +53,10 @@ function toView(n: ApiNotification): NotificationView {
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 ${body}`
break
}
case 'task.checkpoint_reported':
tone = 'blue'
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 중간 점검 상태를 보고했습니다`
break
case 'task.removed':
tone = 'red'
to = projectId ? `/projects/${projectId}` : null // 업무가 삭제되므로 프로젝트로 이동
+40
View File
@@ -30,6 +30,7 @@ import type {
Activity as ActivityView,
Attachment as AttachmentView,
Comment as CommentView,
ChecklistWorkStatus,
ProjectTask as ProjectTaskView,
TaskDetail as TaskDetailView,
TaskStatus,
@@ -158,6 +159,8 @@ function toTaskActivityView(api: ApiActivity): ActivityView {
}
case 'task.edited':
return { tone: '', icon: 'check', html: `<b>${actor}</b>님이 업무를 수정했습니다`, time }
case 'task.checkpoint_reported':
return { tone: 'blue', icon: 'check', html: `<b>${actor}</b>님이 중간 점검 상태를 보고했습니다`, time }
case 'task.file_removed':
return { tone: '', icon: 'paperclip', html: `<b>${actor}</b>님이 첨부 파일을 삭제했습니다`, time, file: pstr(p, 'fileName') }
default:
@@ -183,6 +186,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
done: c.done,
category: c.category,
reviewNote: c.reviewNote,
workStatus: c.workStatus,
})),
comments: api.comments.map((c) => toCommentView(c, issuerId)),
activities: api.activities.map(toTaskActivityView),
@@ -190,6 +194,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER,
dueLabel: due.dateLabel,
dday: due.dday,
dueAt: api.dueDate,
files: api.files.map(toAttachmentView),
approval: api.approval
? {
@@ -202,6 +207,25 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
: undefined,
changesNote: api.changesNote ?? undefined,
issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`,
checkpoints: api.checkpoints.map((c) => ({
id: c.id,
checkAt: c.checkAt,
dateLabel: monthDayKo(c.checkAt),
reported: c.reported,
})),
checkpointReports: api.checkpointReports.map((r) => ({
id: r.id,
checkpointId: r.checkpointId,
reporterName: r.reporter?.name ?? '알 수 없음',
note: r.note,
items: r.items.map((it) => ({
text: it.text,
category: it.category,
workStatus: it.workStatus,
})),
dateLabel: r.checkAt ? monthDayKo(r.checkAt) : '',
timeLabel: relativeTimeKo(r.createdAt),
})),
}
}
@@ -300,6 +324,21 @@ export const useTaskStore = defineStore('task', () => {
return view
}
// 중간 점검 — 보고 전송(항목 상태 + 메모)
async function submitCheckpointReport(
projectId: string,
taskId: number,
checkpointId: string,
items: { id: string; workStatus: ChecklistWorkStatus }[],
note?: string,
): Promise<TaskDetailView> {
const view = toTaskDetailView(
await taskApi.submitCheckpointReport(projectId, taskId, checkpointId, items, note),
)
current.value = view
return view
}
// 부속 변경 후 상세를 다시 받아 일관된 뷰로 갱신하는 공통 헬퍼
async function refresh(projectId: string, taskId: number): Promise<TaskDetailView> {
const view = toTaskDetailView(await taskApi.get(projectId, taskId))
@@ -374,6 +413,7 @@ export const useTaskStore = defineStore('task', () => {
update,
remove,
changeStatus,
submitCheckpointReport,
addComment,
addReply,
requestApproval,
+1
View File
@@ -16,6 +16,7 @@ export type ApiActivityType =
| 'task.assignees_changed'
| 'task.due_changed'
| 'task.edited'
| 'task.checkpoint_reported'
| 'task.file_removed'
| 'task.removed'
+1
View File
@@ -11,6 +11,7 @@ export type NotificationType =
| 'task.changes_requested'
| 'task.started'
| 'task.due_changed'
| 'task.checkpoint_reported'
| 'task.removed'
| 'task.commented'
| 'member.invited'
+49 -1
View File
@@ -1,6 +1,13 @@
// 업무 도메인 타입 — 백엔드 TaskService 응답 / DTO 와 1:1
import type { TaskStatus, ChecklistCategory } from '@/mock/relay.mock'
import type {
TaskStatus,
ChecklistCategory,
ChecklistWorkStatus,
} from '@/mock/relay.mock'
// 진행 상태 타입은 뷰모델(relay.mock)이 SSOT — 외부에서 @/types/task 로도 쓰도록 재노출
export type { ChecklistWorkStatus }
import type { ApiMember } from '@/types/repo'
import type { ApiActivity } from '@/types/activity'
import type { Paginated } from '@/types/pagination'
@@ -25,6 +32,35 @@ export interface ApiChecklistItem {
category: ChecklistCategory
// 보완 내용(수정 요청 시 항목별 입력) — 없으면 null
reviewNote: string | null
// 작업자 진행 상태(자가보고)
workStatus: ChecklistWorkStatus
}
// 중간 점검일(API 원시)
export interface ApiCheckpoint {
id: string
checkAt: string
// 보고 접수 여부(상태 표시용, 전원 노출)
reported: boolean
}
// 중간 점검 보고 스냅샷 항목
export interface ApiReportItem {
itemId: string
text: string
category: ChecklistCategory
workStatus: ChecklistWorkStatus
}
// 중간 점검 보고(API 원시)
export interface ApiCheckpointReport {
id: string
checkpointId: string
checkAt: string | null
reporter: ApiMember | null
note: string | null
items: ApiReportItem[]
createdAt: string
}
// 첨부파일(API 원시) — 백엔드 AttachmentResponse 와 1:1
@@ -81,6 +117,8 @@ export interface ApiTaskDetail {
files: ApiAttachment[]
approval: ApiApproval | null
changesNote: string | null
checkpoints: ApiCheckpoint[]
checkpointReports: ApiCheckpointReport[]
}
// 업무 목록 세그먼트(상태 묶음) — 진행 중 = prog+review
@@ -117,6 +155,8 @@ export interface CreateTaskPayload {
assigneeIds: string[]
dueDate?: string
checklist?: { text: string; category?: ChecklistCategory }[]
// 중간 점검일(ISO8601 목록)
checkpoints?: string[]
}
// 업무 수정 요청
@@ -127,6 +167,14 @@ export interface UpdateTaskPayload {
dueDate?: string | null
// 체크리스트 전체 동기화(id 있으면 기존 유지, 없으면 신규, 빠지면 삭제)
checklist?: { id?: string; text: string; category?: ChecklistCategory }[]
// 중간 점검일(제공 시 전체 교체)
checkpoints?: string[]
}
// 중간 점검 보고 요청(작업자) — 점검일 당일, 항목별 상태 + 메모
export interface CheckpointReportPayload {
note?: string
items: { id: string; workStatus: ChecklistWorkStatus }[]
}
// 댓글/답글 작성 요청(5단계)