Compare commits
3 Commits
de92fc01b4
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 01132d2ac3 | |||
| 5ff9bf0df8 | |||
| fb0669990d |
@@ -0,0 +1,41 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 공동 지시자 기능 — 업무별 공동 지시자(task_co_issuers) 다대다 조인 테이블 추가.
|
||||
// 원 지시자(tasks.issuer_id)와 함께 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskCoIssuers1783400000000 implements MigrationInterface {
|
||||
name = 'AddTaskCoIssuers1783400000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 공동 지시자 조인 테이블(task_assignees 와 동일 구조)
|
||||
await queryRunner.query(
|
||||
`CREATE TABLE "task_co_issuers" ("task_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_task_co_issuers" PRIMARY KEY ("task_id", "user_id"))`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_task_co_issuers_task" ON "task_co_issuers" ("task_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_task_co_issuers_user" ON "task_co_issuers" ("user_id")`,
|
||||
);
|
||||
|
||||
// FK — 업무/사용자 삭제 시 조인 행도 함께 삭제
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_task" FOREIGN KEY ("task_id") REFERENCES "tasks"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" ADD CONSTRAINT "FK_task_co_issuers_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_user"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "task_co_issuers" DROP CONSTRAINT "FK_task_co_issuers_task"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_user"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_task_co_issuers_task"`);
|
||||
await queryRunner.query(`DROP TABLE "task_co_issuers"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 검토자 기록 — 업무의 최근 검토(승인/수정요청) 수행자(tasks.reviewed_by) 컬럼 추가.
|
||||
// 원 지시자·공동 지시자 중 실제로 검토한 사람을 저장해, 완료/수정요청 카드에 정확한 검토자를 표기한다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskReviewedBy1783500000000 implements MigrationInterface {
|
||||
name = 'AddTaskReviewedBy1783500000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "tasks" ADD "reviewed_by" uuid`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD CONSTRAINT "FK_tasks_reviewed_by" FOREIGN KEY ("reviewed_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP CONSTRAINT "FK_tasks_reviewed_by"`,
|
||||
);
|
||||
await queryRunner.query(`ALTER TABLE "tasks" DROP COLUMN "reviewed_by"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 마감 임박·초과 알림(cron) 중복 발송 방지 플래그 — tasks 에 발송 시각 컬럼 2개 추가.
|
||||
// 마감이 변경되면 서비스에서 다시 null 로 초기화해 새 마감 기준으로 재알림한다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddTaskDueNotifyFlags1783600000000 implements MigrationInterface {
|
||||
name = 'AddTaskDueNotifyFlags1783600000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD "due_soon_notified_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" ADD "overdue_notified_at" TIMESTAMP WITH TIME ZONE`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP COLUMN "overdue_notified_at"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "tasks" DROP COLUMN "due_soon_notified_at"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export type NotificationType =
|
||||
| 'task.changes_requested' // 내가 담당한 업무에 수정이 요청됨
|
||||
| 'task.started' // 내가 지시한 업무를 담당자가 시작/재개함
|
||||
| 'task.due_changed' // 내가 담당한 업무의 마감이 변경됨
|
||||
| 'task.due_soon' // 내가 담당한 업무의 마감이 하루 앞으로 다가옴
|
||||
| 'task.overdue' // 내가 담당한 업무의 마감이 지남(미완료)
|
||||
| 'task.checkpoint_reported' // 내가 지시한 업무의 중간 점검 보고가 옴
|
||||
| 'task.removed' // 내 관련 업무가 삭제됨
|
||||
| 'task.commented' // 내 관련 업무에 댓글/답글이 달림
|
||||
|
||||
@@ -14,6 +14,8 @@ const ALL_TYPES: NotificationType[] = [
|
||||
'task.changes_requested',
|
||||
'task.started',
|
||||
'task.due_changed',
|
||||
'task.due_soon',
|
||||
'task.overdue',
|
||||
'task.checkpoint_reported',
|
||||
'task.removed',
|
||||
'task.commented',
|
||||
@@ -58,9 +60,9 @@ describe('WebPushService.notificationContent', () => {
|
||||
expect(content.body.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('타입 목록이 17종(현재 정의)과 일치해야 한다', () => {
|
||||
it('타입 목록이 19종(현재 정의)과 일치해야 한다', () => {
|
||||
// NotificationType 추가 시 이 테스트가 알려주도록 개수를 고정한다.
|
||||
expect(ALL_TYPES).toHaveLength(17);
|
||||
expect(ALL_TYPES).toHaveLength(19);
|
||||
expect(new Set(ALL_TYPES).size).toBe(ALL_TYPES.length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +140,22 @@ export class WebPushService implements OnModuleInit {
|
||||
: `'${taskTitle}' 업무의 마감기한을 해제했습니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.due_soon': {
|
||||
const label = fmtDueKo(pstr(payload, 'dueDate'));
|
||||
title = '마감 임박';
|
||||
body = label
|
||||
? `'${taskTitle}' 업무의 마감이 다가옵니다 (마감: ${label})`
|
||||
: `'${taskTitle}' 업무의 마감이 다가옵니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.overdue': {
|
||||
const label = fmtDueKo(pstr(payload, 'dueDate'));
|
||||
title = '마감 초과';
|
||||
body = label
|
||||
? `'${taskTitle}' 업무가 마감기한을 넘겼습니다 (마감: ${label})`
|
||||
: `'${taskTitle}' 업무가 마감기한을 넘겼습니다`;
|
||||
break;
|
||||
}
|
||||
case 'task.checkpoint_reported':
|
||||
body = `'${taskTitle}' 업무의 중간 점검을 보고했습니다`;
|
||||
break;
|
||||
|
||||
@@ -73,6 +73,19 @@ export class CreateTaskDto {
|
||||
@IsUUID('4', { each: true })
|
||||
assigneeIds!: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'공동 지시자 사용자 ID 배열(선택, 프로젝트 멤버만 가능) — 원 지시자와 함께 완료 검토 권한을 가진다',
|
||||
type: [String],
|
||||
required: false,
|
||||
example: ['b2c3d4e5-...'],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20, { message: '공동 지시자가 너무 많습니다.' })
|
||||
@IsUUID('4', { each: true })
|
||||
coIssuerIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '마감 기한(ISO8601, 선택)',
|
||||
required: false,
|
||||
|
||||
@@ -80,6 +80,18 @@ export class UpdateTaskDto {
|
||||
@IsUUID('4', { each: true })
|
||||
assigneeIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description:
|
||||
'공동 지시자 사용자 ID 배열(프로젝트 멤버만 가능). 제공 시 전체 교체, 빈 배열이면 모두 해제',
|
||||
type: [String],
|
||||
required: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20, { message: '공동 지시자가 너무 많습니다.' })
|
||||
@IsUUID('4', { each: true })
|
||||
coIssuerIds?: string[];
|
||||
|
||||
@ApiProperty({
|
||||
description: '마감 기한(ISO8601). null 이면 마감 해제',
|
||||
required: false,
|
||||
|
||||
@@ -60,6 +60,16 @@ export class Task {
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
issuer!: User | null;
|
||||
|
||||
// 공동 지시자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증).
|
||||
// 원 지시자와 동일하게 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
@ManyToMany(() => User)
|
||||
@JoinTable({
|
||||
name: 'task_co_issuers',
|
||||
joinColumn: { name: 'task_id', referencedColumnName: 'id' },
|
||||
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
|
||||
})
|
||||
coIssuers!: User[];
|
||||
|
||||
// 담당자 목록 (프로젝트 멤버만 지정 가능 — 서비스에서 검증)
|
||||
@ManyToMany(() => User)
|
||||
@JoinTable({
|
||||
@@ -96,6 +106,23 @@ export class Task {
|
||||
@Column({ name: 'changes_note', type: 'text', nullable: true })
|
||||
changesNote!: string | null;
|
||||
|
||||
// 최근 검토(승인/수정요청)를 수행한 사용자 — 원 지시자 또는 공동 지시자.
|
||||
// 완료/수정요청 카드의 '검토자' 표기에 사용(사용자 삭제 시 SET NULL)
|
||||
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'reviewed_by' })
|
||||
reviewedBy!: User | null;
|
||||
|
||||
// --- 마감 임박·초과 알림(cron) 발송 여부 플래그 ---
|
||||
// 중복 발송을 막기 위해 발송 시각을 기록하고, 마감일이 변경되면 다시 null 로 초기화한다.
|
||||
|
||||
// 마감 임박(하루 앞) 알림 발송 시각 — 미발송이면 null
|
||||
@Column({ name: 'due_soon_notified_at', type: 'timestamptz', nullable: true })
|
||||
dueSoonNotifiedAt!: Date | null;
|
||||
|
||||
// 마감 초과 알림 발송 시각 — 미발송이면 null
|
||||
@Column({ name: 'overdue_notified_at', type: 'timestamptz', nullable: true })
|
||||
overdueNotifiedAt!: Date | null;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
@@ -2,10 +2,18 @@ import {
|
||||
ForbiddenException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, IsNull, Repository, type SelectQueryBuilder } from 'typeorm';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import {
|
||||
Brackets,
|
||||
In,
|
||||
IsNull,
|
||||
Repository,
|
||||
type SelectQueryBuilder,
|
||||
} from 'typeorm';
|
||||
import { UserService, type PublicUser } from '../user/user.service';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
@@ -161,6 +169,8 @@ export interface TaskDetailResponse {
|
||||
checklist: ChecklistItemResponse[];
|
||||
assignees: PublicUser[];
|
||||
issuer: PublicUser | null;
|
||||
// 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다
|
||||
coIssuers: PublicUser[];
|
||||
dueDate: string | null;
|
||||
createdAt: string;
|
||||
comments: CommentResponse[];
|
||||
@@ -168,6 +178,8 @@ export interface TaskDetailResponse {
|
||||
files: AttachmentResponse[];
|
||||
approval: ApprovalInfo | null;
|
||||
changesNote: string | null;
|
||||
// 최근 검토(승인/수정요청)를 수행한 사용자 — 완료/수정요청 카드의 검토자 표기용
|
||||
reviewer: PublicUser | null;
|
||||
// 중간 점검일 + 작업자 보고 이력
|
||||
checkpoints: CheckpointResponse[];
|
||||
checkpointReports: CheckpointReportResponse[];
|
||||
@@ -194,6 +206,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
// 업무 비즈니스 로직
|
||||
@Injectable()
|
||||
export class TaskService {
|
||||
private readonly logger = new Logger(TaskService.name);
|
||||
|
||||
// 마감 임박 판정 창(시간) — 미완료 업무의 마감이 이 시간 이내로 다가오면 임박 알림 1회 발송
|
||||
private static readonly DUE_SOON_WINDOW_HOURS = 24;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Task)
|
||||
private readonly taskRepo: Repository<Task>,
|
||||
@@ -230,6 +247,99 @@ export class TaskService {
|
||||
};
|
||||
}
|
||||
|
||||
// 마감 알림 payload — 행위자 없는 시스템 알림. 마감 라벨 표시를 위해 dueDate 동봉
|
||||
private dueNotifyPayload(task: Task): Record<string, unknown> {
|
||||
return {
|
||||
projectId: task.project.id,
|
||||
projectName: task.project.name,
|
||||
taskSeq: task.seq,
|
||||
taskTitle: task.title,
|
||||
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
|
||||
};
|
||||
}
|
||||
|
||||
// 마감 임박·초과 알림 — 매일 오전 9시. 미완료(done 아님) 업무 중
|
||||
// (1) 마감이 DUE_SOON_WINDOW_HOURS 이내로 다가온 건 → 담당자에게 '마감 임박' 1회
|
||||
// (2) 마감이 이미 지난 건 → 담당자에게 '마감 초과' 1회
|
||||
// 각각 발송 플래그(dueSoonNotifiedAt/overdueNotifiedAt)로 중복 발송을 막는다.
|
||||
// best-effort: 실패해도 서비스에 영향 없음(다음 주기 재시도).
|
||||
// 서버 타임존과 무관하게 한국 시간 오전 9시에 발송되도록 timeZone 고정.
|
||||
@Cron(CronExpression.EVERY_DAY_AT_9AM, { timeZone: 'Asia/Seoul' })
|
||||
async notifyDueTasks(): Promise<void> {
|
||||
const now = new Date();
|
||||
try {
|
||||
await this.notifyDueSoon(now);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`마감 임박 알림 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.notifyOverdue(now);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`마감 초과 알림 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 마감 임박(하루 앞) 미발송 업무 → 담당자에게 알림. 다중 인스턴스 중복 발송을 막기 위해
|
||||
// 플래그를 원자적으로 선점(UPDATE ... WHERE ... IS NULL RETURNING)한 인스턴스만 발송한다.
|
||||
private async notifyDueSoon(now: Date): Promise<void> {
|
||||
const soonEnd = new Date(
|
||||
now.getTime() + TaskService.DUE_SOON_WINDOW_HOURS * 60 * 60 * 1000,
|
||||
);
|
||||
const claimed = await this.taskRepo
|
||||
.createQueryBuilder()
|
||||
.update(Task)
|
||||
.set({ dueSoonNotifiedAt: now })
|
||||
.where('status != :done', { done: 'done' })
|
||||
.andWhere('due_date BETWEEN :now AND :soonEnd', { now, soonEnd })
|
||||
.andWhere('due_soon_notified_at IS NULL')
|
||||
.returning('id')
|
||||
.execute();
|
||||
await this.notifyClaimedDue(claimed, 'task.due_soon', '마감 임박');
|
||||
}
|
||||
|
||||
// 마감 초과 미발송 업무 → 담당자에게 알림(위와 동일하게 선점 후 발송)
|
||||
private async notifyOverdue(now: Date): Promise<void> {
|
||||
const claimed = await this.taskRepo
|
||||
.createQueryBuilder()
|
||||
.update(Task)
|
||||
.set({ overdueNotifiedAt: now })
|
||||
.where('status != :done', { done: 'done' })
|
||||
.andWhere('due_date < :now', { now })
|
||||
.andWhere('overdue_notified_at IS NULL')
|
||||
.returning('id')
|
||||
.execute();
|
||||
await this.notifyClaimedDue(claimed, 'task.overdue', '마감 초과');
|
||||
}
|
||||
|
||||
// 선점(UPDATE RETURNING id)한 업무들을 로드해 담당자에게 마감 알림을 발송
|
||||
private async notifyClaimedDue(
|
||||
claimed: { raw: unknown },
|
||||
type: 'task.due_soon' | 'task.overdue',
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const ids = Array.isArray(claimed.raw)
|
||||
? (claimed.raw as { id: string }[]).map((r) => r.id)
|
||||
: [];
|
||||
if (ids.length === 0) return;
|
||||
const tasks = await this.taskRepo.find({
|
||||
where: { id: In(ids) },
|
||||
relations: ['assignees', 'project'],
|
||||
});
|
||||
for (const task of tasks) {
|
||||
await this.notification.notify({
|
||||
recipientIds: (task.assignees ?? []).map((a) => a.id),
|
||||
actorId: null,
|
||||
type,
|
||||
payload: this.dueNotifyPayload(task),
|
||||
});
|
||||
}
|
||||
this.logger.log(`${label} 알림 ${tasks.length}건 발송`);
|
||||
}
|
||||
|
||||
// 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트
|
||||
async list(
|
||||
projectId: string,
|
||||
@@ -369,6 +479,13 @@ export class TaskService {
|
||||
dto.assigneeIds,
|
||||
);
|
||||
|
||||
// 공동 지시자(선택) — 프로젝트 멤버만 가능, 원 지시자 본인은 제외
|
||||
const coIssuers = await this.resolveCoIssuers(
|
||||
project.id,
|
||||
dto.coIssuerIds,
|
||||
issuer.user.id,
|
||||
);
|
||||
|
||||
// 프로젝트 내 다음 순번 채번
|
||||
const last = await this.taskRepo.findOne({
|
||||
where: { project: { id: project.id } },
|
||||
@@ -384,6 +501,7 @@ export class TaskService {
|
||||
status: 'todo',
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
issuer: issuer.user,
|
||||
coIssuers,
|
||||
assignees,
|
||||
checklist: (dto.checklist ?? []).map((c, idx) =>
|
||||
this.checklistRepo.create({
|
||||
@@ -443,7 +561,15 @@ export class TaskService {
|
||||
if (dto.title !== undefined) task.title = dto.title;
|
||||
if (dto.content !== undefined) task.content = dto.content;
|
||||
if (dto.dueDate !== undefined) {
|
||||
task.dueDate = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
const newDue = dto.dueDate ? new Date(dto.dueDate) : null;
|
||||
const changed =
|
||||
(task.dueDate?.getTime() ?? null) !== (newDue?.getTime() ?? null);
|
||||
task.dueDate = newDue;
|
||||
// 마감일이 실제로 바뀌면 임박·초과 알림을 새 마감 기준으로 다시 보낼 수 있게 플래그 초기화
|
||||
if (changed) {
|
||||
task.dueSoonNotifiedAt = null;
|
||||
task.overdueNotifiedAt = null;
|
||||
}
|
||||
}
|
||||
if (dto.assigneeIds !== undefined) {
|
||||
task.assignees = await this.resolveMemberAssignees(
|
||||
@@ -451,6 +577,13 @@ export class TaskService {
|
||||
dto.assigneeIds,
|
||||
);
|
||||
}
|
||||
if (dto.coIssuerIds !== undefined) {
|
||||
task.coIssuers = await this.resolveCoIssuers(
|
||||
project.id,
|
||||
dto.coIssuerIds,
|
||||
task.issuer?.id ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
await this.taskRepo.save(task);
|
||||
|
||||
@@ -1072,7 +1205,7 @@ export class TaskService {
|
||||
);
|
||||
}
|
||||
|
||||
// 내가 지시(생성)한 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
|
||||
// 내가 지시(생성)한 업무 — 원 지시자 또는 공동 지시자. 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
|
||||
async listIssued(
|
||||
userId: string,
|
||||
page?: number,
|
||||
@@ -1086,7 +1219,16 @@ export class TaskService {
|
||||
.leftJoinAndSelect('task.assignees', 'assignee')
|
||||
.leftJoinAndSelect('task.issuer', 'issuer')
|
||||
.leftJoinAndSelect('task.checklist', 'checklist')
|
||||
.where('task.issuer = :userId', { userId });
|
||||
// 원 지시자이거나 공동 지시자(task_co_issuers)면 '내가 지시한 업무'에 포함.
|
||||
// 공동 지시자는 EXISTS 서브쿼리로 판정해 조인으로 인한 행 중복(페이지네이션 왜곡)을 피한다.
|
||||
.where(
|
||||
new Brackets((w) => {
|
||||
w.where('task.issuer = :userId').orWhere(
|
||||
'EXISTS (SELECT 1 FROM task_co_issuers tci WHERE tci.task_id = task.id AND tci.user_id = :userId)',
|
||||
);
|
||||
}),
|
||||
)
|
||||
.setParameter('userId', userId);
|
||||
this.applyMyTaskFilters(qb, filters);
|
||||
const [tasks, total] = await qb
|
||||
.skip(pg.skip)
|
||||
@@ -1141,17 +1283,26 @@ export class TaskService {
|
||||
|
||||
const isAdmin = membership.roleType === 'admin';
|
||||
const isIssuer = task.issuer?.id === actingUserId;
|
||||
// 공동 지시자도 원 지시자와 동일한 검토 권한을 가진다
|
||||
const isCoIssuer = (task.coIssuers ?? []).some(
|
||||
(u) => u.id === actingUserId,
|
||||
);
|
||||
const isAssignee = task.assignees.some((a) => a.id === actingUserId);
|
||||
// 승인/수정요청(review 출발)은 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
|
||||
// 승인/수정요청(review 출발)은 지시자·공동 지시자(또는 admin), 그 외 작업 전이는 담당자(또는 admin/지시자)
|
||||
const permitted =
|
||||
from === 'review'
|
||||
? isAdmin || isIssuer
|
||||
: isAdmin || isIssuer || isAssignee;
|
||||
? isAdmin || isIssuer || isCoIssuer
|
||||
: isAdmin || isIssuer || isCoIssuer || isAssignee;
|
||||
if (!permitted) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
task.status = next;
|
||||
// 검토 전이(승인→done / 수정요청→changes) 시 실제 검토자 기록 — 완료/수정요청 카드의 검토자 표기용.
|
||||
// 원 지시자·공동 지시자·admin 누구든 검토한 사람이 남는다.
|
||||
if (next === 'done' || next === 'changes') {
|
||||
task.reviewedBy = membership.user;
|
||||
}
|
||||
await this.taskRepo.save(task);
|
||||
|
||||
// 승인 완료(→done) 시 체크리스트 항목을 모두 완료 처리 + 보완 내용 비움(부수효과로 영속)
|
||||
@@ -1172,11 +1323,16 @@ export class TaskService {
|
||||
taskSeq: task.seq,
|
||||
});
|
||||
|
||||
// 알림 — 전이 종류별 수신자(승인요청→지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
|
||||
// 알림 — 전이 종류별 수신자(승인요청→지시자·공동지시자, 승인/수정요청→담당자). 시작(→prog)은 알림 없음
|
||||
const actorName = membership.user.name;
|
||||
// 검토자(원 지시자 + 공동 지시자) — 승인요청·시작 알림 수신 대상. 행위자는 notify 에서 자동 제외
|
||||
const reviewerIds = [
|
||||
...(task.issuer ? [task.issuer.id] : []),
|
||||
...(task.coIssuers ?? []).map((u) => u.id),
|
||||
];
|
||||
if (next === 'review') {
|
||||
await this.notification.notify({
|
||||
recipientIds: task.issuer ? [task.issuer.id] : [],
|
||||
recipientIds: reviewerIds,
|
||||
actorId: actingUserId,
|
||||
type: 'task.approval_requested',
|
||||
payload: this.taskNotifyPayload(project, task, actorName),
|
||||
@@ -1196,10 +1352,10 @@ export class TaskService {
|
||||
payload: this.taskNotifyPayload(project, task, actorName),
|
||||
});
|
||||
} else if (next === 'prog') {
|
||||
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자에게.
|
||||
// 시작(todo→prog)·재개(changes→prog) — 담당자가 작업을 시작했음을 지시자·공동 지시자에게.
|
||||
// resumed=true 면 '재개', 아니면 '시작'으로 문구 분기(수정 요청 후 재개 구분).
|
||||
await this.notification.notify({
|
||||
recipientIds: task.issuer ? [task.issuer.id] : [],
|
||||
recipientIds: reviewerIds,
|
||||
actorId: actingUserId,
|
||||
type: 'task.started',
|
||||
payload: {
|
||||
@@ -1225,7 +1381,14 @@ export class TaskService {
|
||||
private async getTaskOrThrow(projectId: string, seq: number): Promise<Task> {
|
||||
const task = await this.taskRepo.findOne({
|
||||
where: { project: { id: projectId }, seq },
|
||||
relations: ['assignees', 'issuer', 'checklist', 'approvalRequestedBy'],
|
||||
relations: [
|
||||
'assignees',
|
||||
'issuer',
|
||||
'coIssuers',
|
||||
'checklist',
|
||||
'approvalRequestedBy',
|
||||
'reviewedBy',
|
||||
],
|
||||
});
|
||||
if (!task) {
|
||||
throw new NotFoundException();
|
||||
@@ -1300,6 +1463,31 @@ export class TaskService {
|
||||
return memberships.map((m) => m.user);
|
||||
}
|
||||
|
||||
// 공동 지시자 ID 목록 → User[] 로 변환. 프로젝트 멤버만 허용하며 원 지시자(excludeUserId)는 제외.
|
||||
// 목록이 없거나 비면 빈 배열.
|
||||
private async resolveCoIssuers(
|
||||
projectId: string,
|
||||
coIssuerIds: string[] | undefined,
|
||||
excludeUserId: string | null,
|
||||
): Promise<ProjectMember['user'][]> {
|
||||
const uniqueIds = [...new Set(coIssuerIds ?? [])].filter(
|
||||
(id) => id !== excludeUserId,
|
||||
);
|
||||
if (uniqueIds.length === 0) return [];
|
||||
const memberships = await this.memberRepo.find({
|
||||
where: { project: { id: projectId }, user: { id: In(uniqueIds) } },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (memberships.length !== uniqueIds.length) {
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
'공동 지시자는 프로젝트 멤버만 지정할 수 있습니다.',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return memberships.map((m) => m.user);
|
||||
}
|
||||
|
||||
// 업무별 댓글 수(답글 포함) 집계 — 목록 commentCount 용
|
||||
private async commentCountsByTask(
|
||||
taskIds: string[],
|
||||
@@ -1558,9 +1746,10 @@ export class TaskService {
|
||||
reported: reportedCheckpointIds.has(c.id),
|
||||
}));
|
||||
|
||||
// 보고 '내용'은 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
|
||||
// 보고 '내용'은 지시자 + 공동 지시자 + 해당 업무 담당자만 열람 가능(그 외에는 빈 배열)
|
||||
const canViewReports =
|
||||
task.issuer?.id === viewerId ||
|
||||
(task.coIssuers ?? []).some((u) => u.id === viewerId) ||
|
||||
(task.assignees ?? []).some((a) => a.id === viewerId);
|
||||
const checkpointReports: CheckpointReportResponse[] = canViewReports
|
||||
? reportRows.map((r) => ({
|
||||
@@ -1598,6 +1787,7 @@ export class TaskService {
|
||||
checklist,
|
||||
assignees: (task.assignees ?? []).map((u) => UserService.toPublic(u)),
|
||||
issuer: task.issuer ? UserService.toPublic(task.issuer) : null,
|
||||
coIssuers: (task.coIssuers ?? []).map((u) => UserService.toPublic(u)),
|
||||
dueDate: task.dueDate ? task.dueDate.toISOString() : null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
comments,
|
||||
@@ -1605,6 +1795,7 @@ export class TaskService {
|
||||
files,
|
||||
approval,
|
||||
changesNote: task.status === 'changes' ? task.changesNote : null,
|
||||
reviewer: task.reviewedBy ? UserService.toPublic(task.reviewedBy) : null,
|
||||
checkpoints,
|
||||
checkpointReports,
|
||||
};
|
||||
|
||||
@@ -171,6 +171,8 @@ export interface TaskDetail {
|
||||
activities: Activity[]
|
||||
assignees: User[]
|
||||
issuer: User
|
||||
/** 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다 */
|
||||
coIssuers: User[]
|
||||
dueLabel: string
|
||||
dday: string
|
||||
/** 원본 마감 ISO(중간 점검 보고 가능 구간 계산용) — 없으면 null */
|
||||
@@ -184,6 +186,8 @@ export interface TaskDetail {
|
||||
}
|
||||
/** 수정 요청 메시지(수정 요청 상태일 때) */
|
||||
changesNote?: string
|
||||
/** 최근 검토(승인/수정요청)를 수행한 사용자 — 없으면(레거시) 원 지시자로 대체 표기 */
|
||||
reviewer: User | null
|
||||
issuedLabel: string
|
||||
/** 중간 점검일 목록 */
|
||||
checkpoints: TaskCheckpointView[]
|
||||
|
||||
@@ -364,10 +364,7 @@ function openActivity(): void {
|
||||
</div>
|
||||
|
||||
<!-- 목록 -->
|
||||
<div
|
||||
class="dr-listwrap"
|
||||
:class="{ 'is-empty': isEmpty || noResult }"
|
||||
>
|
||||
<div class="dr-listwrap">
|
||||
<EmptyState v-if="loading && isEmpty">
|
||||
불러오는 중…
|
||||
</EmptyState>
|
||||
@@ -686,12 +683,8 @@ function openActivity(): void {
|
||||
flex: 1 1 auto; /* 파일 영역이 페이지의 남는 공간 전체를 차지 → 드래그 드롭 영역도 그만큼 */
|
||||
min-height: 16rem; /* 짧은 뷰포트에서도 최소 드롭 영역 보장 */
|
||||
}
|
||||
/* 비어있음/검색없음일 때 안내문을 영역 중앙에 배치(넓은 빈 영역이 자연스럽게 보이도록) */
|
||||
.dr-listwrap.is-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* 비어있음/검색없음일 때도 안내문 카드는 상단에 폭을 꽉 채워 배치(프로젝트·회의 화면과 통일).
|
||||
남는 아래 공간은 그대로 드래그 드롭 영역으로 유지된다. */
|
||||
.dr-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -96,6 +96,7 @@ async function loadTaskForEdit() {
|
||||
contentText.value = t.content.join('\n')
|
||||
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
|
||||
assignees.value = t.assignees.map(toUserView)
|
||||
coIssuers.value = (t.coIssuers ?? []).map(toUserView)
|
||||
// id 를 보존해 저장 시 기존 항목을 식별(완료 상태 유지)
|
||||
checklist.value = t.checklist.map((c) => ({
|
||||
id: c.id,
|
||||
@@ -169,6 +170,34 @@ function onAssigneeBlur() {
|
||||
setTimeout(() => (showAssigneeDropdown.value = false), 120)
|
||||
}
|
||||
|
||||
// --- 공동 지시자 선택 (프로젝트 멤버 중, 원 지시자 본인 제외) ---
|
||||
// 원 지시자와 함께 완료 검토(승인/수정요청) 권한을 가진다.
|
||||
const coIssuers = ref<User[]>([])
|
||||
const coIssuerQuery = ref('')
|
||||
const showCoIssuerDropdown = ref(false)
|
||||
function removeCoIssuer(id: string) {
|
||||
coIssuers.value = coIssuers.value.filter((c) => c.id !== id)
|
||||
}
|
||||
// 아직 선택되지 않은 멤버 후보(이름 부분일치) — 원 지시자 본인은 제외
|
||||
const coIssuerCandidates = computed(() => {
|
||||
const selected = new Set(coIssuers.value.map((c) => c.id))
|
||||
const kw = coIssuerQuery.value.trim()
|
||||
return memberStore.members
|
||||
.filter((m) => m.user.id !== issuer.value.id && !selected.has(m.user.id))
|
||||
.filter((m) => !kw || m.user.name.includes(kw) || m.email.includes(kw))
|
||||
})
|
||||
function addCoIssuer(userId: string) {
|
||||
const m = memberStore.members.find((x) => x.user.id === userId)
|
||||
if (m && !coIssuers.value.some((c) => c.id === userId)) {
|
||||
coIssuers.value.push(m.user)
|
||||
}
|
||||
coIssuerQuery.value = ''
|
||||
showCoIssuerDropdown.value = false
|
||||
}
|
||||
function onCoIssuerBlur() {
|
||||
setTimeout(() => (showCoIssuerDropdown.value = false), 120)
|
||||
}
|
||||
|
||||
// --- 마감기한 ---
|
||||
const dueDate = ref('') // yyyy-MM-dd (input[type=date])
|
||||
|
||||
@@ -446,6 +475,7 @@ async function submitTask() {
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const assigneeIds = assignees.value.map((a) => a.id)
|
||||
const coIssuerIds = coIssuers.value.map((c) => c.id)
|
||||
const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null
|
||||
const checkpointIsos = checkpoints.value.map((d) => new Date(d).toISOString())
|
||||
|
||||
@@ -456,6 +486,7 @@ async function submitTask() {
|
||||
title: title.value.trim(),
|
||||
content,
|
||||
assigneeIds,
|
||||
coIssuerIds,
|
||||
dueDate: dueIso,
|
||||
checklist: checklist.value.map((c) =>
|
||||
c.id
|
||||
@@ -470,6 +501,7 @@ async function submitTask() {
|
||||
title: title.value.trim(),
|
||||
content: content.length ? content : undefined,
|
||||
assigneeIds,
|
||||
coIssuerIds: coIssuerIds.length ? coIssuerIds : undefined,
|
||||
dueDate: dueIso ?? undefined,
|
||||
checklist: checklist.value.length
|
||||
? checklist.value.map((c) => ({ text: c.text, category: c.category }))
|
||||
@@ -1232,6 +1264,86 @@ function goBack() {
|
||||
<div class="nm">{{ issuer.name }}</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 공동 지시자(선택) — 원 지시자와 함께 완료 검토 권한을 가진다 -->
|
||||
<div class="co-issuer-block">
|
||||
<div class="side-label co-label">
|
||||
공동 지시자 <span class="muted">· 선택</span>
|
||||
</div>
|
||||
<div class="combo">
|
||||
<div class="combo-field">
|
||||
<input
|
||||
v-model="coIssuerQuery"
|
||||
class="combo-input"
|
||||
placeholder="멤버 검색…"
|
||||
autocomplete="off"
|
||||
@focus="showCoIssuerDropdown = true"
|
||||
@blur="onCoIssuerBlur"
|
||||
>
|
||||
<span class="combo-caret">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 멤버 후보 드롭다운 -->
|
||||
<div
|
||||
v-if="showCoIssuerDropdown"
|
||||
class="assignee-dd"
|
||||
>
|
||||
<button
|
||||
v-for="m in coIssuerCandidates"
|
||||
:key="m.user.id"
|
||||
type="button"
|
||||
class="assignee-dd-item"
|
||||
@mousedown.prevent="addCoIssuer(m.user.id)"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="m.user.avatarUrl"
|
||||
:name="m.user.name"
|
||||
/></span>
|
||||
<span class="ad-nm">{{ m.user.name }}</span>
|
||||
<span class="ad-em">{{ m.email }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="coIssuerCandidates.length === 0"
|
||||
class="assignee-dd-msg"
|
||||
>
|
||||
{{ memberStore.members.length === 0 ? '프로젝트 멤버가 없습니다.' : '추가할 멤버가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 선택된 공동 지시자 칩 -->
|
||||
<div
|
||||
v-if="coIssuers.length"
|
||||
class="assignee-chips"
|
||||
>
|
||||
<span
|
||||
v-for="c in coIssuers"
|
||||
:key="c.id"
|
||||
class="chip"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="c.avatarUrl"
|
||||
:name="c.name"
|
||||
/></span>
|
||||
{{ c.name }}
|
||||
<span
|
||||
class="x"
|
||||
@click="removeCoIssuer(c.id)"
|
||||
>×</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="co-issuer-hint">
|
||||
지정하면 원 지시자와 함께 완료된 할 일을 검토(승인·수정요청)할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-foot">
|
||||
{{ isEdit
|
||||
? '변경 사항을 저장하면 담당자에게 반영됩니다.'
|
||||
@@ -2444,4 +2556,20 @@ function goBack() {
|
||||
line-height: 1.6;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* 공동 지시자 블록 — 지시자 카드 안에서 상단 구분선으로 분리 */
|
||||
.co-issuer-block {
|
||||
margin-top: 0.875rem;
|
||||
padding-top: 0.875rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.co-label {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.co-issuer-hint {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
line-height: 1.6;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -309,6 +309,20 @@ const me = computed<User>(() => {
|
||||
const isIssuer = computed(
|
||||
() => !!task.value && !!me.value.id && task.value.issuer.id === me.value.id,
|
||||
)
|
||||
// 공동 지시자 — 원 지시자와 함께 완료 검토(승인/수정요청) 권한을 가진다(편집 권한은 없음)
|
||||
const isCoIssuer = computed(
|
||||
() =>
|
||||
!!task.value &&
|
||||
!!me.value.id &&
|
||||
task.value.coIssuers.some((c) => c.id === me.value.id),
|
||||
)
|
||||
// 검토자 = 원 지시자 또는 공동 지시자. 검토 UI/보고 열람 권한은 이 값으로 판단
|
||||
const isReviewer = computed(() => isIssuer.value || isCoIssuer.value)
|
||||
// 실제 검토(승인/수정요청)를 수행한 사람 이름 — 완료/수정요청 카드 표기용.
|
||||
// 공동 지시자가 검토했으면 그 이름을, 없으면(레거시 데이터) 원 지시자로 대체.
|
||||
const reviewerName = computed(
|
||||
() => task.value?.reviewer?.name ?? task.value?.issuer.name ?? '',
|
||||
)
|
||||
const isAssignee = computed(
|
||||
() => !!task.value && task.value.assignees.some((a) => a.id === me.value.id),
|
||||
)
|
||||
@@ -346,8 +360,8 @@ const CP_STATE_LABEL: Record<CheckpointState, string> = {
|
||||
reported: '보고 완료',
|
||||
missed: '누락',
|
||||
}
|
||||
// 보고 '내용' 열람 권한 — 지시자 또는 해당 업무 담당자(백엔드도 동일하게 제한)
|
||||
const canViewReports = computed(() => isIssuer.value || isAssignee.value)
|
||||
// 보고 '내용' 열람 권한 — 지시자·공동 지시자 또는 해당 업무 담당자(백엔드도 동일하게 제한)
|
||||
const canViewReports = computed(() => isReviewer.value || isAssignee.value)
|
||||
|
||||
// 체크포인트 상태 — 보고됨 / 당일 / 지남(누락) / 예정. 보고됨은 reported 플래그(전원 공유) 기준
|
||||
function checkpointState(cp: TaskCheckpointView): CheckpointState {
|
||||
@@ -996,9 +1010,9 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
|
||||
<!-- 사이드바 -->
|
||||
<aside class="side">
|
||||
<!-- 지시자: 승인 대기(review) → 승인/수정요청 -->
|
||||
<!-- 지시자·공동 지시자: 승인 대기(review) → 승인/수정요청 -->
|
||||
<div
|
||||
v-if="task.status === 'review' && isIssuer"
|
||||
v-if="task.status === 'review' && isReviewer"
|
||||
class="card ap-card review"
|
||||
>
|
||||
<div class="ap-card-head">
|
||||
@@ -1139,7 +1153,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
<span class="ap-card-title">수정 요청됨</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
지시자 <b>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 후 다시 승인을 요청하세요.
|
||||
지시자 <b>{{ reviewerName }}</b>님이 수정을 요청했습니다. 보완 후 다시 승인을 요청하세요.
|
||||
</div>
|
||||
<div
|
||||
v-if="task.changesNote"
|
||||
@@ -1204,7 +1218,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
<span class="ap-card-title">승인 완료</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
지시자 <b>{{ task.issuer.name }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
|
||||
지시자 <b>{{ reviewerName }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1362,6 +1376,27 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
/></span>
|
||||
<span><div class="nm">{{ task.issuer.name }}</div><div class="rl">{{ task.issuer.role }}</div></span>
|
||||
</div>
|
||||
|
||||
<!-- 공동 지시자 — 있을 때만 노출. 원 지시자와 함께 검토 권한을 가진다 -->
|
||||
<div
|
||||
v-if="task.coIssuers.length"
|
||||
class="co-issuers"
|
||||
>
|
||||
<div class="co-issuers-label">
|
||||
공동 지시자
|
||||
</div>
|
||||
<div
|
||||
v-for="c in task.coIssuers"
|
||||
:key="c.id"
|
||||
class="issuer"
|
||||
>
|
||||
<span class="avatar"><UserAvatar
|
||||
:url="c.avatarUrl"
|
||||
:name="c.name"
|
||||
/></span>
|
||||
<span><div class="nm">{{ c.name }}</div><div class="rl">{{ c.role }}</div></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
@@ -3265,6 +3300,21 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 공동 지시자 — 지시자 아래 구분선으로 분리, 여러 명 세로 나열 */
|
||||
.co-issuers {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.co-issuers-label {
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 승인 요청 카드 */
|
||||
.ap-card {
|
||||
padding: 1rem 1.125rem 1.125rem;
|
||||
|
||||
@@ -93,6 +93,24 @@ function toView(n: ApiNotification): NotificationView {
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 ${body}`
|
||||
break
|
||||
}
|
||||
case 'task.due_soon': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
const label = due ? escapeHtml(taskDueInfo(due, false).dateLabel) : ''
|
||||
tone = 'amber'
|
||||
html = label
|
||||
? `<b>${taskTitle}</b> 업무의 마감이 다가옵니다 (마감 <b>${label}</b>)`
|
||||
: `<b>${taskTitle}</b> 업무의 마감이 다가옵니다`
|
||||
break
|
||||
}
|
||||
case 'task.overdue': {
|
||||
const due = pstr(p, 'dueDate')
|
||||
const label = due ? escapeHtml(taskDueInfo(due, false).dateLabel) : ''
|
||||
tone = 'red'
|
||||
html = label
|
||||
? `<b>${taskTitle}</b> 업무가 마감기한을 넘겼습니다 (마감 <b>${label}</b>)`
|
||||
: `<b>${taskTitle}</b> 업무가 마감기한을 넘겼습니다`
|
||||
break
|
||||
}
|
||||
case 'task.checkpoint_reported':
|
||||
tone = 'blue'
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b>의 중간 점검 상태를 보고했습니다`
|
||||
|
||||
@@ -192,6 +192,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
activities: api.activities.map(toTaskActivityView),
|
||||
assignees: api.assignees.map(toUserView),
|
||||
issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER,
|
||||
coIssuers: (api.coIssuers ?? []).map(toUserView),
|
||||
dueLabel: due.dateLabel,
|
||||
dday: due.dday,
|
||||
dueAt: api.dueDate,
|
||||
@@ -206,6 +207,7 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
}
|
||||
: undefined,
|
||||
changesNote: api.changesNote ?? undefined,
|
||||
reviewer: api.reviewer ? toUserView(api.reviewer) : null,
|
||||
issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`,
|
||||
checkpoints: api.checkpoints.map((c) => ({
|
||||
id: c.id,
|
||||
|
||||
@@ -11,6 +11,8 @@ export type NotificationType =
|
||||
| 'task.changes_requested'
|
||||
| 'task.started'
|
||||
| 'task.due_changed'
|
||||
| 'task.due_soon'
|
||||
| 'task.overdue'
|
||||
| 'task.checkpoint_reported'
|
||||
| 'task.removed'
|
||||
| 'task.commented'
|
||||
|
||||
@@ -110,6 +110,8 @@ export interface ApiTaskDetail {
|
||||
checklist: ApiChecklistItem[]
|
||||
assignees: ApiMember[]
|
||||
issuer: ApiMember | null
|
||||
// 공동 지시자 — 원 지시자와 함께 완료 검토 권한을 가진다
|
||||
coIssuers: ApiMember[]
|
||||
dueDate: string | null
|
||||
createdAt: string
|
||||
comments: ApiComment[]
|
||||
@@ -117,6 +119,8 @@ export interface ApiTaskDetail {
|
||||
files: ApiAttachment[]
|
||||
approval: ApiApproval | null
|
||||
changesNote: string | null
|
||||
// 최근 검토(승인/수정요청)를 수행한 사용자 — 완료/수정요청 카드의 검토자 표기용
|
||||
reviewer: ApiMember | null
|
||||
checkpoints: ApiCheckpoint[]
|
||||
checkpointReports: ApiCheckpointReport[]
|
||||
}
|
||||
@@ -153,6 +157,8 @@ export interface CreateTaskPayload {
|
||||
title: string
|
||||
content?: string[]
|
||||
assigneeIds: string[]
|
||||
// 공동 지시자 사용자 ID(선택)
|
||||
coIssuerIds?: string[]
|
||||
dueDate?: string
|
||||
checklist?: { text: string; category?: ChecklistCategory }[]
|
||||
// 중간 점검일(ISO8601 목록)
|
||||
@@ -164,6 +170,8 @@ export interface UpdateTaskPayload {
|
||||
title?: string
|
||||
content?: string[]
|
||||
assigneeIds?: string[]
|
||||
// 공동 지시자 사용자 ID(제공 시 전체 교체)
|
||||
coIssuerIds?: string[]
|
||||
dueDate?: string | null
|
||||
// 체크리스트 전체 동기화(id 있으면 기존 유지, 없으면 신규, 빠지면 삭제)
|
||||
checklist?: { id?: string; text: string; category?: ChecklistCategory }[]
|
||||
|
||||
Reference in New Issue
Block a user