feat: 중간 점검 보고 내용은 지시자·담당자만 열람(상태는 전원 노출)

보고 '내용' 열람을 지시자 + 해당 업무 담당자로 제한:
- 백엔드 buildDetail: viewerId 기준으로 권한 없으면 checkpointReports 를 빈 배열로 응답(findOne 에 현재 사용자 전달). 점검일에는 reported 플래그를 두어 상태는 전원 공유
- 프론트: checkpointState 는 reported 플래그 기준, '보고 완료' 클릭(모달)은 지시자·담당자만. 그 외는 비클릭 상태 배지

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:01:50 +09:00
parent b717ecd9a5
commit 6577550937
6 changed files with 55 additions and 21 deletions
+2 -1
View File
@@ -107,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')
+41 -15
View File
@@ -73,6 +73,8 @@ export interface ChecklistItemResponse {
export interface CheckpointResponse {
id: string;
checkAt: string;
// 보고 접수 여부(상태 표시용) — 내용 열람 권한과 무관하게 전원에게 노출
reported: boolean;
}
// 중간 점검 보고 응답(스냅샷 포함)
@@ -343,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(지시자) 권한. 담당자는 프로젝트 멤버만 지정 가능
@@ -413,6 +419,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, saved.seq),
project,
actingUserId,
);
}
@@ -510,6 +517,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -688,6 +696,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -920,6 +929,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -937,6 +947,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -978,6 +989,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -1422,6 +1434,7 @@ export class TaskService {
return this.buildDetail(
await this.getTaskOrThrow(project.id, seq),
project,
actingUserId,
);
}
@@ -1476,6 +1489,7 @@ export class TaskService {
private async buildDetail(
task: Task,
project: Project,
viewerId: string,
): Promise<TaskDetailResponse> {
const [comments, files, activities, checkpointRows, reportRows] =
await Promise.all([
@@ -1505,23 +1519,35 @@ export class TaskService {
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 checkpointReports: CheckpointReportResponse[] = 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(),
}),
);
// 보고 '내용'은 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
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 =
+3 -1
View File
@@ -55,10 +55,12 @@ export interface ChecklistItem {
/** 중간 점검일(표시용) */
export interface TaskCheckpointView {
id: string
/** ISO — 보고 가능 구간(점검일~마감) 계산용 */
/** ISO — 점검일 당일 판정용 */
checkAt: string
/** 표시 라벨 */
dateLabel: string
/** 보고 접수 여부(상태 배지용) */
reported: boolean
}
/** 중간 점검 보고(스냅샷, 표시용) */
+6 -4
View File
@@ -322,10 +322,12 @@ const CP_STATE_LABEL: Record<CheckpointState, string> = {
reported: '보고 완료',
missed: '누락',
}
// 체크포인트 상태 — 보고됨 / 당일 / 지남(누락) / 예정
// 보고 '내용' 열람 권한 — 지시자 또는 해당 업무 담당자(백엔드도 동일하게 제한)
const canViewReports = computed(() => isIssuer.value || isAssignee.value)
// 체크포인트 상태 — 보고됨 / 당일 / 지남(누락) / 예정. 보고됨은 reported 플래그(전원 공유) 기준
function checkpointState(cp: TaskCheckpointView): CheckpointState {
const reported = task.value?.checkpointReports.some((r) => r.checkpointId === cp.id) ?? false
if (reported) return 'reported'
if (cp.reported) return 'reported'
const cpDay = kstDate(cp.checkAt)
if (cpDay === todayKst.value) return 'today'
if (cpDay < todayKst.value) return 'missed'
@@ -1279,7 +1281,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
{{ reportingCpId === cp.id ? '취소' : '보고서 작성' }}
</button>
<button
v-else-if="checkpointState(cp) === 'reported'"
v-else-if="checkpointState(cp) === 'reported' && canViewReports"
class="cp-state reported clickable"
type="button"
title="보고 내용 보기"
+1
View File
@@ -209,6 +209,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
id: c.id,
checkAt: c.checkAt,
dateLabel: monthDayKo(c.checkAt),
reported: c.reported,
})),
checkpointReports: api.checkpointReports.map((r) => ({
id: r.id,
+2
View File
@@ -40,6 +40,8 @@ export interface ApiChecklistItem {
export interface ApiCheckpoint {
id: string
checkAt: string
// 보고 접수 여부(상태 표시용, 전원 노출)
reported: boolean
}
// 중간 점검 보고 스냅샷 항목