feat: 업무(Task) 모듈 4단계 — CRUD·상태머신·진행률 집계·역할 기반 상세 UI
백엔드
- Task/ChecklistItem 엔티티(저장소 내 순번 seq, jsonb content, assignees ManyToMany, checklist)
- GET/POST/PATCH/DELETE /repos/:repoId/tasks[/:taskId] + POST .../status
- 상태 머신(todo→prog→review→done, review→changes, changes→{review,prog}) + 전이별 역할 검증
- 담당자=저장소 멤버만 지정, 승인 완료(→done) 시 체크리스트 일괄 완료(영속)
- 저장소 doneCount/totalCount·멤버 taskCount 실집계(RepoModule→TaskModule)
- Task.repo FK 컬럼명 snake_case 고정(@JoinColumn), task.service 단위 테스트
프론트
- types/task·useTask·task.store, format.ts 라벨 파생(상태/마감/dday)
- RepoDetailPage 업무 목록, TaskCreatePage 생성/수정 겸용(편집 라우트)
- TaskDetailPage 역할 기반 액션 분기(지시자 승인/수정요청, 담당자 시작/승인요청)
- 액션 카드·상태 뱃지 디자인 시안 정렬, 작성폼/목록 UI 정리
문서: api-contract·integration-progress 4단계 반영
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -5,14 +5,17 @@ import { useRoute, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import RepoHeader from '@/components/RepoHeader.vue'
|
||||
import RepoTabs from '@/components/RepoTabs.vue'
|
||||
import GiteaRepoBar from '@/components/GiteaRepoBar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { findRepoTasks, type Repo, type TaskStatus } from '@/mock/relay.mock'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import type { Repo, TaskStatus } from '@/mock/relay.mock'
|
||||
|
||||
type Segment = 'all' | 'prog' | 'todo' | 'done'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
// 저장소 헤더/진행률 — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
@@ -23,10 +26,25 @@ async function loadRepo() {
|
||||
repo.value = null // 404/403 등은 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(repoId, loadRepo, { immediate: true })
|
||||
|
||||
// 업무 목록 — 4단계(업무 API)에서 교체 예정, 현재는 목업
|
||||
const tasks = computed(() => findRepoTasks(repoId.value))
|
||||
// 업무 목록 — API 연동
|
||||
async function loadTasks() {
|
||||
try {
|
||||
await taskStore.fetchList(repoId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(
|
||||
repoId,
|
||||
() => {
|
||||
loadRepo()
|
||||
loadTasks()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const tasks = computed(() => taskStore.tasks)
|
||||
const memberCount = computed(() =>
|
||||
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
|
||||
)
|
||||
@@ -121,6 +139,13 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</template>
|
||||
</RepoHeader>
|
||||
|
||||
<!-- Gitea 클론 주소/열기 (연동 시에만 표시) -->
|
||||
<GiteaRepoBar
|
||||
:clone-url="repo.cloneUrl"
|
||||
:html-url="repo.htmlUrl"
|
||||
class="gitea-bar-spacing"
|
||||
/>
|
||||
|
||||
<!-- 서브탭(공통) -->
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
@@ -189,10 +214,41 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</svg>
|
||||
마감 임박순
|
||||
</div>
|
||||
<RouterLink
|
||||
class="btn primary new-task"
|
||||
:to="`/repos/${repo.id}/tasks/new`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
새 업무
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<!-- 업무 목록 -->
|
||||
<div class="list">
|
||||
<div
|
||||
v-if="taskStore.loading && tasks.length === 0"
|
||||
class="state-msg"
|
||||
>
|
||||
업무를 불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredTasks.length === 0"
|
||||
class="state-msg"
|
||||
>
|
||||
{{ tasks.length === 0 ? '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' : '조건에 맞는 업무가 없습니다.' }}
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="list"
|
||||
>
|
||||
<RouterLink
|
||||
v-for="task in filteredTasks"
|
||||
:key="task.id"
|
||||
@@ -334,6 +390,9 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0.75rem;
|
||||
}
|
||||
.gitea-bar-spacing {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
|
||||
.prog-strip {
|
||||
@@ -376,6 +435,16 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
.toolbar .search {
|
||||
width: 15rem;
|
||||
}
|
||||
.new-task {
|
||||
/* .sort 의 margin-left:auto 가 오른쪽 그룹을 밀어내므로 여기엔 auto 를 두지 않는다
|
||||
(이중 auto 는 여유 공간을 분배해 필터/정렬 버튼이 떨어져 보이는 원인) */
|
||||
border: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
.new-task svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
|
||||
/* 업무 행 */
|
||||
.task-row {
|
||||
@@ -472,4 +541,13 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.state-msg {
|
||||
padding: 2.5rem 1.125rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,9 @@ onMounted(() => {
|
||||
void repoStore.fetchCreateMeta()
|
||||
})
|
||||
|
||||
// Gitea 연동 여부 — 저장소 중 하나라도 htmlUrl 이 있으면 연동된 것으로 간주
|
||||
const giteaConnected = computed(() => repos.value.some((r) => !!r.htmlUrl))
|
||||
|
||||
// 검색어 + 공개여부 필터 적용
|
||||
const filteredRepos = computed(() =>
|
||||
repos.value.filter((repo) => {
|
||||
@@ -43,7 +46,10 @@ const filteredRepos = computed(() =>
|
||||
<div class="pagehead">
|
||||
<h1>저장소</h1>
|
||||
<span class="count-pill">{{ repos.length }}</span>
|
||||
<span class="gitea-badge">
|
||||
<span
|
||||
v-if="giteaConnected"
|
||||
class="gitea-badge"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -201,6 +207,25 @@ const filteredRepos = computed(() =>
|
||||
</svg>
|
||||
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="repo.giteaMissing"
|
||||
class="missing-badge"
|
||||
title="Gitea 조직에서 원격 저장소를 찾을 수 없습니다."
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
|
||||
<path d="M15 7h2a5 5 0 0 1 0 10h-2" />
|
||||
<path d="m2 2 20 20" />
|
||||
</svg>
|
||||
원격 없음
|
||||
</span>
|
||||
</div>
|
||||
<div class="repo-desc">
|
||||
{{ repo.desc }}
|
||||
@@ -261,9 +286,6 @@ const filteredRepos = computed(() =>
|
||||
:style="{ width: repo.progressPct + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<div class="stat-pct">
|
||||
{{ repo.progressLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="chev">
|
||||
@@ -447,11 +469,6 @@ const filteredRepos = computed(() =>
|
||||
.repo-stat .pbar {
|
||||
width: 9.75rem;
|
||||
}
|
||||
.stat-pct {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.chev {
|
||||
color: var(--text-3);
|
||||
flex-shrink: 0;
|
||||
@@ -468,4 +485,24 @@ const filteredRepos = computed(() =>
|
||||
color: var(--text-3);
|
||||
font-size: 0.844rem;
|
||||
}
|
||||
|
||||
/* 원격(Gitea) 없음 배지 */
|
||||
.missing-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--red);
|
||||
background: var(--red-weak);
|
||||
border: 1px solid var(--red-border);
|
||||
padding: 0.125rem 0.4375rem;
|
||||
border-radius: 20px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.missing-badge svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,38 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
// 업무 상세 — 담당자(작업자) 관점. state 쿼리로 3가지 상태를 분기한다.
|
||||
// - request : 진행 중 → 지시자에게 승인 요청
|
||||
// - changes : 수정 요청됨 → 다시 승인 요청
|
||||
// - approved: 승인 완료
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
// 업무 상세 — 담당자(작업자) 관점. 실제 task.status 로 카드/액션을 분기한다.
|
||||
// - todo : 업무 시작(todo→prog)
|
||||
// - prog : 승인 요청(prog→review)
|
||||
// - review: 승인 대기 중(액션 없음)
|
||||
// - changes: 다시 승인 요청(changes→review)
|
||||
// - done : 승인 완료
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { TASK_DETAIL } from '@/mock/relay.mock'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import type { TaskDetail } from '@/mock/relay.mock'
|
||||
|
||||
// 카드 스타일 분기용 뷰 상태(승인 요청 / 수정 요청 / 승인 완료)
|
||||
type ViewState = 'request' | 'changes' | 'approved'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const task = TASK_DETAIL
|
||||
const taskId = computed(() => Number(route.params.taskId))
|
||||
|
||||
// 쿼리에서 상태 결정(미지정/오류 시 request)
|
||||
const state = computed<ViewState>(() => {
|
||||
const s = String(route.query.state ?? 'request')
|
||||
return s === 'changes' || s === 'approved' ? s : 'request'
|
||||
const taskStore = useTaskStore()
|
||||
|
||||
// 업무 상세 — API 연동
|
||||
const task = ref<TaskDetail | null>(null)
|
||||
async function loadTask() {
|
||||
try {
|
||||
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
|
||||
} catch {
|
||||
task.value = null
|
||||
}
|
||||
}
|
||||
watch(taskId, loadTask, { immediate: true })
|
||||
|
||||
// 실제 상태 → 카드 뷰 상태 매핑 (todo/prog/review → request 카드)
|
||||
const viewState = computed<ViewState>(() => {
|
||||
const s = task.value?.status
|
||||
if (s === 'done') return 'approved'
|
||||
if (s === 'changes') return 'changes'
|
||||
return 'request'
|
||||
})
|
||||
|
||||
// 상태별 표시 구성
|
||||
const CONFIG: Record<ViewState, { badge: string; badgeLabel: string; statusLabel: string }> = {
|
||||
request: { badge: 'prog', badgeLabel: '진행 중', statusLabel: '진행 중' },
|
||||
changes: { badge: 'changes', badgeLabel: '수정 요청', statusLabel: '수정 요청' },
|
||||
approved: { badge: 'done', badgeLabel: '완료', statusLabel: '완료' },
|
||||
}
|
||||
const cfg = computed(() => CONFIG[state.value])
|
||||
|
||||
// 완료 상태면 체크리스트 전부 완료로 표시
|
||||
const checklist = computed(() =>
|
||||
state.value === 'approved' ? task.checklist.map((c) => ({ ...c, done: true })) : task.checklist,
|
||||
)
|
||||
const checklist = computed(() => task.value?.checklist ?? [])
|
||||
const checklistDone = computed(() => checklist.value.filter((c) => c.done).length)
|
||||
const checklistPct = computed(() =>
|
||||
checklist.value.length ? Math.round((checklistDone.value / checklist.value.length) * 100) : 0,
|
||||
@@ -44,9 +52,32 @@ const showDelete = ref(false)
|
||||
const showRequest = ref(false)
|
||||
const commentText = ref('')
|
||||
|
||||
function confirmDelete() {
|
||||
// 상태 전이 — 담당자: 시작(todo→prog) / 승인요청(prog→review) / 재요청(changes→review)
|
||||
async function changeTo(status: TaskDetail['status']) {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
async function startTask() {
|
||||
await changeTo('prog')
|
||||
}
|
||||
async function submitApprovalRequest() {
|
||||
showRequest.value = false
|
||||
await changeTo('review')
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!task.value) return
|
||||
showDelete.value = false
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
try {
|
||||
await taskStore.remove(repoId.value, task.value.id)
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
@@ -78,7 +109,10 @@ function renderMention(text: string): string {
|
||||
<AppShell>
|
||||
<!-- 본문 v-html 은 내부에서 생성한 신뢰 마크업(멘션 강조)만 렌더한다 -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div class="page">
|
||||
<div
|
||||
v-if="task"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
@@ -109,8 +143,8 @@ function renderMention(text: string): string {
|
||||
<div class="ph-titlerow">
|
||||
<span
|
||||
class="st-badge"
|
||||
:class="cfg.badge"
|
||||
>{{ cfg.badgeLabel }}</span>
|
||||
:class="task.status"
|
||||
>{{ task.statusLabel }}</span>
|
||||
<span class="ph-tid">#{{ task.id }}</span>
|
||||
</div>
|
||||
<h1 class="ph-title">
|
||||
@@ -120,7 +154,7 @@ function renderMention(text: string): string {
|
||||
<div class="actions">
|
||||
<RouterLink
|
||||
class="btn"
|
||||
:to="`/repos/${repoId}/tasks/new`"
|
||||
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -352,12 +386,12 @@ function renderMention(text: string): string {
|
||||
<!-- 상태별 승인 카드 -->
|
||||
<div
|
||||
class="card ap-card"
|
||||
:class="state"
|
||||
:class="viewState"
|
||||
>
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico">
|
||||
<svg
|
||||
v-if="state === 'request'"
|
||||
v-if="viewState === 'request'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -366,7 +400,7 @@ function renderMention(text: string): string {
|
||||
stroke-linejoin="round"
|
||||
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
||||
<svg
|
||||
v-else-if="state === 'changes'"
|
||||
v-else-if="viewState === 'changes'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -384,12 +418,34 @@ function renderMention(text: string): string {
|
||||
stroke-linejoin="round"
|
||||
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><path d="M22 4 12 14.01l-3-3" /></svg>
|
||||
</span>
|
||||
<span class="ap-card-title">{{ state === 'request' ? '지시자에게 승인 요청' : state === 'changes' ? '수정 요청됨' : '승인 완료' }}</span>
|
||||
<span class="ap-card-title">{{ viewState === 'request' ? '내 작업' : viewState === 'changes' ? '수정 요청됨' : '승인 완료' }}</span>
|
||||
</div>
|
||||
|
||||
<template v-if="state === 'request'">
|
||||
<!-- 승인 대기 중 (이미 요청 보냄) -->
|
||||
<template v-if="task.status === 'review'">
|
||||
<div class="ap-card-meta">
|
||||
지시자에게 승인을 요청했습니다. 검토 결과를 기다리는 중입니다.
|
||||
</div>
|
||||
</template>
|
||||
<!-- 할 일 → 업무 시작 -->
|
||||
<template v-else-if="task.status === 'todo'">
|
||||
<div class="ap-card-desc">
|
||||
작업을 마쳤다면 지시자 <b>정태경</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
|
||||
아직 시작하지 않은 업무입니다. 작업을 시작하면 상태가 <b>진행 중</b>으로 바뀝니다.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@click="startTask"
|
||||
>
|
||||
업무 시작
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 진행 중 → 승인 요청 -->
|
||||
<template v-else-if="task.status === 'prog'">
|
||||
<div class="ap-card-desc">
|
||||
작업을 마쳤다면 지시자에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
@@ -409,13 +465,10 @@ function renderMention(text: string): string {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="state === 'changes'">
|
||||
<!-- 수정 요청됨 → 다시 승인 요청 -->
|
||||
<template v-else-if="task.status === 'changes'">
|
||||
<div class="ap-card-meta">
|
||||
<b>정태경</b>님이 수정을 요청했습니다 · 1시간 전
|
||||
</div>
|
||||
<div class="ap-card-note">
|
||||
세로형 카피 가독성이 낮으니 자간과 대비를 조정해주세요. 모바일 적응형까지 포함해 다시 올려주세요.
|
||||
지시자가 수정을 요청했습니다. 보완 후 다시 승인을 요청하세요.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
@@ -435,10 +488,10 @@ function renderMention(text: string): string {
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 완료 -->
|
||||
<template v-else>
|
||||
<div class="ap-card-meta">
|
||||
<b>정태경</b>님이 검토를 승인했습니다 · 방금 · 해야 할 일이 모두 완료되었습니다.
|
||||
지시자가 검토를 승인했습니다. 업무가 완료되었습니다.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -450,8 +503,8 @@ function renderMention(text: string): string {
|
||||
</div>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="state"
|
||||
><span class="dot" />{{ cfg.statusLabel }}</span>
|
||||
:class="task.status"
|
||||
><span class="dot" />{{ task.statusLabel }}</span>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
@@ -496,7 +549,10 @@ function renderMention(text: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div
|
||||
v-if="task.files.length"
|
||||
class="card side-card"
|
||||
>
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
첨부파일 · {{ task.files.length }}개
|
||||
@@ -538,6 +594,16 @@ function renderMention(text: string): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩/없음 상태 -->
|
||||
<div
|
||||
v-else
|
||||
class="page"
|
||||
>
|
||||
<p class="task-empty">
|
||||
업무를 불러오는 중이거나 찾을 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 모달 -->
|
||||
<Teleport to="body">
|
||||
<!-- 삭제 -->
|
||||
@@ -566,7 +632,7 @@ function renderMention(text: string): string {
|
||||
업무를 삭제할까요?
|
||||
</h2>
|
||||
<p class="modal-desc">
|
||||
<b>#{{ task.id }} {{ task.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
|
||||
<b>#{{ task?.id }} {{ task?.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
@@ -622,7 +688,7 @@ function renderMention(text: string): string {
|
||||
<button
|
||||
class="mbtn request-send"
|
||||
type="button"
|
||||
@click="showRequest = false"
|
||||
@click="submitApprovalRequest"
|
||||
>
|
||||
요청 보내기
|
||||
</button>
|
||||
@@ -637,6 +703,11 @@ function renderMention(text: string): string {
|
||||
.crumb {
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
.task-empty {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.pagehead {
|
||||
|
||||
@@ -1,40 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
// 새 업무 작성 — 제목/내용/체크리스트 + 사이드(담당자/마감/첨부/지시자)
|
||||
import { computed, ref } from 'vue'
|
||||
// 업무 작성/수정 — 제목/내용/체크리스트 + 사이드(담당자/마감/지시자) · 백엔드 연동
|
||||
// taskId 파라미터가 있으면 '수정 모드'로 동작(기존 값 미리 채움 + PATCH 저장)
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { findRepo, USERS, type Attachment, type ChecklistItem, type User } from '@/mock/relay.mock'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useMemberStore } from '@/stores/member.store'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useTask } from '@/composables/useTask'
|
||||
import { avatarColor, initialOf } from '@/shared/utils/format'
|
||||
import type { Repo, ChecklistItem, User } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repo = computed(() => findRepo(repoId.value))
|
||||
// 수정 모드 여부 — taskId 파라미터 존재 시 편집
|
||||
const taskId = computed(() => (route.params.taskId ? Number(route.params.taskId) : null))
|
||||
const isEdit = computed(() => taskId.value !== null)
|
||||
|
||||
// 제목
|
||||
const title = ref('3분기 신제품 런칭 캠페인 소재 제작')
|
||||
const repoStore = useRepoStore()
|
||||
const memberStore = useMemberStore()
|
||||
const taskStore = useTaskStore()
|
||||
const authStore = useAuthStore()
|
||||
const taskApi = useTask()
|
||||
|
||||
// 담당자 (칩)
|
||||
const assignees = ref<User[]>([USERS.kim, USERS.park, USERS.lee])
|
||||
// API 멤버 → 화면용 User(이니셜/색상 파생)
|
||||
function toUserView(m: ApiMember): User {
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
role: m.role ?? '',
|
||||
initial: initialOf(m.name),
|
||||
color: avatarColor(m.id),
|
||||
}
|
||||
}
|
||||
|
||||
// ISO → input[type=date] 용 'yyyy-MM-dd'
|
||||
function toDateInput(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
const y = d.getFullYear()
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
return `${y}-${m}-${day}`
|
||||
}
|
||||
|
||||
// 저장소 헤더(빵부스러기) — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
} catch {
|
||||
repo.value = null
|
||||
}
|
||||
}
|
||||
// 담당자 후보 = 저장소 멤버
|
||||
async function loadMembers() {
|
||||
try {
|
||||
await memberStore.fetchList(repoId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
// 수정 모드: 기존 업무 값을 폼에 미리 채운다
|
||||
async function loadTaskForEdit() {
|
||||
if (taskId.value === null) return
|
||||
try {
|
||||
const t = await taskApi.get(repoId.value, taskId.value)
|
||||
title.value = t.title
|
||||
contentText.value = t.content.join('\n')
|
||||
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
|
||||
assignees.value = t.assignees.map(toUserView)
|
||||
checklist.value = t.checklist.map((c) => ({ text: c.text, done: c.done }))
|
||||
} catch {
|
||||
// 404 등은 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(
|
||||
[repoId, taskId],
|
||||
() => {
|
||||
loadRepo()
|
||||
loadMembers()
|
||||
loadTaskForEdit()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 제목 / 내용
|
||||
const title = ref('')
|
||||
const contentText = ref('')
|
||||
|
||||
// 지시자 = 현재 로그인 사용자
|
||||
const issuer = computed<User>(() => {
|
||||
const u = authStore.user
|
||||
return {
|
||||
id: u?.id ?? '',
|
||||
name: u?.name ?? '나',
|
||||
role: u?.role ?? '',
|
||||
initial: initialOf(u?.name ?? '나'),
|
||||
color: avatarColor(u?.id ?? ''),
|
||||
}
|
||||
})
|
||||
|
||||
// --- 담당자 선택 (저장소 멤버 중) ---
|
||||
const assignees = ref<User[]>([])
|
||||
const assigneeQuery = ref('')
|
||||
const showAssigneeDropdown = ref(false)
|
||||
function removeAssignee(id: string) {
|
||||
assignees.value = assignees.value.filter((a) => a.id !== id)
|
||||
}
|
||||
// 아직 선택되지 않은 멤버 후보(이름 부분일치)
|
||||
const assigneeCandidates = computed(() => {
|
||||
const selected = new Set(assignees.value.map((a) => a.id))
|
||||
const kw = assigneeQuery.value.trim()
|
||||
return memberStore.members
|
||||
.filter((m) => !selected.has(m.user.id))
|
||||
.filter((m) => !kw || m.user.name.includes(kw) || m.email.includes(kw))
|
||||
})
|
||||
function addAssignee(userId: string) {
|
||||
const m = memberStore.members.find((x) => x.user.id === userId)
|
||||
if (m && !assignees.value.some((a) => a.id === userId)) {
|
||||
assignees.value.push(m.user)
|
||||
}
|
||||
assigneeQuery.value = ''
|
||||
showAssigneeDropdown.value = false
|
||||
}
|
||||
function onAssigneeBlur() {
|
||||
// 항목은 @mousedown.prevent 로 선택하므로 약간 지연 후 닫는다
|
||||
setTimeout(() => (showAssigneeDropdown.value = false), 120)
|
||||
}
|
||||
|
||||
// 체크리스트
|
||||
const checklist = ref<ChecklistItem[]>([
|
||||
{ text: '캠페인 핵심 메시지 3개 시안 작성', done: true },
|
||||
{ text: '레퍼런스 리서치 및 무드보드 정리', done: true },
|
||||
{ text: '메인 배너 이미지 2종 (가로/세로) 디자인', done: false },
|
||||
{ text: '상세페이지 카피라이팅 및 법무 검토', done: false },
|
||||
{ text: '최종 산출물 공유 드라이브 업로드', done: false },
|
||||
])
|
||||
// --- 마감기한 ---
|
||||
const dueDate = ref('') // yyyy-MM-dd (input[type=date])
|
||||
|
||||
// 체크리스트 (생성 시 항목만 등록 — 완료여부는 항상 false 로 생성)
|
||||
const checklist = ref<ChecklistItem[]>([])
|
||||
const newItem = ref('')
|
||||
const doneCount = computed(() => checklist.value.filter((c) => c.done).length)
|
||||
const progressPct = computed(() =>
|
||||
checklist.value.length ? Math.round((doneCount.value / checklist.value.length) * 100) : 0,
|
||||
)
|
||||
function toggleItem(item: ChecklistItem) {
|
||||
item.done = !item.done
|
||||
}
|
||||
function removeItem(index: number) {
|
||||
checklist.value.splice(index, 1)
|
||||
}
|
||||
@@ -45,25 +152,51 @@ function addItem() {
|
||||
newItem.value = ''
|
||||
}
|
||||
|
||||
// 첨부파일
|
||||
const files = ref<Attachment[]>([
|
||||
{ name: '캠페인_브리프_v2.pdf', size: '2.4 MB', kind: 'pdf' },
|
||||
{ name: '브랜드_가이드라인.pdf', size: '8.1 MB', kind: 'pdf' },
|
||||
{ name: '레퍼런스_이미지.zip', size: '34.7 MB', kind: 'zip' },
|
||||
])
|
||||
function removeFile(index: number) {
|
||||
files.value.splice(index, 1)
|
||||
// --- 제출 ---
|
||||
const submitting = ref(false)
|
||||
const canSubmit = computed(
|
||||
() => !submitting.value && title.value.trim().length > 0 && assignees.value.length > 0,
|
||||
)
|
||||
|
||||
async function submitTask() {
|
||||
if (!canSubmit.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const content = contentText.value
|
||||
.split('\n')
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const assigneeIds = assignees.value.map((a) => a.id)
|
||||
const dueIso = dueDate.value ? new Date(dueDate.value).toISOString() : null
|
||||
|
||||
if (isEdit.value && taskId.value !== null) {
|
||||
// 수정 — 제목/내용/담당자/마감만 갱신(체크리스트 항목 편집은 5단계)
|
||||
const updated = await taskStore.update(repoId.value, taskId.value, {
|
||||
title: title.value.trim(),
|
||||
content,
|
||||
assigneeIds,
|
||||
dueDate: dueIso,
|
||||
})
|
||||
router.push(`/repos/${repoId.value}/tasks/${updated.id}`)
|
||||
} else {
|
||||
const created = await taskStore.create(repoId.value, {
|
||||
title: title.value.trim(),
|
||||
content: content.length ? content : undefined,
|
||||
assigneeIds,
|
||||
dueDate: dueIso ?? undefined,
|
||||
checklist: checklist.value.length
|
||||
? checklist.value.map((c) => ({ text: c.text }))
|
||||
: undefined,
|
||||
})
|
||||
router.push(`/repos/${repoId.value}/tasks/${created.id}`)
|
||||
}
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리(권한/검증 등)
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 파일 종류 → 아이콘 클래스/라벨
|
||||
const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
pdf: { cls: 'fi-pdf', label: 'PDF' },
|
||||
zip: { cls: 'fi-zip', label: 'ZIP' },
|
||||
doc: { cls: 'fi-doc', label: 'DOC' },
|
||||
img: { cls: 'fi-doc', label: 'IMG' },
|
||||
}
|
||||
|
||||
// 취소 / 지시 보내기 — 추후 API 연동, 현재는 저장소 상세로 이동
|
||||
function goBack() {
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
}
|
||||
@@ -87,12 +220,11 @@ function goBack() {
|
||||
{{ repo?.name ?? '저장소' }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
새 업무
|
||||
{{ isEdit ? '수정' : '새 업무' }}
|
||||
</div>
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>새 업무 작성</h1>
|
||||
<span class="draft-tag">임시저장됨 · 방금</span>
|
||||
<h1>{{ isEdit ? '업무 수정' : '새 업무 작성' }}</h1>
|
||||
<div class="actions">
|
||||
<button
|
||||
class="btn ghost"
|
||||
@@ -101,16 +233,11 @@ function goBack() {
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="btn"
|
||||
type="button"
|
||||
>
|
||||
임시저장
|
||||
</button>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="button"
|
||||
@click="goBack"
|
||||
:disabled="!canSubmit"
|
||||
@click="submitTask"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
@@ -125,7 +252,7 @@ function goBack() {
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</svg>
|
||||
지시 보내기
|
||||
{{ submitting ? (isEdit ? '저장 중…' : '전송 중…') : (isEdit ? '저장' : '지시 보내기') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,11 +353,11 @@ function goBack() {
|
||||
><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<p>9월 신제품(라인 클렌저) 정식 출시에 맞춰 사용할 캠페인 소재 일체를 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
|
||||
<p>퍼포먼스 광고용 배너와 자사몰 상세페이지를 우선순위로 진행하고, SNS 콘텐츠는 1차 시안 확정 후 착수합니다. 카피는 "민감 피부 저자극" 메시지를 핵심으로 잡되, 과장 광고 표현은 법무 검토를 반드시 거쳐주세요.</p>
|
||||
<p>각 산출물은 아래 체크리스트 기준으로 관리하며, 진행 상황은 업무 상세에서 코멘트로 공유 바랍니다.</p>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="contentText"
|
||||
class="editor-body"
|
||||
placeholder="업무 배경과 요구사항을 작성하세요. (줄바꿈은 문단으로 저장됩니다)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +387,6 @@ function goBack() {
|
||||
<span
|
||||
class="cbox"
|
||||
:class="{ done: item.done }"
|
||||
@click="toggleItem(item)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -273,6 +399,7 @@ function goBack() {
|
||||
</span>
|
||||
<span class="txt">{{ item.text }}</span>
|
||||
<span
|
||||
v-if="!isEdit"
|
||||
class="row-del"
|
||||
@click="removeItem(index)"
|
||||
>
|
||||
@@ -287,7 +414,10 @@ function goBack() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="add-row">
|
||||
<div
|
||||
v-if="!isEdit"
|
||||
class="add-row"
|
||||
>
|
||||
<span class="plus">+</span>
|
||||
<input
|
||||
v-model="newItem"
|
||||
@@ -296,6 +426,12 @@ function goBack() {
|
||||
@keydown.enter="addItem"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-else
|
||||
class="checklist-note"
|
||||
>
|
||||
체크리스트 항목 편집은 추후 단계에서 지원됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -307,25 +443,15 @@ function goBack() {
|
||||
<span class="req">*</span> 담당자 <span class="muted">· {{ assignees.length }}명</span>
|
||||
</div>
|
||||
<div class="combo">
|
||||
<!-- 입력칸은 고정 — 선택된 담당자는 아래에 표기 -->
|
||||
<div class="combo-field">
|
||||
<span
|
||||
v-for="a in assignees"
|
||||
:key="a.id"
|
||||
class="chip"
|
||||
>
|
||||
<span
|
||||
class="avatar"
|
||||
:class="a.color"
|
||||
>{{ a.initial }}</span>
|
||||
{{ a.name }}
|
||||
<span
|
||||
class="x"
|
||||
@click="removeAssignee(a.id)"
|
||||
>×</span>
|
||||
</span>
|
||||
<input
|
||||
v-model="assigneeQuery"
|
||||
class="combo-input"
|
||||
placeholder="이름 검색…"
|
||||
placeholder="멤버 검색…"
|
||||
autocomplete="off"
|
||||
@focus="showAssigneeDropdown = true"
|
||||
@blur="onAssigneeBlur"
|
||||
>
|
||||
<span class="combo-caret">
|
||||
<svg
|
||||
@@ -338,6 +464,53 @@ function goBack() {
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<!-- 멤버 후보 드롭다운 -->
|
||||
<div
|
||||
v-if="showAssigneeDropdown"
|
||||
class="assignee-dd"
|
||||
>
|
||||
<button
|
||||
v-for="m in assigneeCandidates"
|
||||
:key="m.user.id"
|
||||
type="button"
|
||||
class="assignee-dd-item"
|
||||
@mousedown.prevent="addAssignee(m.user.id)"
|
||||
>
|
||||
<span
|
||||
class="avatar"
|
||||
:class="m.user.color"
|
||||
>{{ m.user.initial }}</span>
|
||||
<span class="ad-nm">{{ m.user.name }}</span>
|
||||
<span class="ad-em">{{ m.email }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="assigneeCandidates.length === 0"
|
||||
class="assignee-dd-msg"
|
||||
>
|
||||
{{ memberStore.members.length === 0 ? '저장소 멤버가 없습니다.' : '추가할 멤버가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 선택된 담당자 칩 (입력칸 아래에 표기) -->
|
||||
<div
|
||||
v-if="assignees.length"
|
||||
class="assignee-chips"
|
||||
>
|
||||
<span
|
||||
v-for="a in assignees"
|
||||
:key="a.id"
|
||||
class="chip"
|
||||
>
|
||||
<span
|
||||
class="avatar"
|
||||
:class="a.color"
|
||||
>{{ a.initial }}</span>
|
||||
{{ a.name }}
|
||||
<span
|
||||
class="x"
|
||||
@click="removeAssignee(a.id)"
|
||||
>×</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -345,65 +518,21 @@ function goBack() {
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
<span class="req">*</span> 마감기한
|
||||
</div>
|
||||
<div class="input">
|
||||
<span class="ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
|
||||
</span>
|
||||
2026년 6월 30일 (화) 18:00
|
||||
</div>
|
||||
<div class="date-meta">
|
||||
<span class="dot" /> 마감까지 15일 남음
|
||||
마감기한 <span class="muted">· 선택</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="dueDate"
|
||||
class="input date-input"
|
||||
type="date"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
첨부파일 <span class="muted">· {{ files.length }}개</span>
|
||||
첨부파일 <span class="muted">· 5단계 예정</span>
|
||||
</div>
|
||||
<div class="files">
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file.name"
|
||||
class="file"
|
||||
>
|
||||
<span
|
||||
class="file-ico"
|
||||
:class="FILE_META[file.kind].cls"
|
||||
>{{ FILE_META[file.kind].label }}</span>
|
||||
<span class="file-info">
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<span class="file-size">{{ file.size }}</span>
|
||||
</span>
|
||||
<span
|
||||
class="x"
|
||||
@click="removeFile(index)"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropzone">
|
||||
파일을 끌어다 놓거나 <b>찾아보기</b>
|
||||
<div class="dropzone is-disabled">
|
||||
파일 첨부는 곧 제공됩니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -414,14 +543,18 @@ function goBack() {
|
||||
지시자
|
||||
</div>
|
||||
<div class="issuer">
|
||||
<span class="avatar av-f">정</span>
|
||||
<span
|
||||
class="avatar"
|
||||
:class="issuer.color"
|
||||
>{{ issuer.initial }}</span>
|
||||
<span>
|
||||
<div class="nm">정태경</div>
|
||||
<div class="rl">마케팅 그룹 리드</div>
|
||||
<div class="nm">{{ issuer.name }}</div>
|
||||
</span>
|
||||
</div>
|
||||
<div class="side-foot">
|
||||
지시를 보내면 담당자 {{ assignees.length }}명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.
|
||||
{{ isEdit
|
||||
? '변경 사항을 저장하면 담당자에게 반영됩니다.'
|
||||
: `지시를 보내면 담당자 ${assignees.length}명에게 알림이 전송되고, 업무가 각자의 ‘내 업무’에 추가됩니다.` }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -452,17 +585,6 @@ function goBack() {
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.draft-tag {
|
||||
font-size: 0.719rem;
|
||||
font-weight: 600;
|
||||
color: var(--amber);
|
||||
background: var(--amber-weak);
|
||||
border: 1px solid var(--amber-border);
|
||||
padding: 0.1875rem 0.5rem;
|
||||
border-radius: 20px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.pagehead .actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
@@ -573,17 +695,22 @@ function goBack() {
|
||||
margin: 0 0.3125rem;
|
||||
}
|
||||
.editor-body {
|
||||
display: block;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: inherit;
|
||||
color: var(--text);
|
||||
min-height: 9.375rem;
|
||||
line-height: 1.65;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
resize: vertical;
|
||||
}
|
||||
.editor-body p {
|
||||
margin-bottom: 0.6875rem;
|
||||
}
|
||||
.editor-body p:last-child {
|
||||
margin-bottom: 0;
|
||||
.editor-body::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.mention {
|
||||
color: var(--accent);
|
||||
@@ -648,7 +775,6 @@ function goBack() {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cbox.done {
|
||||
background: var(--green);
|
||||
@@ -720,6 +846,11 @@ function goBack() {
|
||||
.add-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.checklist-note {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 사이드바 */
|
||||
.side {
|
||||
@@ -762,6 +893,76 @@ function goBack() {
|
||||
.combo {
|
||||
position: relative;
|
||||
}
|
||||
/* 멤버 후보 드롭다운 */
|
||||
.assignee-dd {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5625rem;
|
||||
box-shadow: 0 12px 32px rgba(20, 24, 33, 0.16);
|
||||
padding: 0.375rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.assignee-dd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.4375rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.assignee-dd-item:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.assignee-dd-item .avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 0.625rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ad-nm {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ad-em {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 9rem;
|
||||
}
|
||||
.assignee-dd-msg {
|
||||
padding: 0.625rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.date-input {
|
||||
cursor: pointer;
|
||||
}
|
||||
.dropzone.is-disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.dropzone.is-disabled:hover {
|
||||
border-color: var(--border-strong);
|
||||
background: #fafbfc;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.combo-field {
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
@@ -787,6 +988,13 @@ function goBack() {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
/* 선택된 담당자 칩 — 입력칸 아래에 표기 */
|
||||
.assignee-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -973,11 +1181,6 @@ function goBack() {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.issuer .rl {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.side-foot {
|
||||
font-size: 0.719rem;
|
||||
color: var(--text-3);
|
||||
|
||||
@@ -15,27 +15,45 @@ import {
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import {
|
||||
TASK_DETAIL,
|
||||
USERS,
|
||||
type Attachment,
|
||||
type Comment,
|
||||
type Reply,
|
||||
type TaskDetail,
|
||||
type TaskStatus,
|
||||
type User,
|
||||
} from '@/mock/relay.mock'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { avatarColor, initialOf } from '@/shared/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const task = TASK_DETAIL
|
||||
const taskId = computed(() => Number(route.params.taskId))
|
||||
|
||||
// 상태 배지 클래스(시안의 prog/review/done/changes 와 동일)
|
||||
const statusClass = computed<TaskStatus>(() => task.status)
|
||||
const taskStore = useTaskStore()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// 업무 상세 — API 연동
|
||||
const task = ref<TaskDetail | null>(null)
|
||||
async function loadTask() {
|
||||
try {
|
||||
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
|
||||
} catch {
|
||||
task.value = null // 404 등은 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(taskId, loadTask, { immediate: true })
|
||||
|
||||
// 상태 배지 클래스(prog/review/done/changes)
|
||||
const statusClass = computed<TaskStatus>(() => task.value?.status ?? 'todo')
|
||||
|
||||
// 체크리스트 진행률
|
||||
const checklistDone = computed(() => task.checklist.filter((c) => c.done).length)
|
||||
const checklistPct = computed(() =>
|
||||
task.checklist.length ? Math.round((checklistDone.value / task.checklist.length) * 100) : 0,
|
||||
)
|
||||
const checklistDone = computed(() => task.value?.checklist.filter((c) => c.done).length ?? 0)
|
||||
const checklistPct = computed(() => {
|
||||
const total = task.value?.checklist.length ?? 0
|
||||
return total ? Math.round((checklistDone.value / total) * 100) : 0
|
||||
})
|
||||
|
||||
// 본문 멘션(@…)을 강조 span 으로 변환
|
||||
function renderMention(text: string): string {
|
||||
@@ -85,10 +103,54 @@ function closeAllOverlays() {
|
||||
moreOpen.value = false
|
||||
}
|
||||
|
||||
// 모달에서 삭제 확정 시 저장소 상세로 이동(목업)
|
||||
function confirmDelete() {
|
||||
// 모달에서 삭제 확정 → DELETE 후 저장소 상세로 이동
|
||||
async function confirmDelete() {
|
||||
if (!task.value) return
|
||||
showDelete.value = false
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
try {
|
||||
await taskStore.remove(repoId.value, task.value.id)
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
// 승인(review→done) / 수정요청(review→changes) — 지시자 액션
|
||||
async function approveTask() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'done')
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
showApprove.value = false
|
||||
}
|
||||
}
|
||||
async function requestChanges() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, 'changes')
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
showChanges.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 담당자 액션 — 업무 시작(todo→prog) / 승인 요청(prog→review) / 다시 승인 요청(changes→review)
|
||||
async function changeTo(status: TaskStatus) {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
function startTask() {
|
||||
void changeTo('prog')
|
||||
}
|
||||
function requestApproval() {
|
||||
void changeTo('review')
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
@@ -96,13 +158,44 @@ function confirmDelete() {
|
||||
* ============================================================ */
|
||||
const activeTab = ref<'comments' | 'activity'>('comments')
|
||||
|
||||
// 댓글은 로컬 상태로 복제해 답글/등록을 반영
|
||||
const comments = reactive<Comment[]>(task.comments.map((c) => ({ ...c, replies: [...c.replies] })))
|
||||
// 댓글 — 5단계 실연동 예정. 현재는 로컬 상태 데모(새로고침 시 비어 있음)
|
||||
const comments = reactive<Comment[]>([])
|
||||
const replyOpen = ref<number | null>(null)
|
||||
const replyText = ref('')
|
||||
const commentText = ref('')
|
||||
|
||||
const me = USERS.jung // 현재 사용자(지시자)
|
||||
// 현재 로그인 사용자 (댓글 작성자)
|
||||
const me = computed<User>(() => {
|
||||
const u = authStore.user
|
||||
return {
|
||||
id: u?.id ?? '',
|
||||
name: u?.name ?? '나',
|
||||
role: u?.role ?? '',
|
||||
initial: initialOf(u?.name ?? '나'),
|
||||
color: avatarColor(u?.id ?? ''),
|
||||
}
|
||||
})
|
||||
|
||||
// 현재 사용자의 이 업무에 대한 역할(액션 분기용)
|
||||
const isIssuer = computed(
|
||||
() => !!task.value && !!me.value.id && task.value.issuer.id === me.value.id,
|
||||
)
|
||||
const isAssignee = computed(
|
||||
() => !!task.value && task.value.assignees.some((a) => a.id === me.value.id),
|
||||
)
|
||||
// 담당자 액션 카드 테마(시안): changes→빨강 / review→앰버 / done→초록 / 그 외→accent
|
||||
const assigneeCardClass = computed(() => {
|
||||
switch (task.value?.status) {
|
||||
case 'changes':
|
||||
return 'changes'
|
||||
case 'review':
|
||||
return 'review'
|
||||
case 'done':
|
||||
return 'done'
|
||||
default:
|
||||
return 'request'
|
||||
}
|
||||
})
|
||||
|
||||
function toggleReply(index: number) {
|
||||
replyOpen.value = replyOpen.value === index ? null : index
|
||||
@@ -112,7 +205,7 @@ function submitReply(index: number) {
|
||||
const text = replyText.value.trim()
|
||||
const target = comments[index]
|
||||
if (!text || !target) return
|
||||
const reply: Reply = { author: me, time: '방금', text }
|
||||
const reply: Reply = { author: me.value, time: '방금', text }
|
||||
target.replies.push(reply)
|
||||
replyText.value = ''
|
||||
replyOpen.value = null
|
||||
@@ -120,7 +213,7 @@ function submitReply(index: number) {
|
||||
function submitComment() {
|
||||
const text = commentText.value.trim()
|
||||
if (!text) return
|
||||
comments.push({ author: me, roleBadge: '지시자', time: '방금', text, replies: [] })
|
||||
comments.push({ author: me.value, roleBadge: '지시자', time: '방금', text, replies: [] })
|
||||
commentText.value = ''
|
||||
}
|
||||
|
||||
@@ -387,7 +480,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="page">
|
||||
<div
|
||||
v-if="task"
|
||||
class="page"
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
@@ -429,7 +525,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<div class="actions">
|
||||
<RouterLink
|
||||
class="btn"
|
||||
:to="`/repos/${repoId}/tasks/new`"
|
||||
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -791,9 +887,9 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
|
||||
<!-- 사이드바 -->
|
||||
<aside class="side">
|
||||
<!-- 승인 요청 카드 -->
|
||||
<!-- 지시자: 승인 대기(review) → 승인/수정요청 -->
|
||||
<div
|
||||
v-if="task.approval"
|
||||
v-if="task.status === 'review' && isIssuer"
|
||||
class="card ap-card review"
|
||||
>
|
||||
<div class="ap-card-head">
|
||||
@@ -814,10 +910,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<span class="ap-card-title">승인 요청 도착</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
<b>{{ task.approval.requester }}</b>님이 작업 완료 후 승인을 요청했습니다 · {{ task.approval.requestedAgo }}
|
||||
</div>
|
||||
<div class="ap-card-note">
|
||||
{{ task.approval.note }}
|
||||
담당자가 작업 완료 후 승인을 요청했습니다. 검토 후 승인하거나 수정을 요청하세요.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
@@ -853,12 +946,173 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 담당자: 시작 / 승인요청 / 재요청 / 대기중 / 완료 -->
|
||||
<div
|
||||
v-else-if="isAssignee"
|
||||
class="card ap-card"
|
||||
:class="assigneeCardClass"
|
||||
>
|
||||
<!-- 할 일 → 업무 시작 -->
|
||||
<template v-if="task.status === 'todo'">
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M5 3 19 12 5 21V3z" /></svg>
|
||||
</span>
|
||||
<span class="ap-card-title">업무 시작</span>
|
||||
</div>
|
||||
<div class="ap-card-desc">
|
||||
아직 시작하지 않은 업무입니다. 작업을 시작하면 상태가 <b>진행 중</b>으로 바뀌고 지시자에게 표시됩니다.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@click="startTask"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M5 3 19 12 5 21V3z" /></svg>
|
||||
업무 시작
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 진행 중 → 승인 요청 -->
|
||||
<template v-else-if="task.status === 'prog'">
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
||||
</span>
|
||||
<span class="ap-card-title">지시자에게 승인 요청</span>
|
||||
</div>
|
||||
<div class="ap-card-desc">
|
||||
작업을 마쳤다면 지시자 <b>{{ task.issuer.name }}</b>님에게 검토 승인을 요청하세요. 승인되면 업무가 완료 처리됩니다.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@click="requestApproval"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
||||
승인 요청 보내기
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 수정 요청됨 → 다시 승인 요청 -->
|
||||
<template v-else-if="task.status === 'changes'">
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 7v6h6" /><path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13" /></svg>
|
||||
</span>
|
||||
<span class="ap-card-title">수정 요청됨</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
지시자 <b>{{ task.issuer.name }}</b>님이 수정을 요청했습니다. 보완 후 다시 승인을 요청하세요.
|
||||
</div>
|
||||
<div class="ap-card-actions">
|
||||
<button
|
||||
class="ap-btn request-send"
|
||||
type="button"
|
||||
@click="requestApproval"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
||||
다시 승인 요청
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 승인 대기 중 -->
|
||||
<template v-else-if="task.status === 'review'">
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 8v4l3 2" /><circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
/></svg>
|
||||
</span>
|
||||
<span class="ap-card-title">승인 대기 중</span>
|
||||
</div>
|
||||
<div class="ap-card-desc">
|
||||
지시자에게 승인을 요청했습니다. 검토 결과를 기다리는 중입니다.
|
||||
</div>
|
||||
</template>
|
||||
<!-- 완료 -->
|
||||
<template v-else>
|
||||
<div class="ap-card-head">
|
||||
<span class="ap-card-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><path d="M22 4 12 14.01l-3-3" /></svg>
|
||||
</span>
|
||||
<span class="ap-card-title">승인 완료</span>
|
||||
</div>
|
||||
<div class="ap-card-meta">
|
||||
지시자 <b>{{ task.issuer.name }}</b>님이 검토를 승인했습니다. 업무가 완료되었습니다.
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
상태
|
||||
</div>
|
||||
<span class="status-badge review"><span class="dot" />{{ task.statusLabel }}</span>
|
||||
<span
|
||||
class="status-badge"
|
||||
:class="task.status"
|
||||
><span class="dot" />{{ task.statusLabel }}</span>
|
||||
</div>
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
@@ -903,7 +1157,10 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card side-card">
|
||||
<div
|
||||
v-if="task.files.length"
|
||||
class="card side-card"
|
||||
>
|
||||
<div class="side-field">
|
||||
<div class="side-label">
|
||||
첨부파일 · {{ task.files.length }}개
|
||||
@@ -945,6 +1202,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 로딩/없음 상태 -->
|
||||
<div
|
||||
v-else
|
||||
class="page"
|
||||
>
|
||||
<p class="task-empty">
|
||||
업무를 불러오는 중이거나 찾을 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- ===== 모달 ===== -->
|
||||
<Teleport to="body">
|
||||
<!-- 삭제 -->
|
||||
@@ -973,7 +1240,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
업무를 삭제할까요?
|
||||
</h2>
|
||||
<p class="modal-desc">
|
||||
<b>#{{ task.id }} {{ task.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
|
||||
<b>#{{ task?.id }} {{ task?.title }}</b> 업무와 체크리스트·댓글·첨부파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
@@ -1021,7 +1288,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
이 업무를 승인할까요?
|
||||
</h2>
|
||||
<p class="modal-desc">
|
||||
<b>{{ task.approval?.requester }}</b>님의 검토 요청을 승인합니다. 업무 상태가 <b>완료</b>로 변경되고 담당자에게 알림이 전송됩니다.
|
||||
검토 요청을 승인합니다. 업무 상태가 <b>완료</b>로 변경되고 담당자에게 알림이 전송됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-foot">
|
||||
@@ -1035,7 +1302,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<button
|
||||
class="mbtn approve"
|
||||
type="button"
|
||||
@click="showApprove = false"
|
||||
@click="approveTask"
|
||||
>
|
||||
승인
|
||||
</button>
|
||||
@@ -1077,7 +1344,7 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
<button
|
||||
class="mbtn danger"
|
||||
type="button"
|
||||
@click="showChanges = false"
|
||||
@click="requestChanges"
|
||||
>
|
||||
수정 요청 보내기
|
||||
</button>
|
||||
@@ -1419,6 +1686,11 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
.crumb {
|
||||
padding: 1rem 0 0;
|
||||
}
|
||||
.task-empty {
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
/* 페이지 헤더 */
|
||||
.pagehead {
|
||||
@@ -2075,6 +2347,30 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
.status-badge.review .dot {
|
||||
background: var(--amber);
|
||||
}
|
||||
.status-badge.todo {
|
||||
color: var(--text-2);
|
||||
background: #f1f2f4;
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
.status-badge.todo .dot {
|
||||
background: var(--text-3);
|
||||
}
|
||||
.status-badge.changes {
|
||||
color: var(--red);
|
||||
background: var(--red-weak);
|
||||
border-color: var(--red-border);
|
||||
}
|
||||
.status-badge.changes .dot {
|
||||
background: var(--red);
|
||||
}
|
||||
.status-badge.done {
|
||||
color: var(--green);
|
||||
background: var(--green-weak);
|
||||
border-color: #cde8d8;
|
||||
}
|
||||
.status-badge.done .dot {
|
||||
background: var(--green);
|
||||
}
|
||||
.assignees {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2209,6 +2505,42 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
border-color: var(--amber-border);
|
||||
background: var(--amber-weak);
|
||||
}
|
||||
/* 담당자 액션 카드(accent 테마) — 디자인 시안의 .ap-card.request */
|
||||
.ap-card.request {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.ap-card.request .ap-card-ico {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
.ap-card.request .ap-card-title {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
/* 수정 요청됨(빨강 테마) */
|
||||
.ap-card.changes {
|
||||
border-color: var(--red-border);
|
||||
background: var(--red-weak);
|
||||
}
|
||||
.ap-card.changes .ap-card-ico {
|
||||
color: var(--red);
|
||||
border-color: var(--red-border);
|
||||
}
|
||||
.ap-card.changes .ap-card-title {
|
||||
color: var(--red);
|
||||
}
|
||||
/* 승인 완료(초록 테마) */
|
||||
.ap-card.done {
|
||||
border-color: #cde8d8;
|
||||
background: var(--green-weak);
|
||||
}
|
||||
.ap-card.done .ap-card-ico {
|
||||
color: var(--green);
|
||||
border-color: #cde8d8;
|
||||
}
|
||||
.ap-card.done .ap-card-title {
|
||||
color: #167a44;
|
||||
}
|
||||
.ap-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2245,6 +2577,16 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ap-card-desc {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.625rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.ap-card-desc b {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ap-card-note {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text);
|
||||
@@ -2295,6 +2637,15 @@ const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
|
||||
.ap-btn.approve:hover {
|
||||
background: #188a4b;
|
||||
}
|
||||
.ap-btn.request-send {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.ap-btn.request-send:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 모달/에이전트 패널은 Teleport 로 body 에 렌더되므로 전역(scoped 미적용) 스타일 사용 -->
|
||||
|
||||
Reference in New Issue
Block a user