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:
@@ -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[]
|
||||
|
||||
@@ -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