feat: 업무 공동 지시자·검토자 표기·마감 임박/초과 알림 추가

- 공동 지시자(coIssuers): 업무에 공동 지시자를 지정해 원 지시자와 함께
  완료 검토(승인/수정요청) 권한을 부여. 생성/수정 UI, 검토 권한 체크,
  보고 열람, 승인요청·시작 알림 수신, '내가 지시한 업무' 목록에 포함.
- 검토자 표기(reviewedBy): 승인 완료/수정 요청 카드에 실제 검토를 수행한
  사람(원 지시자 또는 공동 지시자)을 표기하도록 추적·노출.
- 마감 알림: 매일 오전 9시(KST) cron으로 미완료 업무의 마감 임박(24h 이내)
  ·초과를 담당자에게 1회 알림(task.due_soon/task.overdue). 발송 플래그로
  중복 방지(다중 인스턴스 대비 원자적 선점), 마감 변경 시 초기화.
  웹푸시·인앱 문구 동시 지원(커버리지 스펙 갱신).

DB: task_co_issuers 조인테이블, tasks.reviewed_by,
    tasks.due_soon_notified_at/overdue_notified_at 마이그레이션 추가
    (dev synchronize 자동, 운영 migrationsRun).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-10 00:40:04 +09:00
parent fb0669990d
commit 5ff9bf0df8
17 changed files with 585 additions and 21 deletions
+4
View File
@@ -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[]
+128
View File
@@ -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>
+56 -6
View File
@@ -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;
+18
View File
@@ -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>의 중간 점검 상태를 보고했습니다`
+2
View File
@@ -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,
+2
View File
@@ -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'
+8
View File
@@ -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 }[]