Compare commits
27 Commits
c550d66826
...
2b534743d6
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b534743d6 | |||
| af8b363f23 | |||
| 4efe5a7a9a | |||
| dd7d75310e | |||
| 32c04db9fe | |||
| b8bb046733 | |||
| ea83895938 | |||
| 1f7d7dcebb | |||
| 1df0f2e4e2 | |||
| 69a25d46ec | |||
| a8d068a76c | |||
| ce50c3c4f5 | |||
| 2fd19e0bec | |||
| 1a6321ed49 | |||
| bc99911b5f | |||
| 69708b2b8b | |||
| b00785d056 | |||
| 4a31331ae1 | |||
| 8a52ad0d0a | |||
| 6577550937 | |||
| b717ecd9a5 | |||
| 814dd7bb8b | |||
| 98a2308c4e | |||
| 31704cc95b | |||
| 4ef07f65a7 | |||
| 25ca63a925 | |||
| 20664dab0f |
@@ -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;
|
||||
}
|
||||
@@ -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,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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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 강조 단계 */
|
||||
|
||||
@@ -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
@@ -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 // 업무가 삭제되므로 프로젝트로 이동
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,6 +16,7 @@ export type ApiActivityType =
|
||||
| 'task.assignees_changed'
|
||||
| 'task.due_changed'
|
||||
| 'task.edited'
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.file_removed'
|
||||
| 'task.removed'
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export type NotificationType =
|
||||
| 'task.changes_requested'
|
||||
| 'task.started'
|
||||
| 'task.due_changed'
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.removed'
|
||||
| 'task.commented'
|
||||
| 'member.invited'
|
||||
|
||||
@@ -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단계)
|
||||
|
||||
Reference in New Issue
Block a user