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:
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 헤더 카드 — 상세/멤버/활동/설정 화면이 공유
|
||||
import { computed } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import type { Repo, User } from '@/mock/relay.mock'
|
||||
|
||||
interface Props {
|
||||
@@ -122,25 +121,12 @@ const extra = computed(() => props.moreCount ?? props.repo.moreCount)
|
||||
<!-- 진행률 스트립 등 추가 영역 -->
|
||||
<slot name="extra" />
|
||||
</div>
|
||||
<div class="rh-actions">
|
||||
<slot name="actions">
|
||||
<RouterLink
|
||||
class="btn primary"
|
||||
: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="M5 12h14M12 5v14" />
|
||||
</svg>
|
||||
새 업무
|
||||
</RouterLink>
|
||||
</slot>
|
||||
<!-- 액션 영역 — 페이지가 #actions 슬롯을 제공할 때만 렌더(기본 '새 업무' 버튼 제거) -->
|
||||
<div
|
||||
v-if="$slots.actions"
|
||||
class="rh-actions"
|
||||
>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiRepoTask,
|
||||
ApiTaskDetail,
|
||||
CreateTaskPayload,
|
||||
TaskListQuery,
|
||||
UpdateTaskPayload,
|
||||
} from '@/types/task'
|
||||
import type { TaskStatus } from '@/mock/relay.mock'
|
||||
|
||||
// 업무 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/tasks[/:taskId]
|
||||
export function useTask() {
|
||||
const api = useApi()
|
||||
|
||||
// 업무 목록 (상태/담당자 필터 선택)
|
||||
async function list(repoId: string, query: TaskListQuery = {}): Promise<ApiRepoTask[]> {
|
||||
return (await api.get(`/repos/${repoId}/tasks`, {
|
||||
params: query,
|
||||
})) as unknown as ApiRepoTask[]
|
||||
}
|
||||
|
||||
// 업무 단건 상세 (taskId = 저장소 내 순번)
|
||||
async function get(repoId: string, taskId: number): Promise<ApiTaskDetail> {
|
||||
return (await api.get(`/repos/${repoId}/tasks/${taskId}`)) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
// 업무 생성 (admin)
|
||||
async function create(repoId: string, payload: CreateTaskPayload): Promise<ApiTaskDetail> {
|
||||
return (await api.post(`/repos/${repoId}/tasks`, payload)) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
// 업무 수정 (admin)
|
||||
async function update(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
payload: UpdateTaskPayload,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.patch(
|
||||
`/repos/${repoId}/tasks/${taskId}`,
|
||||
payload,
|
||||
)) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
// 업무 삭제 (admin)
|
||||
async function remove(repoId: string, taskId: number): Promise<void> {
|
||||
await api.delete(`/repos/${repoId}/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
// 상태 변경 (상태 머신)
|
||||
async function changeStatus(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
status: TaskStatus,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/status`, {
|
||||
status,
|
||||
})) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove, changeStatus }
|
||||
}
|
||||
@@ -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 미적용) 스타일 사용 -->
|
||||
|
||||
@@ -62,6 +62,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/relay/TaskCreatePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 업무 수정 — 생성 폼(TaskCreatePage)을 편집 모드로 재사용
|
||||
path: '/repos/:repoId/tasks/:taskId/edit',
|
||||
name: 'task-edit',
|
||||
component: () => import('@/pages/relay/TaskCreatePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/tasks/:taskId',
|
||||
name: 'task-detail',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
|
||||
|
||||
import type { TaskStatus } from '@/mock/relay.mock'
|
||||
|
||||
/**
|
||||
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
|
||||
*/
|
||||
@@ -75,3 +77,68 @@ export function progressOf(done: number, total: number): ProgressInfo {
|
||||
const label = pct === 0 ? '시작 전' : `${pct}% 완료`
|
||||
return { pct, level, label }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 업무(Task) 표시용 파생 값
|
||||
* ============================================================ */
|
||||
|
||||
const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토']
|
||||
|
||||
/** 업무 상태 → 한국어 라벨 */
|
||||
const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
todo: '할 일',
|
||||
prog: '진행 중',
|
||||
review: '승인 대기',
|
||||
done: '완료',
|
||||
changes: '수정 요청',
|
||||
}
|
||||
export function taskStatusLabel(status: TaskStatus): string {
|
||||
return TASK_STATUS_LABEL[status] ?? status
|
||||
}
|
||||
|
||||
/** 'M월 D일' (예: '6월 8일') */
|
||||
export function monthDayKo(input: Date | string): string {
|
||||
const d = typeof input === 'string' ? new Date(input) : input
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return `${d.getMonth() + 1}월 ${d.getDate()}일`
|
||||
}
|
||||
|
||||
/** 업무 마감 정보(원시 dueDate + 완료여부 → 표시 라벨들) */
|
||||
export interface TaskDueInfo {
|
||||
/** 전체 날짜 라벨 (예: '6월 18일 (목)') — 마감 없으면 '' */
|
||||
dateLabel: string
|
||||
/** D-day 라벨 (예: 'D-3', 'D-DAY', '2일 지남') — 마감 없으면 '' */
|
||||
dday: string
|
||||
/** 목록 행 라벨 (완료: '6월 9일 완료' / 진행: 'D-3' / 지남: '2일 지남') */
|
||||
listLabel: string
|
||||
/** 마감 지남 여부(완료 업무는 false) */
|
||||
overdue: boolean
|
||||
}
|
||||
|
||||
/** dueDate(ISO|null) + 완료여부로 마감 표시 정보 계산 */
|
||||
export function taskDueInfo(dueDate: string | null, isDone: boolean): TaskDueInfo {
|
||||
if (!dueDate) {
|
||||
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
|
||||
}
|
||||
const due = new Date(dueDate)
|
||||
if (Number.isNaN(due.getTime())) {
|
||||
return { dateLabel: '', dday: '', listLabel: '', overdue: false }
|
||||
}
|
||||
const dateLabel = `${due.getMonth() + 1}월 ${due.getDate()}일 (${WEEKDAYS_KO[due.getDay()]})`
|
||||
|
||||
// 자정 기준 일 수 차이
|
||||
const startDue = new Date(due.getFullYear(), due.getMonth(), due.getDate())
|
||||
const now = new Date()
|
||||
const startNow = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const days = Math.round((startDue.getTime() - startNow.getTime()) / 86_400_000)
|
||||
|
||||
if (isDone) {
|
||||
return { dateLabel, dday: '', listLabel: `${monthDayKo(due)} 완료`, overdue: false }
|
||||
}
|
||||
if (days < 0) {
|
||||
const passed = `${-days}일 지남`
|
||||
return { dateLabel, dday: passed, listLabel: passed, overdue: true }
|
||||
}
|
||||
const dday = days === 0 ? 'D-DAY' : `D-${days}`
|
||||
return { dateLabel, dday, listLabel: dday, overdue: false }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useTask } from '@/composables/useTask'
|
||||
import {
|
||||
avatarColor,
|
||||
initialOf,
|
||||
monthDayKo,
|
||||
taskDueInfo,
|
||||
taskStatusLabel,
|
||||
} from '@/shared/utils/format'
|
||||
import type {
|
||||
ApiMember,
|
||||
} from '@/types/repo'
|
||||
import type {
|
||||
ApiRepoTask,
|
||||
ApiTaskDetail,
|
||||
CreateTaskPayload,
|
||||
TaskListQuery,
|
||||
UpdateTaskPayload,
|
||||
} from '@/types/task'
|
||||
import type {
|
||||
RepoTask as RepoTaskView,
|
||||
TaskDetail as TaskDetailView,
|
||||
User as UserView,
|
||||
} from '@/mock/relay.mock'
|
||||
|
||||
// API 멤버 → 화면용 User(이니셜/색상 파생)
|
||||
function toUserView(m: ApiMember): UserView {
|
||||
return {
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
role: m.role ?? '',
|
||||
initial: initialOf(m.name),
|
||||
color: avatarColor(m.id),
|
||||
}
|
||||
}
|
||||
|
||||
// 지시자 없음(SET NULL) 대비 폴백 사용자
|
||||
const UNKNOWN_USER: UserView = {
|
||||
id: '',
|
||||
name: '알 수 없음',
|
||||
role: '',
|
||||
initial: '?',
|
||||
color: 'av-a',
|
||||
}
|
||||
|
||||
// API 업무 목록 행 → 화면용 RepoTask
|
||||
function toRepoTaskView(api: ApiRepoTask): RepoTaskView {
|
||||
const due = taskDueInfo(api.dueDate, api.status === 'done')
|
||||
const [, total] = api.checklist
|
||||
return {
|
||||
id: api.id,
|
||||
title: api.title,
|
||||
status: api.status,
|
||||
dueLabel: due.listLabel || undefined,
|
||||
overdue: due.overdue || undefined,
|
||||
checklist: total > 0 ? api.checklist : undefined,
|
||||
comments: api.commentCount > 0 ? api.commentCount : undefined,
|
||||
assignees: api.assignees.map(toUserView),
|
||||
}
|
||||
}
|
||||
|
||||
// API 업무 상세 → 화면용 TaskDetail (comments/activities/files/approval 은 5단계)
|
||||
function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
const due = taskDueInfo(api.dueDate, api.status === 'done')
|
||||
return {
|
||||
id: api.id,
|
||||
repoId: api.repoId,
|
||||
repoName: api.repoName,
|
||||
title: api.title,
|
||||
status: api.status,
|
||||
statusLabel: taskStatusLabel(api.status),
|
||||
content: api.content,
|
||||
checklist: api.checklist.map((c) => ({ text: c.text, done: c.done })),
|
||||
comments: [],
|
||||
activities: [],
|
||||
assignees: api.assignees.map(toUserView),
|
||||
issuer: api.issuer ? toUserView(api.issuer) : UNKNOWN_USER,
|
||||
dueLabel: due.dateLabel,
|
||||
dday: due.dday,
|
||||
files: [],
|
||||
issuedLabel: `${monthDayKo(api.createdAt)} 지시 · #${api.id}`,
|
||||
}
|
||||
}
|
||||
|
||||
// 업무 스토어 — 저장소 업무 목록 + 현재 상세 + CRUD/상태 액션
|
||||
export const useTaskStore = defineStore('task', () => {
|
||||
const tasks = ref<RepoTaskView[]>([])
|
||||
const current = ref<TaskDetailView | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const taskApi = useTask()
|
||||
|
||||
// 목록 조회
|
||||
async function fetchList(repoId: string, query: TaskListQuery = {}): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await taskApi.list(repoId, query)
|
||||
tasks.value = list.map(toRepoTaskView)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 단건 상세 조회
|
||||
async function fetchOne(repoId: string, taskId: number): Promise<TaskDetailView | null> {
|
||||
loading.value = true
|
||||
try {
|
||||
current.value = toTaskDetailView(await taskApi.get(repoId, taskId))
|
||||
return current.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 생성 — 생성된 상세 반환
|
||||
async function create(repoId: string, payload: CreateTaskPayload): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.create(repoId, payload))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
// 수정
|
||||
async function update(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
payload: UpdateTaskPayload,
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.update(repoId, taskId, payload))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
// 삭제
|
||||
async function remove(repoId: string, taskId: number): Promise<void> {
|
||||
await taskApi.remove(repoId, taskId)
|
||||
tasks.value = tasks.value.filter((t) => t.id !== taskId)
|
||||
if (current.value?.id === taskId) current.value = null
|
||||
}
|
||||
|
||||
// 상태 변경
|
||||
async function changeStatus(
|
||||
repoId: string,
|
||||
taskId: number,
|
||||
status: TaskDetailView['status'],
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.changeStatus(repoId, taskId, status))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
return {
|
||||
tasks,
|
||||
current,
|
||||
loading,
|
||||
fetchList,
|
||||
fetchOne,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
changeStatus,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
// 업무 도메인 타입 — 백엔드 TaskService 응답 / DTO 와 1:1
|
||||
|
||||
import type { TaskStatus } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1
|
||||
export interface ApiRepoTask {
|
||||
id: number // 저장소 내 순번(seq)
|
||||
title: string
|
||||
status: TaskStatus
|
||||
dueDate: string | null
|
||||
checklist: [number, number] // [완료, 전체]
|
||||
commentCount: number
|
||||
assignees: ApiMember[]
|
||||
}
|
||||
|
||||
// 체크리스트 항목(API 원시)
|
||||
export interface ApiChecklistItem {
|
||||
id: string
|
||||
text: string
|
||||
done: boolean
|
||||
}
|
||||
|
||||
// 업무 상세(API 원시) — comments/activities/files 는 5단계에서 채움(현재 빈 배열)
|
||||
export interface ApiTaskDetail {
|
||||
id: number
|
||||
repoId: string
|
||||
repoName: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
content: string[]
|
||||
checklist: ApiChecklistItem[]
|
||||
assignees: ApiMember[]
|
||||
issuer: ApiMember | null
|
||||
dueDate: string | null
|
||||
createdAt: string
|
||||
comments: unknown[]
|
||||
activities: unknown[]
|
||||
files: unknown[]
|
||||
}
|
||||
|
||||
// 업무 목록 필터
|
||||
export interface TaskListQuery {
|
||||
status?: TaskStatus
|
||||
assignee?: string
|
||||
}
|
||||
|
||||
// 업무 생성 요청
|
||||
export interface CreateTaskPayload {
|
||||
title: string
|
||||
content?: string[]
|
||||
assigneeIds: string[]
|
||||
dueDate?: string
|
||||
checklist?: { text: string }[]
|
||||
}
|
||||
|
||||
// 업무 수정 요청
|
||||
export interface UpdateTaskPayload {
|
||||
title?: string
|
||||
content?: string[]
|
||||
assigneeIds?: string[]
|
||||
dueDate?: string | null
|
||||
}
|
||||
Reference in New Issue
Block a user