feat: 프론트엔드 프로젝트 우선 구조 재구성 (Phase 2-a)
- 데이터 계층 신설: types/project·useProject·project.store (프로젝트 CRUD·페이지네이션) - useTask/useMember/useActivity 경로를 /projects/:projectId/... 로 전환 - task·my-task·activity 타입/스토어 repoId/repoName → projectId/projectName - 알림 store 라우팅·문구 프로젝트화, 활동 타입 repo.linked/unlinked·project.* 반영 - 라우팅 /projects/... 전면 교체, / → /projects 리다이렉트 - 페이지 rename Repo*→Project* (List/Create/Detail/Settings/Members/Activity) - ProjectCreatePage: Gitea 저장소 폼 → 단순 프로젝트 생성 폼으로 재작성 - RepoHeader→ProjectHeader, RepoTabs→ProjectTabs, AppShell 네비 프로젝트화 - 죽은 파일 삭제: repo.store·useRepo·GiteaRepoBar Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,191 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// Gitea 저장소 바 — 클론 주소 복사 + Gitea 웹에서 열기 (미연동 시 렌더 안 함)
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
interface Props {
|
||||
cloneUrl?: string | null
|
||||
htmlUrl?: string | null
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// 둘 다 없으면 Gitea 미연동 — 표시하지 않음
|
||||
const connected = computed(() => !!(props.cloneUrl || props.htmlUrl))
|
||||
|
||||
const copied = ref(false)
|
||||
let copyTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// 클론 주소 클립보드 복사 — 성공 시 잠시 "복사됨" 표시
|
||||
async function copyClone() {
|
||||
if (!props.cloneUrl) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.cloneUrl)
|
||||
copied.value = true
|
||||
if (copyTimer) clearTimeout(copyTimer)
|
||||
copyTimer = setTimeout(() => (copied.value = false), 1500)
|
||||
} catch {
|
||||
// 클립보드 권한 없음 등 — 무시(주소는 화면에 노출되어 있음)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="connected"
|
||||
class="gitea-bar"
|
||||
>
|
||||
<span class="gb-ico">
|
||||
<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="M8 12h8" />
|
||||
</svg>
|
||||
</span>
|
||||
<code
|
||||
v-if="cloneUrl"
|
||||
class="gb-url"
|
||||
:title="cloneUrl"
|
||||
>{{ cloneUrl }}</code>
|
||||
<span
|
||||
v-else
|
||||
class="gb-url muted"
|
||||
>클론 주소 없음</span>
|
||||
|
||||
<button
|
||||
v-if="cloneUrl"
|
||||
class="gb-btn"
|
||||
type="button"
|
||||
:aria-label="copied ? '복사됨' : '클론 주소 복사'"
|
||||
@click="copyClone"
|
||||
>
|
||||
<svg
|
||||
v-if="!copied"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect
|
||||
x="9"
|
||||
y="9"
|
||||
width="13"
|
||||
height="13"
|
||||
rx="2"
|
||||
/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
{{ copied ? '복사됨' : '복사' }}
|
||||
</button>
|
||||
|
||||
<a
|
||||
v-if="htmlUrl"
|
||||
class="gb-btn open"
|
||||
:href="htmlUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||
<path d="M15 3h6v6M10 14 21 3" />
|
||||
</svg>
|
||||
Gitea에서 열기
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.gitea-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.gb-ico {
|
||||
color: #1b7a3d;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gb-ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.gb-url {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gb-url.muted {
|
||||
font-family: inherit;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.gb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3125rem;
|
||||
height: 2rem;
|
||||
padding: 0 0.6875rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
font-family: inherit;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
}
|
||||
.gb-btn:hover {
|
||||
background: #f7f8fa;
|
||||
color: var(--text);
|
||||
}
|
||||
.gb-btn svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.gb-btn.open {
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.gb-btn.open:hover {
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -1,16 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 헤더 카드 — 상세/멤버/활동/설정 화면이 공유
|
||||
// 프로젝트 헤더 카드 — 상세/멤버/활동/설정 화면이 공유
|
||||
import { computed } from 'vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import type { Repo, User } from '@/mock/relay.mock'
|
||||
import type { Project, User } from '@/mock/relay.mock'
|
||||
|
||||
interface Props {
|
||||
repo: Repo
|
||||
repo: Project
|
||||
/** 메타 우측 텍스트(예: "2시간 전 업데이트", "멤버 5명") */
|
||||
metaText: string
|
||||
/** 아바타 스택 override (기본: repo.members) */
|
||||
/** 아바타 스택 override (기본: project.members) */
|
||||
members?: User[]
|
||||
/** +N 표기 (기본: repo.moreCount) */
|
||||
/** +N 표기 (기본: project.moreCount) */
|
||||
moreCount?: number
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
@@ -72,39 +72,10 @@ const extra = computed(() => props.moreCount ?? props.repo.moreCount)
|
||||
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rh-slug">
|
||||
{{ repo.owner }} / {{ repo.id }}
|
||||
</div>
|
||||
<div class="rh-desc">
|
||||
{{ repo.desc }}
|
||||
</div>
|
||||
<div class="rh-meta">
|
||||
<span class="mi">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line
|
||||
x1="6"
|
||||
y1="3"
|
||||
x2="6"
|
||||
y2="15"
|
||||
/><circle
|
||||
cx="18"
|
||||
cy="6"
|
||||
r="3"
|
||||
/><circle
|
||||
cx="6"
|
||||
cy="18"
|
||||
r="3"
|
||||
/><path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
{{ repo.branch }}
|
||||
</span>
|
||||
<span class="mi avstack">
|
||||
<span
|
||||
v-for="m in avatars"
|
||||
@@ -3,7 +3,7 @@
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
interface Props {
|
||||
repoId: string
|
||||
projectId: string
|
||||
active: 'tasks' | 'members' | 'activity' | 'settings'
|
||||
taskCount: number
|
||||
memberCount: number
|
||||
@@ -16,26 +16,26 @@ defineProps<Props>()
|
||||
<template>
|
||||
<nav class="tabs">
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
:to="`/projects/${projectId}`"
|
||||
:class="{ active: active === 'tasks' }"
|
||||
>
|
||||
<span class="tb-label">업무</span> <span class="tb-count">{{ taskCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}/members`"
|
||||
:to="`/projects/${projectId}/members`"
|
||||
:class="{ active: active === 'members' }"
|
||||
>
|
||||
<span class="tb-label">멤버</span> <span class="tb-count">{{ memberCount }}</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}/activity`"
|
||||
:to="`/projects/${projectId}/activity`"
|
||||
:class="{ active: active === 'activity' }"
|
||||
>
|
||||
<span class="tb-label">활동</span>
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="canManage"
|
||||
:to="`/repos/${repoId}/settings`"
|
||||
:to="`/projects/${projectId}/settings`"
|
||||
:class="{ active: active === 'settings' }"
|
||||
>
|
||||
<span class="tb-label">설정</span>
|
||||
@@ -5,9 +5,9 @@ import type { ApiActivity } from '@/types/activity'
|
||||
export function useActivity() {
|
||||
const api = useApi()
|
||||
|
||||
// 저장소 활동 피드(최신순)
|
||||
async function list(repoId: string): Promise<ApiActivity[]> {
|
||||
return (await api.get(`/repos/${repoId}/activity`)) as unknown as ApiActivity[]
|
||||
// 프로젝트 활동 피드(최신순)
|
||||
async function list(projectId: string): Promise<ApiActivity[]> {
|
||||
return (await api.get(`/projects/${projectId}/activity`)) as unknown as ApiActivity[]
|
||||
}
|
||||
|
||||
return { list }
|
||||
|
||||
@@ -46,7 +46,7 @@ export function useAi() {
|
||||
|
||||
// 할 일(체크리스트) 추천 — 실패는 호출측에서 인라인 처리(silent)
|
||||
async function suggestChecklist(payload: {
|
||||
repoId: string
|
||||
projectId: string
|
||||
title: string
|
||||
content?: string[]
|
||||
}): Promise<SuggestChecklistResult> {
|
||||
|
||||
@@ -6,48 +6,48 @@ import type {
|
||||
} from '@/types/member'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 저장소 멤버 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/members
|
||||
// 프로젝트 멤버 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
// 경로는 모두 projectId 하위: /projects/:projectId/members
|
||||
export function useMember() {
|
||||
const api = useApi()
|
||||
|
||||
// 멤버 목록 (멤버 또는 공개 저장소) — 백엔드가 페이지네이션 { items, total, ... } 반환
|
||||
async function list(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
page = 1,
|
||||
size = 20,
|
||||
): Promise<Paginated<ApiRepoMember>> {
|
||||
return (await api.get(`/repos/${repoId}/members`, {
|
||||
return (await api.get(`/projects/${projectId}/members`, {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiRepoMember>
|
||||
}
|
||||
|
||||
// 멤버 초대 (admin) — 가입된 사용자만 이메일로 초대
|
||||
async function invite(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
payload: InviteMemberPayload,
|
||||
): Promise<ApiRepoMember> {
|
||||
return (await api.post(
|
||||
`/repos/${repoId}/members`,
|
||||
`/projects/${projectId}/members`,
|
||||
payload,
|
||||
)) as unknown as ApiRepoMember
|
||||
}
|
||||
|
||||
// 멤버 역할/직무 변경 (admin, owner 제외)
|
||||
async function update(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
userId: string,
|
||||
payload: UpdateMemberPayload,
|
||||
): Promise<ApiRepoMember> {
|
||||
return (await api.patch(
|
||||
`/repos/${repoId}/members/${userId}`,
|
||||
`/projects/${projectId}/members/${userId}`,
|
||||
payload,
|
||||
)) as unknown as ApiRepoMember
|
||||
}
|
||||
|
||||
// 멤버 제거 (admin, owner 제외)
|
||||
async function remove(repoId: string, userId: string): Promise<void> {
|
||||
await api.delete(`/repos/${repoId}/members/${userId}`)
|
||||
async function remove(projectId: string, userId: string): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/members/${userId}`)
|
||||
}
|
||||
|
||||
return { list, invite, update, remove }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiProject,
|
||||
CreateProjectPayload,
|
||||
UpdateProjectPayload,
|
||||
} from '@/types/project'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 프로젝트 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useProject() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(page = 1, size = 20): Promise<Paginated<ApiProject>> {
|
||||
return (await api.get('/projects', {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiProject>
|
||||
}
|
||||
|
||||
async function get(id: string): Promise<ApiProject> {
|
||||
return (await api.get(`/projects/${id}`)) as unknown as ApiProject
|
||||
}
|
||||
|
||||
async function create(payload: CreateProjectPayload): Promise<ApiProject> {
|
||||
return (await api.post('/projects', payload)) as unknown as ApiProject
|
||||
}
|
||||
|
||||
async function update(
|
||||
id: string,
|
||||
payload: UpdateProjectPayload,
|
||||
): Promise<ApiProject> {
|
||||
return (await api.patch(`/projects/${id}`, payload)) as unknown as ApiProject
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
await api.delete(`/projects/${id}`)
|
||||
}
|
||||
|
||||
return { list, get, create, update, remove }
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useApi } from './useApi'
|
||||
import type {
|
||||
ApiRepo,
|
||||
CreateRepoPayload,
|
||||
RepoCreateMeta,
|
||||
UpdateRepoPayload,
|
||||
} from '@/types/repo'
|
||||
import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
export function useRepo() {
|
||||
const api = useApi()
|
||||
|
||||
async function list(page = 1, size = 20): Promise<Paginated<ApiRepo>> {
|
||||
return (await api.get('/repos', {
|
||||
params: { page, size },
|
||||
})) as unknown as Paginated<ApiRepo>
|
||||
}
|
||||
|
||||
// 저장소 생성 폼 메타(소유자/템플릿) — 생성 전 미리 조회
|
||||
async function meta(): Promise<RepoCreateMeta> {
|
||||
return (await api.get('/repos/meta')) as unknown as RepoCreateMeta
|
||||
}
|
||||
|
||||
// 이름(slug) 사용 가능 여부 — 생성 폼 실시간 체크 (실패해도 전역 알림 X)
|
||||
async function checkName(slug: string): Promise<{ available: boolean }> {
|
||||
return (await api.get('/repos/name-available', {
|
||||
params: { slug },
|
||||
silent: true,
|
||||
})) as unknown as { available: boolean }
|
||||
}
|
||||
|
||||
async function get(id: string): Promise<ApiRepo> {
|
||||
return (await api.get(`/repos/${id}`)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function create(payload: CreateRepoPayload): Promise<ApiRepo> {
|
||||
return (await api.post('/repos', payload)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function update(id: string, payload: UpdateRepoPayload): Promise<ApiRepo> {
|
||||
return (await api.patch(`/repos/${id}`, payload)) as unknown as ApiRepo
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
await api.delete(`/repos/${id}`)
|
||||
}
|
||||
|
||||
return { list, meta, checkName, get, create, update, remove }
|
||||
}
|
||||
@@ -13,56 +13,56 @@ import type { TaskStatus } from '@/mock/relay.mock'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
|
||||
// 업무 도메인 API Composable — 인터셉터가 success/data 를 언래핑
|
||||
// 경로는 모두 repoId(slugName) 하위: /repos/:repoId/tasks[/:taskId]
|
||||
// 경로는 모두 projectId 하위: /projects/:projectId/tasks[/:taskId]
|
||||
export function useTask() {
|
||||
const api = useApi()
|
||||
|
||||
// 업무 목록 (세그먼트/검색/담당자 필터 + 페이지네이션) — 응답에 세그먼트 카운트 동봉
|
||||
async function list(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
query: TaskListQuery = {},
|
||||
page = 1,
|
||||
size = DEFAULT_PAGE_SIZE,
|
||||
): Promise<ApiRepoTaskListResult> {
|
||||
return (await api.get(`/repos/${repoId}/tasks`, {
|
||||
return (await api.get(`/projects/${projectId}/tasks`, {
|
||||
params: { ...query, page, size },
|
||||
})) as unknown as ApiRepoTaskListResult
|
||||
}
|
||||
|
||||
// 업무 단건 상세 (taskId = 저장소 내 순번)
|
||||
async function get(repoId: string, taskId: number): Promise<ApiTaskDetail> {
|
||||
return (await api.get(`/repos/${repoId}/tasks/${taskId}`)) as unknown as ApiTaskDetail
|
||||
// 업무 단건 상세 (taskId = 프로젝트 내 순번)
|
||||
async function get(projectId: string, taskId: number): Promise<ApiTaskDetail> {
|
||||
return (await api.get(`/projects/${projectId}/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
|
||||
async function create(projectId: string, payload: CreateTaskPayload): Promise<ApiTaskDetail> {
|
||||
return (await api.post(`/projects/${projectId}/tasks`, payload)) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
// 업무 수정 (admin)
|
||||
async function update(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
payload: UpdateTaskPayload,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.patch(
|
||||
`/repos/${repoId}/tasks/${taskId}`,
|
||||
`/projects/${projectId}/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 remove(projectId: string, taskId: number): Promise<void> {
|
||||
await api.delete(`/projects/${projectId}/tasks/${taskId}`)
|
||||
}
|
||||
|
||||
// 상태 변경 (상태 머신)
|
||||
async function changeStatus(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
status: TaskStatus,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/status`, {
|
||||
return (await api.post(`/projects/${projectId}/tasks/${taskId}/status`, {
|
||||
status,
|
||||
})) as unknown as ApiTaskDetail
|
||||
}
|
||||
@@ -70,25 +70,25 @@ export function useTask() {
|
||||
/* ===================== 댓글 / 답글 (5단계) ===================== */
|
||||
|
||||
async function addComment(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
text: string,
|
||||
fileId?: string,
|
||||
): Promise<ApiComment> {
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/comments`, {
|
||||
return (await api.post(`/projects/${projectId}/tasks/${taskId}/comments`, {
|
||||
text,
|
||||
...(fileId ? { fileId } : {}),
|
||||
})) as unknown as ApiComment
|
||||
}
|
||||
|
||||
async function addReply(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
commentId: string,
|
||||
text: string,
|
||||
): Promise<ApiCommentReply> {
|
||||
return (await api.post(
|
||||
`/repos/${repoId}/tasks/${taskId}/comments/${commentId}/replies`,
|
||||
`/projects/${projectId}/tasks/${taskId}/comments/${commentId}/replies`,
|
||||
{ text },
|
||||
)) as unknown as ApiCommentReply
|
||||
}
|
||||
@@ -96,52 +96,52 @@ export function useTask() {
|
||||
/* ===================== 파일 첨부 (5단계) ===================== */
|
||||
|
||||
async function uploadFile(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
file: File,
|
||||
): Promise<ApiAttachment> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/files`, form, {
|
||||
return (await api.post(`/projects/${projectId}/tasks/${taskId}/files`, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})) as unknown as ApiAttachment
|
||||
}
|
||||
|
||||
async function removeFile(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
fileId: string,
|
||||
): Promise<void> {
|
||||
await api.delete(`/repos/${repoId}/tasks/${taskId}/files/${fileId}`)
|
||||
await api.delete(`/projects/${projectId}/tasks/${taskId}/files/${fileId}`)
|
||||
}
|
||||
|
||||
/* ===================== 승인 워크플로우 (5단계) ===================== */
|
||||
|
||||
async function requestApproval(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
note?: string,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/approval/request`, {
|
||||
return (await api.post(`/projects/${projectId}/tasks/${taskId}/approval/request`, {
|
||||
...(note ? { note } : {}),
|
||||
})) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
async function approve(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.post(
|
||||
`/repos/${repoId}/tasks/${taskId}/approval/approve`,
|
||||
`/projects/${projectId}/tasks/${taskId}/approval/approve`,
|
||||
)) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
async function requestChanges(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
note: string,
|
||||
): Promise<ApiTaskDetail> {
|
||||
return (await api.post(`/repos/${repoId}/tasks/${taskId}/approval/changes`, {
|
||||
return (await api.post(`/projects/${projectId}/tasks/${taskId}/approval/changes`, {
|
||||
note,
|
||||
})) as unknown as ApiTaskDetail
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ const authStore = useAuthStore()
|
||||
const agentOpen = ref(false)
|
||||
|
||||
// 현재 경로 기준으로 상단 네비 활성 항목을 판별
|
||||
const activeNav = computed<'tasks' | 'repos'>(() =>
|
||||
route.path.startsWith('/repos') ? 'repos' : 'tasks',
|
||||
const activeNav = computed<'tasks' | 'projects'>(() =>
|
||||
route.path.startsWith('/projects') ? 'projects' : 'tasks',
|
||||
)
|
||||
|
||||
// 현재 로그인 사용자(프로필 아바타는 UserAvatar 가 이미지/placeholder 로 표시)
|
||||
@@ -65,7 +65,7 @@ async function onLogout() {
|
||||
<template>
|
||||
<header class="topbar">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="brand"
|
||||
>
|
||||
<div class="logo">
|
||||
@@ -82,10 +82,10 @@ async function onLogout() {
|
||||
내 업무
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
:class="{ active: activeNav === 'repos' }"
|
||||
to="/projects"
|
||||
:class="{ active: activeNav === 'projects' }"
|
||||
>
|
||||
저장소
|
||||
프로젝트
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
|
||||
@@ -71,6 +71,32 @@ export interface Repo {
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
/** 프로젝트 — 작업의 기본 단위(우선). 저장소는 프로젝트에 0~N개 연동된다. */
|
||||
export interface Project {
|
||||
/** 라우팅 식별자(UUID) */
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
visibility: Visibility
|
||||
/** "미분류" 시스템 프로젝트 */
|
||||
isSystem: boolean
|
||||
members: User[]
|
||||
/** 멤버 스택 초과분(+N) */
|
||||
moreCount: number
|
||||
updatedAgo: string
|
||||
/** 연동 저장소 수 */
|
||||
repoCount: number
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
progressPct: number
|
||||
progressLevel: 'done' | 'mid' | 'low'
|
||||
progressLabel: string
|
||||
/** admin 여부 — 단건 조회에서만 채워짐 */
|
||||
canManage?: boolean
|
||||
/** 소유자 여부 — 프로젝트 삭제 권한 */
|
||||
isOwner?: boolean
|
||||
}
|
||||
|
||||
/** 저장소 상세의 업무 목록 행 */
|
||||
export interface RepoTask {
|
||||
id: number
|
||||
@@ -127,8 +153,8 @@ export interface Activity {
|
||||
/** 업무 상세 */
|
||||
export interface TaskDetail {
|
||||
id: number
|
||||
repoId: string
|
||||
repoName: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
statusLabel: string
|
||||
@@ -161,9 +187,9 @@ export interface MyTaskRow {
|
||||
id: number
|
||||
title: string
|
||||
status: TaskStatus
|
||||
repoName: string
|
||||
/** 라우팅용 저장소 식별자(slug) */
|
||||
repoId?: string
|
||||
projectName: string
|
||||
/** 라우팅용 프로젝트 식별자 */
|
||||
projectId?: string
|
||||
/** 담당자 미니 아바타(지시 뷰) */
|
||||
assignees?: User[]
|
||||
/** 보조 텍스트(예: "지시 · 한승우", "정민호", "박지훈님이 2시간 전 승인 요청") */
|
||||
|
||||
@@ -37,7 +37,7 @@ async function onLogin() {
|
||||
password: password.value,
|
||||
keepLogin: keepLogin.value,
|
||||
})
|
||||
await router.push('/repos')
|
||||
await router.push('/projects')
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
|
||||
@@ -17,7 +17,7 @@ onMounted(() => {
|
||||
|
||||
// 행 클릭 → 항상 업무 상세(TaskDetailPage). 역할 기반 액션은 상세에서 분기한다
|
||||
function taskLink(row: MyTaskRow): string {
|
||||
return `/repos/${row.repoId}/tasks/${row.id}`
|
||||
return `/projects/${row.projectId}/tasks/${row.id}`
|
||||
}
|
||||
|
||||
const groups = computed(() => (view.value === 'assigned' ? myTaskStore.assigned : myTaskStore.issued))
|
||||
@@ -225,7 +225,7 @@ function loadMore() {
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
{{ row.repoName }}
|
||||
{{ row.projectName }}
|
||||
</span>
|
||||
<span class="meta-sep" />
|
||||
<span
|
||||
|
||||
+15
-15
@@ -3,35 +3,35 @@
|
||||
import { computed, defineComponent, h, ref, watch } from 'vue'
|
||||
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 ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useActivityStore } from '@/stores/activity.store'
|
||||
import type { Repo, ActivityIcon } from '@/mock/relay.mock'
|
||||
import type { Project, ActivityIcon } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | 'task' | 'member' | 'setting'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const activityStore = useActivityStore()
|
||||
|
||||
// 저장소 헤더 + 활동 피드 로딩
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
async function load() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 등은 인터셉터가 알림 처리
|
||||
}
|
||||
try {
|
||||
await activityStore.fetch(repoId.value)
|
||||
await activityStore.fetch(projectId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
watch(repoId, load, { immediate: true })
|
||||
watch(projectId, load, { immediate: true })
|
||||
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
@@ -86,14 +86,14 @@ const ActIcon = defineComponent({
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
:to="`/projects/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
@@ -102,12 +102,12 @@ const ActIcon = defineComponent({
|
||||
<b>활동</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
<ProjectHeader
|
||||
:repo="repo"
|
||||
:meta-text="repo.updatedAgo"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
<ProjectTabs
|
||||
:project-id="repo.id"
|
||||
active="activity"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
// 새 프로젝트 만들기 — 표시 제목/설명/공개 범위
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const router = useRouter()
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const name = ref('')
|
||||
const description = ref('')
|
||||
const visibility = ref<Visibility>('private')
|
||||
const submitting = ref(false)
|
||||
|
||||
// 생성 — 성공 시 새 프로젝트 상세로 이동
|
||||
async function createProject() {
|
||||
if (submitting.value) return
|
||||
if (!name.value.trim()) {
|
||||
window.alert('프로젝트 이름을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const project = await projectStore.create({
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
visibility: visibility.value,
|
||||
})
|
||||
await router.push(`/projects/${project.id}`)
|
||||
} catch {
|
||||
// 에러는 API 인터셉터에서 전역 처리
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
프로젝트
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
새 프로젝트
|
||||
</div>
|
||||
<div class="pagehead">
|
||||
<h1>새 프로젝트 만들기</h1>
|
||||
</div>
|
||||
<p class="lede">
|
||||
프로젝트를 만들고 업무를 작성해 담당자에게 전달하세요. Gitea 저장소는 프로젝트 생성 후
|
||||
연동할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="card"
|
||||
@submit.prevent="createProject"
|
||||
>
|
||||
<!-- 표시 제목 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 프로젝트 이름
|
||||
</div>
|
||||
<div class="fhint">
|
||||
목록과 화면에 표시되는 이름입니다.
|
||||
</div>
|
||||
<input
|
||||
v-model="name"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: 3분기 신제품 런칭 캠페인"
|
||||
maxlength="60"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 공개 범위 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 공개 범위
|
||||
</div>
|
||||
<div class="radio-group">
|
||||
<button
|
||||
class="radio-card"
|
||||
:class="{ sel: visibility === 'private' }"
|
||||
type="button"
|
||||
@click="visibility = 'private'"
|
||||
>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">비공개</span>
|
||||
<span class="rc-desc">초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio" />
|
||||
</button>
|
||||
<button
|
||||
class="radio-card"
|
||||
:class="{ sel: visibility === 'public' }"
|
||||
type="button"
|
||||
@click="visibility = 'public'"
|
||||
>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">공개</span>
|
||||
<span class="rc-desc">조직 내 모든 구성원이 프로젝트를 보고 업무 현황을 열람할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 설명
|
||||
</div>
|
||||
<div class="fhint">
|
||||
프로젝트의 목적과 범위를 간단히 적어주세요.
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
maxlength="300"
|
||||
placeholder="예: 3분기 신제품 런칭을 위한 콘텐츠·광고 제작"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<div class="form-footer">
|
||||
<RouterLink
|
||||
class="btn ghost"
|
||||
to="/projects"
|
||||
>
|
||||
취소
|
||||
</RouterLink>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
>
|
||||
{{ submitting ? '생성 중…' : '프로젝트 생성' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 47.5rem;
|
||||
}
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0;
|
||||
}
|
||||
.crumb .lnk {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.crumb .sep {
|
||||
color: var(--text-3);
|
||||
margin: 0 0.4375rem;
|
||||
}
|
||||
.pagehead {
|
||||
padding: 0.5625rem 0 0.375rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
}
|
||||
.lede {
|
||||
color: var(--text-2);
|
||||
font-size: 0.844rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
.fblock {
|
||||
padding: 1.375rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fblock:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.flabel .req {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fhint {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.tinput {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.tinput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.tinput::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
textarea.tarea {
|
||||
min-height: 6rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
}
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.radio-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.8125rem;
|
||||
padding: 0.8125rem 0.875rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.radio-card:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
.radio-card.sel {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.rc-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.rc-desc {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.rc-radio {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
.radio-card.sel .rc-radio {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.radio-card.sel .rc-radio::after {
|
||||
content: '';
|
||||
width: 0.5625rem;
|
||||
height: 0.5625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5625rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
border-radius: 0 0 0.625rem 0.625rem;
|
||||
}
|
||||
.btn {
|
||||
height: 2.25rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn.ghost {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.btn.primary {
|
||||
border: none;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
</style>
|
||||
+19
-27
@@ -5,26 +5,25 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
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 ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import type { TaskListQuery, TaskSegment } from '@/types/task'
|
||||
import type { Repo, TaskStatus } from '@/mock/relay.mock'
|
||||
import type { Project, TaskStatus } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const taskStore = useTaskStore()
|
||||
const { tasks, counts, hasMore, loadingMore, loading } = storeToRefs(taskStore)
|
||||
|
||||
// 저장소 헤더/진행률 — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 등은 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -42,7 +41,7 @@ function currentQuery(): TaskListQuery {
|
||||
// 업무 목록 첫 페이지 — 세그먼트/검색 변경 시 리셋 로드
|
||||
async function loadTasks() {
|
||||
try {
|
||||
await taskStore.fetchList(repoId.value, currentQuery())
|
||||
await taskStore.fetchList(projectId.value, currentQuery())
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -51,7 +50,7 @@ async function loadTasks() {
|
||||
// 더보기 — 같은 조건으로 다음 페이지 이어붙이기
|
||||
async function loadMoreTasks() {
|
||||
try {
|
||||
await taskStore.loadMore(repoId.value, currentQuery())
|
||||
await taskStore.loadMore(projectId.value, currentQuery())
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -59,7 +58,7 @@ async function loadMoreTasks() {
|
||||
|
||||
// 저장소 전환 시 헤더 + 목록 리셋 로드
|
||||
watch(
|
||||
repoId,
|
||||
projectId,
|
||||
() => {
|
||||
loadRepo()
|
||||
loadTasks()
|
||||
@@ -108,7 +107,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
@@ -118,7 +117,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</div>
|
||||
|
||||
<!-- 저장소 헤더(공통) — 진행률 스트립을 extra 슬롯으로 주입 -->
|
||||
<RepoHeader
|
||||
<ProjectHeader
|
||||
:repo="repo"
|
||||
:meta-text="repo.updatedAgo"
|
||||
>
|
||||
@@ -150,18 +149,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</RepoHeader>
|
||||
|
||||
<!-- Gitea 클론 주소/열기 (연동 시에만 표시) -->
|
||||
<GiteaRepoBar
|
||||
:clone-url="repo.cloneUrl"
|
||||
:html-url="repo.htmlUrl"
|
||||
class="gitea-bar-spacing"
|
||||
/>
|
||||
</ProjectHeader>
|
||||
|
||||
<!-- 서브탭(공통) -->
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
<ProjectTabs
|
||||
:project-id="repo.id"
|
||||
active="tasks"
|
||||
:task-count="counts.all"
|
||||
:member-count="memberCount"
|
||||
@@ -230,7 +222,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</div>
|
||||
<RouterLink
|
||||
class="btn primary new-task"
|
||||
:to="`/repos/${repo.id}/tasks/new`"
|
||||
:to="`/projects/${repo.id}/tasks/new`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -268,7 +260,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
:key="task.id"
|
||||
class="task-row"
|
||||
:class="{ 'is-done': task.status === 'done' }"
|
||||
:to="`/repos/${repo.id}/tasks/${task.id}`"
|
||||
:to="`/projects/${repo.id}/tasks/${task.id}`"
|
||||
>
|
||||
<span
|
||||
class="st-dot"
|
||||
@@ -437,7 +429,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
|
||||
/* 진행률 스트립 (ProjectHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
|
||||
.prog-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
+30
-82
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
// 저장소 목록 — 조직의 저장소를 검색/필터하여 목록으로 표시
|
||||
// 프로젝트 목록 — 검색/필터하여 목록으로 표시
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | Visibility
|
||||
@@ -13,25 +13,20 @@ type Filter = 'all' | Visibility
|
||||
const keyword = ref('')
|
||||
const filter = ref<Filter>('all')
|
||||
|
||||
const repoStore = useRepoStore()
|
||||
const { repos, loading, owner, total, hasMore, loadingMore } = storeToRefs(repoStore)
|
||||
const projectStore = useProjectStore()
|
||||
const { projects, loading, total, hasMore, loadingMore } = storeToRefs(projectStore)
|
||||
|
||||
onMounted(() => {
|
||||
void repoStore.fetchList()
|
||||
void repoStore.fetchCreateMeta()
|
||||
void projectStore.fetchList()
|
||||
})
|
||||
|
||||
// Gitea 연동 여부 — 저장소 중 하나라도 htmlUrl 이 있으면 연동된 것으로 간주
|
||||
const giteaConnected = computed(() => repos.value.some((r) => !!r.htmlUrl))
|
||||
|
||||
// 검색어 + 공개여부 필터 적용
|
||||
const filteredRepos = computed(() =>
|
||||
repos.value.filter((repo) => {
|
||||
projects.value.filter((project) => {
|
||||
const matchKeyword =
|
||||
!keyword.value ||
|
||||
repo.name.includes(keyword.value) ||
|
||||
repo.slug.includes(keyword.value)
|
||||
const matchFilter = filter.value === 'all' || repo.visibility === filter.value
|
||||
!keyword.value || project.name.includes(keyword.value)
|
||||
const matchFilter =
|
||||
filter.value === 'all' || project.visibility === filter.value
|
||||
return matchKeyword && matchFilter
|
||||
}),
|
||||
)
|
||||
@@ -41,34 +36,15 @@ const filteredRepos = computed(() =>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<b>저장소</b>
|
||||
<b>프로젝트</b>
|
||||
</div>
|
||||
|
||||
<div class="pagehead">
|
||||
<h1>저장소</h1>
|
||||
<h1>프로젝트</h1>
|
||||
<span class="count-pill">{{ total }}</span>
|
||||
<span
|
||||
v-if="giteaConnected"
|
||||
class="gitea-badge"
|
||||
>
|
||||
<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="M8 12h8" />
|
||||
</svg>
|
||||
Gitea 연동됨
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
<b>{{ owner || '내' }}</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게
|
||||
전달하세요.
|
||||
프로젝트를 열어 업무를 작성하고 담당자에게 전달하세요. 저장소는 프로젝트에 연동할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<!-- 툴바 -->
|
||||
@@ -90,7 +66,7 @@ const filteredRepos = computed(() =>
|
||||
<input
|
||||
v-model="keyword"
|
||||
type="text"
|
||||
placeholder="저장소 검색…"
|
||||
placeholder="프로젝트 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
@@ -128,7 +104,7 @@ const filteredRepos = computed(() =>
|
||||
</div>
|
||||
<RouterLink
|
||||
class="btn primary"
|
||||
to="/repos/new"
|
||||
to="/projects/new"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
@@ -142,7 +118,7 @@ const filteredRepos = computed(() =>
|
||||
>
|
||||
<path d="M5 12h14M12 5v14" />
|
||||
</svg>
|
||||
새 저장소
|
||||
새 프로젝트
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -152,7 +128,7 @@ const filteredRepos = computed(() =>
|
||||
v-for="repo in filteredRepos"
|
||||
:key="repo.id"
|
||||
class="repo-row"
|
||||
:to="`/repos/${repo.id}`"
|
||||
:to="`/projects/${repo.id}`"
|
||||
>
|
||||
<div class="repo-ico">
|
||||
<svg
|
||||
@@ -171,7 +147,10 @@ const filteredRepos = computed(() =>
|
||||
<div class="repo-main">
|
||||
<div class="repo-title">
|
||||
<span class="repo-name">{{ repo.name }}</span>
|
||||
<span class="repo-slug">{{ repo.slug }}</span>
|
||||
<span
|
||||
v-if="repo.isSystem"
|
||||
class="repo-slug"
|
||||
>시스템</span>
|
||||
<span
|
||||
class="vis"
|
||||
:class="repo.visibility"
|
||||
@@ -208,31 +187,15 @@ 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 }}
|
||||
</div>
|
||||
<div class="repo-meta">
|
||||
<span class="mi">
|
||||
<span
|
||||
v-if="repo.repoCount > 0"
|
||||
class="mi"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
@@ -241,25 +204,10 @@ const filteredRepos = computed(() =>
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line
|
||||
x1="6"
|
||||
y1="3"
|
||||
x2="6"
|
||||
y2="15"
|
||||
/>
|
||||
<circle
|
||||
cx="18"
|
||||
cy="6"
|
||||
r="3"
|
||||
/>
|
||||
<circle
|
||||
cx="6"
|
||||
cy="18"
|
||||
r="3"
|
||||
/>
|
||||
<path d="M18 9a9 9 0 0 1-9 9" />
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
{{ repo.branch }}
|
||||
저장소 {{ repo.repoCount }}
|
||||
</span>
|
||||
<span class="mi avstack">
|
||||
<span
|
||||
@@ -307,7 +255,7 @@ const filteredRepos = computed(() =>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading && repos.length === 0"
|
||||
v-if="loading && projects.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
불러오는 중…
|
||||
@@ -316,7 +264,7 @@ const filteredRepos = computed(() =>
|
||||
v-else-if="filteredRepos.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
{{ repos.length === 0 ? '아직 저장소가 없습니다. 새 저장소를 만들어 보세요.' : '검색 결과가 없습니다.' }}
|
||||
{{ projects.length === 0 ? '아직 프로젝트가 없습니다. 새 프로젝트를 만들어 보세요.' : '검색 결과가 없습니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -326,7 +274,7 @@ const filteredRepos = computed(() =>
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="repoStore.loadMore()"
|
||||
@click="projectStore.loadMore()"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
+18
-18
@@ -3,28 +3,28 @@
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
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 ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useMemberStore } from '@/stores/member.store'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
import type { Repo } from '@/mock/relay.mock'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { MemberRoleType } from '@/types/member'
|
||||
|
||||
type RoleFilter = 'all' | 'admin' | 'member'
|
||||
|
||||
const route = useRoute()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
const memberStore = useMemberStore()
|
||||
|
||||
// 저장소 헤더 — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null // 404/403 은 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -35,14 +35,14 @@ const members = computed(() => memberStore.members)
|
||||
const loading = computed(() => memberStore.loading)
|
||||
async function loadMembers() {
|
||||
try {
|
||||
await memberStore.fetchList(repoId.value)
|
||||
await memberStore.fetchList(projectId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
repoId,
|
||||
projectId,
|
||||
() => {
|
||||
loadRepo()
|
||||
loadMembers()
|
||||
@@ -86,7 +86,7 @@ async function changeRole(userId: string, roleType: MemberRoleType) {
|
||||
openRoleFor.value = null
|
||||
roleBusy.value = true
|
||||
try {
|
||||
await memberStore.update(repoId.value, userId, { roleType })
|
||||
await memberStore.update(projectId.value, userId, { roleType })
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
@@ -98,7 +98,7 @@ async function changeRole(userId: string, roleType: MemberRoleType) {
|
||||
async function removeMember(userId: string, name: string) {
|
||||
if (!window.confirm(`${name} 님을 저장소에서 제거하시겠습니까?`)) return
|
||||
try {
|
||||
await memberStore.remove(repoId.value, userId)
|
||||
await memberStore.remove(projectId.value, userId)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -175,7 +175,7 @@ async function submitInvite() {
|
||||
if (!canSubmitInvite.value) return
|
||||
inviteBusy.value = true
|
||||
try {
|
||||
await memberStore.invite(repoId.value, {
|
||||
await memberStore.invite(projectId.value, {
|
||||
email: inviteEmail.value.trim(),
|
||||
roleType: inviteRole.value,
|
||||
})
|
||||
@@ -205,14 +205,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
:to="`/projects/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
@@ -221,14 +221,14 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
<b>멤버</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
<ProjectHeader
|
||||
:repo="repo"
|
||||
:meta-text="`멤버 ${counts.all}명`"
|
||||
:members="headerMembers"
|
||||
:more-count="0"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
<ProjectTabs
|
||||
:project-id="repo.id"
|
||||
active="members"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="counts.all"
|
||||
+19
-95
@@ -3,18 +3,17 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, 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 { type Repo, type Visibility } from '@/mock/relay.mock'
|
||||
import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { type Project, type Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const repoStore = useRepoStore()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const projectStore = useProjectStore()
|
||||
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
|
||||
// 현재 사용자 권한 — 단건 응답에 포함된 값(백엔드와 동일 정책: 설정=admin, 삭제=소유자)
|
||||
const canManage = computed(() => repo.value?.canManage === true)
|
||||
@@ -24,17 +23,16 @@ const memberCount = computed(() =>
|
||||
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
|
||||
)
|
||||
|
||||
// 폼 상태(초기값은 저장소 데이터)
|
||||
// 폼 상태(초기값은 프로젝트 데이터)
|
||||
const displayTitle = ref('')
|
||||
const description = ref('')
|
||||
const visibility = ref<Visibility>('private')
|
||||
const branch = ref('main')
|
||||
const saving = ref(false)
|
||||
|
||||
// 저장소 로드 + 폼 초기화
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null
|
||||
return
|
||||
@@ -42,26 +40,24 @@ async function loadRepo() {
|
||||
if (!repo.value) return
|
||||
// 비관리자는 설정 화면에 진입할 수 없음 — 저장소 상세로 되돌린다(탭도 숨겨져 있어 정상 경로로는 도달 불가)
|
||||
if (!repo.value.canManage) {
|
||||
void router.replace(`/repos/${repoId.value}`)
|
||||
void router.replace(`/projects/${projectId.value}`)
|
||||
return
|
||||
}
|
||||
displayTitle.value = repo.value.name
|
||||
description.value = repo.value.desc
|
||||
visibility.value = repo.value.visibility
|
||||
branch.value = repo.value.branch
|
||||
}
|
||||
watch(repoId, loadRepo, { immediate: true })
|
||||
watch(projectId, loadRepo, { immediate: true })
|
||||
|
||||
// 변경 저장 (admin 전용)
|
||||
async function save() {
|
||||
if (saving.value || !repo.value || !canManage.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
repo.value = await repoStore.update(repoId.value, {
|
||||
repo.value = await projectStore.update(projectId.value, {
|
||||
name: displayTitle.value.trim(),
|
||||
description: description.value.trim(),
|
||||
visibility: visibility.value,
|
||||
branch: branch.value.trim() || 'main',
|
||||
})
|
||||
window.alert('변경 사항을 저장했습니다.')
|
||||
} catch {
|
||||
@@ -76,8 +72,8 @@ async function removeRepo() {
|
||||
if (!repo.value || !isOwner.value) return
|
||||
if (!window.confirm('저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 계속할까요?')) return
|
||||
try {
|
||||
await repoStore.remove(repoId.value)
|
||||
await router.push('/repos')
|
||||
await projectStore.remove(projectId.value)
|
||||
await router.push('/projects')
|
||||
} catch {
|
||||
// 인터셉터 전역 처리
|
||||
}
|
||||
@@ -92,14 +88,14 @@ async function removeRepo() {
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repo.id}`"
|
||||
:to="`/projects/${repo.id}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo.name }}
|
||||
@@ -108,14 +104,14 @@ async function removeRepo() {
|
||||
<b>설정</b>
|
||||
</div>
|
||||
|
||||
<RepoHeader
|
||||
<ProjectHeader
|
||||
:repo="repo"
|
||||
:meta-text="`멤버 ${memberCount}명`"
|
||||
:members="headerMembers"
|
||||
:more-count="repo.moreCount"
|
||||
/>
|
||||
<RepoTabs
|
||||
:repo-id="repo.id"
|
||||
<ProjectTabs
|
||||
:project-id="repo.id"
|
||||
active="settings"
|
||||
:task-count="repo.totalCount"
|
||||
:member-count="memberCount"
|
||||
@@ -163,37 +159,6 @@ async function removeRepo() {
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
소유자 / Gitea 저장소 이름 <span class="fixed-pill">소유자 고정</span>
|
||||
</div>
|
||||
<div class="fhint">
|
||||
Gitea 저장소 이름(영문)은 주소에 사용됩니다. 변경은 아래 위험 구역에서 할 수 있습니다.
|
||||
</div>
|
||||
<div class="readonly">
|
||||
{{ repo.owner }} <span class="slash">/</span> <span class="mono">{{ repo.id }}</span>
|
||||
<span class="lock">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
><rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<GiteaRepoBar
|
||||
:clone-url="repo.cloneUrl"
|
||||
:html-url="repo.htmlUrl"
|
||||
class="gitea-bar-spacing"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
프로젝트 설명
|
||||
@@ -265,47 +230,6 @@ async function removeRepo() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
메인 브랜치
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소의 기본 브랜치 이름입니다.
|
||||
</div>
|
||||
<div class="input-ico">
|
||||
<span class="ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line
|
||||
x1="6"
|
||||
y1="3"
|
||||
x2="6"
|
||||
y2="15"
|
||||
/><circle
|
||||
cx="18"
|
||||
cy="6"
|
||||
r="3"
|
||||
/><circle
|
||||
cx="6"
|
||||
cy="18"
|
||||
r="3"
|
||||
/><path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
v-model="branch"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-foot">
|
||||
<button
|
||||
class="btn primary"
|
||||
@@ -1,846 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
// 새 저장소 만들기 — 저장소 생성 폼
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useRepo } from '@/composables/useRepo'
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
|
||||
const router = useRouter()
|
||||
const repoStore = useRepoStore()
|
||||
const repoApi = useRepo()
|
||||
|
||||
// 생성 폼 메타 — 소유자(GITEA_ORG) 고정 표시 + 템플릿 가용 정보
|
||||
const { owner, templateAvailable, templateSlug } = storeToRefs(repoStore)
|
||||
onMounted(() => {
|
||||
void repoStore.fetchCreateMeta()
|
||||
})
|
||||
|
||||
// 폼 상태
|
||||
const repoName = ref('')
|
||||
const displayTitle = ref('')
|
||||
const visibility = ref<Visibility>('private')
|
||||
const description = ref('')
|
||||
// 템플릿 사용 여부 — 사용 시 설정된 템플릿 저장소(project-base)를 복제해 생성
|
||||
const useTemplate = ref(false)
|
||||
const branch = ref('main')
|
||||
const submitting = ref(false)
|
||||
|
||||
// 이름(slug) 실시간 가용성 — 입력 형식 검사(즉시) + 중복 검사(디바운스 서버 조회)
|
||||
const SLUG_RE = /^[a-z0-9-_]+$/
|
||||
type NameStatus = 'empty' | 'invalid' | 'checking' | 'available' | 'taken'
|
||||
const nameStatus = ref<NameStatus>('empty')
|
||||
let nameTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const availMessage = computed(() => {
|
||||
switch (nameStatus.value) {
|
||||
case 'invalid':
|
||||
return '영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.'
|
||||
case 'checking':
|
||||
return '사용 가능 여부 확인 중…'
|
||||
case 'available':
|
||||
return '사용 가능한 이름입니다'
|
||||
case 'taken':
|
||||
return '이미 사용 중인 이름입니다'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// 입력 변경 시: 빈 값/형식 위반은 즉시 판정, 형식 OK 면 디바운스 후 서버 중복 조회
|
||||
watch(repoName, (val) => {
|
||||
const slug = val.trim()
|
||||
if (nameTimer) clearTimeout(nameTimer)
|
||||
if (!slug) {
|
||||
nameStatus.value = 'empty'
|
||||
return
|
||||
}
|
||||
if (!SLUG_RE.test(slug)) {
|
||||
nameStatus.value = 'invalid'
|
||||
return
|
||||
}
|
||||
nameStatus.value = 'checking'
|
||||
nameTimer = setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const { available } = await repoApi.checkName(slug)
|
||||
// 그 사이 입력이 또 바뀌었으면 결과 무시(경합 방지)
|
||||
if (repoName.value.trim() !== slug) return
|
||||
nameStatus.value = available ? 'available' : 'taken'
|
||||
} catch {
|
||||
// 확인 실패 시 표시는 감춤 — 최종 검증은 제출 시 서버가 수행
|
||||
if (repoName.value.trim() === slug) nameStatus.value = 'empty'
|
||||
}
|
||||
})()
|
||||
}, 350)
|
||||
})
|
||||
|
||||
// 생성 — 성공 시 새 저장소 상세로 이동
|
||||
async function createRepo() {
|
||||
if (submitting.value) return
|
||||
|
||||
// 클라이언트 1차 검증 (서버 DTO 가 최종 검증)
|
||||
if (!repoName.value.trim()) {
|
||||
window.alert('저장소 이름을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
if (!/^[a-z0-9-_]+$/.test(repoName.value.trim())) {
|
||||
window.alert('저장소 이름은 영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다.')
|
||||
return
|
||||
}
|
||||
if (!displayTitle.value.trim()) {
|
||||
window.alert('표시 제목을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
if (!description.value.trim()) {
|
||||
window.alert('프로젝트 설명을 입력해 주세요.')
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const repo = await repoStore.create({
|
||||
slug: repoName.value.trim(),
|
||||
name: displayTitle.value.trim(),
|
||||
visibility: visibility.value,
|
||||
description: description.value.trim(),
|
||||
branch: branch.value.trim() || undefined,
|
||||
useTemplate: useTemplate.value,
|
||||
})
|
||||
await router.push(`/repos/${repo.id}`)
|
||||
} catch {
|
||||
// 에러 메시지는 API 인터셉터에서 전역 처리됨
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
새 저장소
|
||||
</div>
|
||||
<div class="pagehead">
|
||||
<h1>새 저장소 만들기</h1>
|
||||
<span class="gitea-badge">
|
||||
<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="M8 12h8" />
|
||||
</svg>
|
||||
Gitea 연동됨
|
||||
</span>
|
||||
</div>
|
||||
<p class="lede">
|
||||
Gitea 저장소를 연동하여 새 프로젝트를 생성합니다. 생성 후 이 저장소에 업무를 작성하고
|
||||
담당자에게 전달할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<form
|
||||
class="card"
|
||||
@submit.prevent="createRepo"
|
||||
>
|
||||
<!-- 소유자 / 저장소 이름 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 소유자 / 저장소 이름 <span class="fixed-pill">소유자 고정</span>
|
||||
</div>
|
||||
<div class="fhint">
|
||||
소유자는 현재 조직으로 고정되어 변경할 수 없습니다.
|
||||
</div>
|
||||
<div class="repo-id">
|
||||
<div
|
||||
class="owner-fixed"
|
||||
title="소유자는 변경할 수 없습니다"
|
||||
>
|
||||
{{ owner || '조직 불러오는 중…' }}
|
||||
<span class="lock">
|
||||
<svg
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<div class="name-wrap">
|
||||
<span class="slash">/</span>
|
||||
<input
|
||||
v-model="repoName"
|
||||
type="text"
|
||||
placeholder="repository-name"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="nameStatus !== 'empty'"
|
||||
class="avail"
|
||||
:class="nameStatus"
|
||||
>
|
||||
<svg
|
||||
v-if="nameStatus === 'available'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="nameStatus === 'invalid' || nameStatus === 'taken'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/><path d="M15 9l-6 6M9 9l6 6" />
|
||||
</svg>
|
||||
{{ availMessage }}
|
||||
</div>
|
||||
<div class="fhint below">
|
||||
영문 소문자·숫자와 하이픈(-)·밑줄(_)만 사용할 수 있습니다. 한글·공백은 입력할 수 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 표시 제목 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 표시 제목
|
||||
</div>
|
||||
<div class="fhint">
|
||||
Relay 목록과 화면에 표시되는 이름입니다. 한글로 입력하면 저장소를 알아보기 쉽습니다. 위 Gitea 저장소 이름(영문)과는 별개이며 언제든 바꿀 수 있습니다.
|
||||
</div>
|
||||
<input
|
||||
v-model="displayTitle"
|
||||
type="text"
|
||||
class="tinput"
|
||||
placeholder="예: 3분기 신제품 런칭 캠페인"
|
||||
maxlength="40"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 공개 범위 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 공개 범위
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소와 그 안의 업무를 누가 볼 수 있는지 결정합니다.
|
||||
</div>
|
||||
<div class="radio-group">
|
||||
<button
|
||||
class="radio-card"
|
||||
:class="{ sel: visibility === 'private' }"
|
||||
type="button"
|
||||
@click="visibility = 'private'"
|
||||
>
|
||||
<span class="rc-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">비공개</span>
|
||||
<span class="rc-desc">저장소에 초대된 멤버만 접근하고 업무를 확인할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio" />
|
||||
</button>
|
||||
<button
|
||||
class="radio-card"
|
||||
:class="{ sel: visibility === 'public' }"
|
||||
type="button"
|
||||
@click="visibility = 'public'"
|
||||
>
|
||||
<span class="rc-ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
/><path d="M2 12h20M12 2a15 15 0 0 1 0 20M12 2a15 15 0 0 0 0 20" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="rc-body">
|
||||
<span class="rc-title">공개</span>
|
||||
<span class="rc-desc">조직 내 모든 구성원이 저장소를 보고 업무 현황을 열람할 수 있습니다.</span>
|
||||
</span>
|
||||
<span class="rc-radio" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트 설명 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 프로젝트 설명
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소 목록과 상단에 표시됩니다. 프로젝트의 목적과 범위를 간단히 적어주세요.
|
||||
</div>
|
||||
<textarea
|
||||
v-model="description"
|
||||
class="tinput tarea"
|
||||
maxlength="300"
|
||||
placeholder="예: 3분기 신제품 런칭을 위한 콘텐츠·광고 제작 저장소"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 템플릿 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
템플릿
|
||||
</div>
|
||||
<div class="fhint">
|
||||
템플릿을 사용하면 해당 저장소의 폴더 구조·파일이 새 저장소에 그대로 복제됩니다.
|
||||
(기본 브랜치는 템플릿을 따릅니다)
|
||||
</div>
|
||||
<div class="select-wrap">
|
||||
<select v-model="useTemplate">
|
||||
<option :value="false">
|
||||
없음 (빈 저장소)
|
||||
</option>
|
||||
<option
|
||||
v-if="templateAvailable"
|
||||
:value="true"
|
||||
>
|
||||
{{ templateSlug }} 템플릿
|
||||
</option>
|
||||
</select>
|
||||
<span class="caret">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 브랜치 -->
|
||||
<div class="fblock">
|
||||
<div class="flabel">
|
||||
<span class="req">*</span> 메인 브랜치
|
||||
</div>
|
||||
<div class="fhint">
|
||||
저장소의 기본 브랜치 이름입니다.
|
||||
</div>
|
||||
<div class="input-ico branch">
|
||||
<span class="ico">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line
|
||||
x1="6"
|
||||
y1="3"
|
||||
x2="6"
|
||||
y2="15"
|
||||
/><circle
|
||||
cx="18"
|
||||
cy="6"
|
||||
r="3"
|
||||
/><circle
|
||||
cx="6"
|
||||
cy="18"
|
||||
r="3"
|
||||
/><path d="M18 9a9 9 0 0 1-9 9" />
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
v-model="branch"
|
||||
type="text"
|
||||
spellcheck="false"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 푸터 -->
|
||||
<div class="form-footer">
|
||||
<span class="note"><span class="step">1</span> 저장소 생성 → 업무 생성 → 전달</span>
|
||||
<RouterLink
|
||||
class="btn ghost"
|
||||
to="/repos"
|
||||
>
|
||||
취소
|
||||
</RouterLink>
|
||||
<button
|
||||
class="btn primary"
|
||||
type="submit"
|
||||
:disabled="submitting"
|
||||
>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
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>
|
||||
{{ submitting ? '생성 중…' : '저장소 생성' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page {
|
||||
max-width: 47.5rem;
|
||||
}
|
||||
.crumb {
|
||||
padding: 1.125rem 0 0;
|
||||
}
|
||||
.pagehead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5625rem 0 0.375rem;
|
||||
}
|
||||
.pagehead h1 {
|
||||
font-size: 1.375rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025rem;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gitea-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #1b7a3d;
|
||||
background: var(--green-weak);
|
||||
border: 1px solid var(--green-border);
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 20px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gitea-badge svg {
|
||||
width: 0.8125rem;
|
||||
height: 0.8125rem;
|
||||
}
|
||||
.lede {
|
||||
color: var(--text-2);
|
||||
font-size: 0.844rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
/* 폼 블록 */
|
||||
.fblock {
|
||||
padding: 1.375rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.fblock:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
.flabel {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
}
|
||||
.flabel .req {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
.fhint {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 0.6875rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.fhint.below {
|
||||
margin-top: 0.5625rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tinput {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.tinput:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.tinput::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
textarea.tarea {
|
||||
min-height: 6rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 소유자 / 저장소 이름 */
|
||||
.repo-id {
|
||||
display: flex;
|
||||
}
|
||||
.owner-fixed {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.8125rem;
|
||||
background: #f1f2f4;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-right: none;
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: not-allowed;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
.owner-fixed .lock {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.name-wrap {
|
||||
flex: 1;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
background: #fff;
|
||||
padding: 0 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.name-wrap:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.name-wrap .slash {
|
||||
color: var(--text-3);
|
||||
margin-right: 0.4375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
.name-wrap input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 100%;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
}
|
||||
.name-wrap input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.avail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5625rem;
|
||||
}
|
||||
/* 상태별 색상 — 사용 가능(초록) / 형식 위반·중복(빨강) / 확인 중(회색) */
|
||||
.avail.available {
|
||||
color: var(--green);
|
||||
}
|
||||
.avail.invalid,
|
||||
.avail.taken {
|
||||
color: var(--red);
|
||||
}
|
||||
.avail.checking {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.avail svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fixed-pill {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
background: #e9ebef;
|
||||
border: 1px solid var(--border-strong);
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
border-radius: 20px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 공개 범위 라디오 카드 */
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.radio-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.8125rem;
|
||||
padding: 0.8125rem 0.875rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
transition:
|
||||
border-color 0.12s,
|
||||
background 0.12s;
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
.radio-card:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
.radio-card.sel {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.rc-ico {
|
||||
width: 2.125rem;
|
||||
height: 2.125rem;
|
||||
border-radius: 0.5rem;
|
||||
background: #f1f2f4;
|
||||
color: var(--text-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rc-ico svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.radio-card.sel .rc-ico {
|
||||
background: #fff;
|
||||
color: var(--accent);
|
||||
}
|
||||
.rc-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.rc-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.rc-desc {
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.125rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.rc-radio {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.5rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #fff;
|
||||
}
|
||||
.radio-card.sel .rc-radio {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.radio-card.sel .rc-radio::after {
|
||||
content: '';
|
||||
width: 0.5625rem;
|
||||
height: 0.5625rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* select */
|
||||
.select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.select-wrap select {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 2.375rem 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.select-wrap select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.select-wrap .caret {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.select-wrap .caret svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* 아이콘 입력 */
|
||||
.input-ico {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
padding: 0 0.75rem;
|
||||
gap: 0.5625rem;
|
||||
}
|
||||
.input-ico.branch {
|
||||
max-width: 17.5rem;
|
||||
}
|
||||
.input-ico:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.input-ico .ico {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.input-ico .ico svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
.input-ico input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
outline: none;
|
||||
height: 2.5rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* 푸터 */
|
||||
.form-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
border-radius: 0 0 0.625rem 0.625rem;
|
||||
}
|
||||
.form-footer .note {
|
||||
color: var(--text-3);
|
||||
font-size: 0.781rem;
|
||||
margin-right: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.form-footer .note .step {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.form-footer .btn {
|
||||
height: 2.25rem;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -5,26 +5,26 @@ import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { useRepoStore } from '@/stores/repo.store'
|
||||
import { useProjectStore } from '@/stores/project.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 { useAi } from '@/composables/useAi'
|
||||
import { attachmentKindOf, fileBadge, formatBytes } from '@/shared/utils/format'
|
||||
import type { Repo, ChecklistItem, User } from '@/mock/relay.mock'
|
||||
import type { Project, ChecklistItem, User } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type { ApiAttachment } from '@/types/task'
|
||||
import type { ChecklistSuggestionGroup } from '@/types/ai'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
// 수정 모드 여부 — taskId 파라미터 존재 시 편집
|
||||
const taskId = computed(() => (route.params.taskId ? Number(route.params.taskId) : null))
|
||||
const isEdit = computed(() => taskId.value !== null)
|
||||
|
||||
const repoStore = useRepoStore()
|
||||
const projectStore = useProjectStore()
|
||||
const memberStore = useMemberStore()
|
||||
const taskStore = useTaskStore()
|
||||
const authStore = useAuthStore()
|
||||
@@ -52,10 +52,10 @@ function toDateInput(iso: string): string {
|
||||
}
|
||||
|
||||
// 저장소 헤더(빵부스러기) — API 연동
|
||||
const repo = ref<Repo | null>(null)
|
||||
const repo = ref<Project | null>(null)
|
||||
async function loadRepo() {
|
||||
try {
|
||||
repo.value = await repoStore.fetchOne(repoId.value)
|
||||
repo.value = await projectStore.fetchOne(projectId.value)
|
||||
} catch {
|
||||
repo.value = null
|
||||
}
|
||||
@@ -63,7 +63,7 @@ async function loadRepo() {
|
||||
// 담당자 후보 = 저장소 멤버
|
||||
async function loadMembers() {
|
||||
try {
|
||||
await memberStore.fetchList(repoId.value)
|
||||
await memberStore.fetchList(projectId.value)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -72,7 +72,7 @@ async function loadMembers() {
|
||||
async function loadTaskForEdit() {
|
||||
if (taskId.value === null) return
|
||||
try {
|
||||
const t = await taskApi.get(repoId.value, taskId.value)
|
||||
const t = await taskApi.get(projectId.value, taskId.value)
|
||||
title.value = t.title
|
||||
contentText.value = t.content.join('\n')
|
||||
dueDate.value = t.dueDate ? toDateInput(t.dueDate) : ''
|
||||
@@ -85,7 +85,7 @@ async function loadTaskForEdit() {
|
||||
}
|
||||
}
|
||||
watch(
|
||||
[repoId, taskId],
|
||||
[projectId, taskId],
|
||||
() => {
|
||||
loadRepo()
|
||||
loadMembers()
|
||||
@@ -175,7 +175,7 @@ async function requestAiSuggestions() {
|
||||
.map((p) => p.trim())
|
||||
.filter((p) => p.length > 0)
|
||||
const { groups } = await ai.suggestChecklist({
|
||||
repoId: repoId.value,
|
||||
projectId: projectId.value,
|
||||
title: title.value.trim(),
|
||||
content: content.length ? content : undefined,
|
||||
})
|
||||
@@ -236,7 +236,7 @@ function removePending(index: number) {
|
||||
async function removeExisting(fileId: string) {
|
||||
if (taskId.value === null) return
|
||||
try {
|
||||
await taskApi.removeFile(repoId.value, taskId.value, fileId)
|
||||
await taskApi.removeFile(projectId.value, taskId.value, fileId)
|
||||
existingFiles.value = existingFiles.value.filter((f) => f.id !== fileId)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
@@ -247,7 +247,7 @@ async function uploadPendingFiles(tid: number): Promise<number> {
|
||||
let failed = 0
|
||||
for (const file of pendingFiles.value) {
|
||||
try {
|
||||
await taskApi.uploadFile(repoId.value, tid, file)
|
||||
await taskApi.uploadFile(projectId.value, tid, file)
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
@@ -275,7 +275,7 @@ async function submitTask() {
|
||||
let tid: number
|
||||
if (isEdit.value && taskId.value !== null) {
|
||||
// 수정 — 제목/내용/담당자/마감 + 체크리스트(추가/삭제/순서) 동기화
|
||||
const updated = await taskStore.update(repoId.value, taskId.value, {
|
||||
const updated = await taskStore.update(projectId.value, taskId.value, {
|
||||
title: title.value.trim(),
|
||||
content,
|
||||
assigneeIds,
|
||||
@@ -286,7 +286,7 @@ async function submitTask() {
|
||||
})
|
||||
tid = updated.id
|
||||
} else {
|
||||
const created = await taskStore.create(repoId.value, {
|
||||
const created = await taskStore.create(projectId.value, {
|
||||
title: title.value.trim(),
|
||||
content: content.length ? content : undefined,
|
||||
assigneeIds,
|
||||
@@ -305,7 +305,7 @@ async function submitTask() {
|
||||
`파일 ${failed}개 업로드에 실패했습니다. 업무 상세 화면에서 다시 첨부해 주세요.`,
|
||||
)
|
||||
}
|
||||
router.push(`/repos/${repoId.value}/tasks/${tid}`)
|
||||
router.push(`/projects/${projectId.value}/tasks/${tid}`)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리(권한/검증 등)
|
||||
} finally {
|
||||
@@ -314,7 +314,7 @@ async function submitTask() {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
router.push(`/projects/${projectId.value}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -323,14 +323,14 @@ function goBack() {
|
||||
<div class="page">
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
:to="`/projects/${projectId}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ repo?.name ?? '저장소' }}
|
||||
|
||||
@@ -26,7 +26,7 @@ import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const repoId = computed(() => String(route.params.repoId))
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
const taskId = computed(() => Number(route.params.taskId))
|
||||
|
||||
const taskStore = useTaskStore()
|
||||
@@ -36,7 +36,7 @@ const authStore = useAuthStore()
|
||||
const task = ref<TaskDetail | null>(null)
|
||||
async function loadTask() {
|
||||
try {
|
||||
task.value = await taskStore.fetchOne(repoId.value, taskId.value)
|
||||
task.value = await taskStore.fetchOne(projectId.value, taskId.value)
|
||||
} catch {
|
||||
task.value = null // 404 등은 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -106,8 +106,8 @@ async function confirmDelete() {
|
||||
if (!task.value) return
|
||||
showDelete.value = false
|
||||
try {
|
||||
await taskStore.remove(repoId.value, task.value.id)
|
||||
router.push(`/repos/${repoId.value}`)
|
||||
await taskStore.remove(projectId.value, task.value.id)
|
||||
router.push(`/projects/${projectId.value}`)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -117,7 +117,7 @@ async function confirmDelete() {
|
||||
async function approveTask() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.approve(repoId.value, task.value.id)
|
||||
task.value = await taskStore.approve(projectId.value, task.value.id)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
@@ -131,7 +131,7 @@ async function submitChanges() {
|
||||
const note = changesNote.value.trim()
|
||||
if (!note) return
|
||||
try {
|
||||
task.value = await taskStore.requestChanges(repoId.value, task.value.id, note)
|
||||
task.value = await taskStore.requestChanges(projectId.value, task.value.id, note)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
@@ -144,7 +144,7 @@ async function submitChanges() {
|
||||
async function changeTo(status: TaskStatus) {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.changeStatus(repoId.value, task.value.id, status)
|
||||
task.value = await taskStore.changeStatus(projectId.value, task.value.id, status)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -158,7 +158,7 @@ async function sendApproval() {
|
||||
if (!task.value) return
|
||||
try {
|
||||
task.value = await taskStore.requestApproval(
|
||||
repoId.value,
|
||||
projectId.value,
|
||||
task.value.id,
|
||||
approvalNote.value.trim() || undefined,
|
||||
)
|
||||
@@ -221,7 +221,7 @@ async function submitReply(index: number) {
|
||||
const target = comments.value[index]
|
||||
if (!text || !target?.id || !task.value) return
|
||||
try {
|
||||
task.value = await taskStore.addReply(repoId.value, task.value.id, target.id, text)
|
||||
task.value = await taskStore.addReply(projectId.value, task.value.id, target.id, text)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -232,7 +232,7 @@ async function submitComment() {
|
||||
const text = commentText.value.trim()
|
||||
if (!text || !task.value) return
|
||||
try {
|
||||
task.value = await taskStore.addComment(repoId.value, task.value.id, text)
|
||||
task.value = await taskStore.addComment(projectId.value, task.value.id, text)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -261,7 +261,7 @@ async function onFileChange(e: Event) {
|
||||
}
|
||||
uploading.value = true
|
||||
try {
|
||||
task.value = await taskStore.uploadFile(repoId.value, task.value.id, file)
|
||||
task.value = await taskStore.uploadFile(projectId.value, task.value.id, file)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
} finally {
|
||||
@@ -272,7 +272,7 @@ async function onFileChange(e: Event) {
|
||||
async function removeFile(att: Attachment) {
|
||||
if (!task.value || !att.id) return
|
||||
try {
|
||||
task.value = await taskStore.removeFile(repoId.value, task.value.id, att.id)
|
||||
task.value = await taskStore.removeFile(projectId.value, task.value.id, att.id)
|
||||
} catch {
|
||||
// 인터셉터가 알림 처리
|
||||
}
|
||||
@@ -317,21 +317,21 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
>
|
||||
<div class="crumb">
|
||||
<RouterLink
|
||||
to="/repos"
|
||||
to="/projects"
|
||||
class="lnk"
|
||||
>
|
||||
저장소
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
:to="`/projects/${projectId}`"
|
||||
class="lnk"
|
||||
>
|
||||
{{ task.repoName }}
|
||||
{{ task.projectName }}
|
||||
</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<RouterLink
|
||||
:to="`/repos/${repoId}`"
|
||||
:to="`/projects/${projectId}`"
|
||||
class="lnk"
|
||||
>
|
||||
업무
|
||||
@@ -356,7 +356,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
|
||||
<div class="actions">
|
||||
<RouterLink
|
||||
class="btn"
|
||||
:to="`/repos/${repoId}/tasks/${task.id}/edit`"
|
||||
:to="`/projects/${projectId}/tasks/${task.id}/edit`"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/repos',
|
||||
redirect: '/projects',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
@@ -20,63 +20,63 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/repos',
|
||||
name: 'repo-list',
|
||||
component: () => import('@/pages/relay/RepoListPage.vue'),
|
||||
path: '/projects',
|
||||
name: 'project-list',
|
||||
component: () => import('@/pages/relay/ProjectListPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 정적 경로 '/repos/new' 는 동적 '/repos/:repoId' 보다 먼저 선언해 우선 매칭되게 한다
|
||||
path: '/repos/new',
|
||||
name: 'repo-create',
|
||||
component: () => import('@/pages/relay/RepoCreatePage.vue'),
|
||||
// 정적 '/projects/new' 는 동적 '/projects/:projectId' 보다 먼저 선언
|
||||
path: '/projects/new',
|
||||
name: 'project-create',
|
||||
component: () => import('@/pages/relay/ProjectCreatePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId',
|
||||
name: 'repo-detail',
|
||||
component: () => import('@/pages/relay/RepoDetailPage.vue'),
|
||||
path: '/projects/:projectId',
|
||||
name: 'project-detail',
|
||||
component: () => import('@/pages/relay/ProjectDetailPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/members',
|
||||
name: 'repo-members',
|
||||
component: () => import('@/pages/relay/RepoMembersPage.vue'),
|
||||
path: '/projects/:projectId/members',
|
||||
name: 'project-members',
|
||||
component: () => import('@/pages/relay/ProjectMembersPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/activity',
|
||||
name: 'repo-activity',
|
||||
component: () => import('@/pages/relay/RepoActivityPage.vue'),
|
||||
path: '/projects/:projectId/activity',
|
||||
name: 'project-activity',
|
||||
component: () => import('@/pages/relay/ProjectActivityPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/settings',
|
||||
name: 'repo-settings',
|
||||
component: () => import('@/pages/relay/RepoSettingsPage.vue'),
|
||||
path: '/projects/:projectId/settings',
|
||||
name: 'project-settings',
|
||||
component: () => import('@/pages/relay/ProjectSettingsPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/tasks/new',
|
||||
path: '/projects/:projectId/tasks/new',
|
||||
name: 'task-create',
|
||||
component: () => import('@/pages/relay/TaskCreatePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 업무 수정 — 생성 폼(TaskCreatePage)을 편집 모드로 재사용
|
||||
path: '/repos/:repoId/tasks/:taskId/edit',
|
||||
path: '/projects/:projectId/tasks/:taskId/edit',
|
||||
name: 'task-edit',
|
||||
component: () => import('@/pages/relay/TaskCreatePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/repos/:repoId/tasks/:taskId',
|
||||
path: '/projects/:projectId/tasks/:taskId',
|
||||
name: 'task-detail',
|
||||
component: () => import('@/pages/relay/TaskDetailPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// '내 업무' — 담당/지시 저장소 횡단 집계
|
||||
// '내 업무' — 담당/지시 프로젝트 횡단 집계
|
||||
path: '/tasks',
|
||||
name: 'my-tasks',
|
||||
component: () => import('@/pages/relay/MyTasksPage.vue'),
|
||||
@@ -103,14 +103,18 @@ router.beforeEach(async (to) => {
|
||||
await authStore.bootstrap()
|
||||
}
|
||||
|
||||
// 보호 라우트인데 미로그인 → 로그인 페이지로 (로그인 후에는 항상 저장소 목록으로 진입)
|
||||
// 보호 라우트인데 미로그인 → 로그인 페이지로 (로그인 후에는 항상 프로젝트 목록으로 진입)
|
||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||
return { name: 'login' }
|
||||
}
|
||||
|
||||
// 이미 로그인 상태에서 로그인/회원가입 진입 → 저장소 목록으로
|
||||
if (!to.meta.requiresAuth && authStore.isLoggedIn && (to.name === 'login' || to.name === 'signup')) {
|
||||
return { path: '/repos' }
|
||||
// 이미 로그인 상태에서 로그인/회원가입 진입 → 프로젝트 목록으로
|
||||
if (
|
||||
!to.meta.requiresAuth &&
|
||||
authStore.isLoggedIn &&
|
||||
(to.name === 'login' || to.name === 'signup')
|
||||
) {
|
||||
return { path: '/projects' }
|
||||
}
|
||||
|
||||
return true
|
||||
|
||||
@@ -36,22 +36,26 @@ function toRepoActivityItem(api: ApiActivity): RepoActivityItem {
|
||||
const target = escapeHtml(pstr(p, 'targetName'))
|
||||
|
||||
switch (api.type) {
|
||||
case 'repo.created':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소를 생성했습니다` }
|
||||
case 'repo.renamed':
|
||||
case 'project.created':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 프로젝트를 생성했습니다` }
|
||||
case 'project.renamed':
|
||||
return {
|
||||
...base,
|
||||
tone: 'setting',
|
||||
icon: 'pencil',
|
||||
html: `<b>${actor}</b>님이 표시 제목을 ‘${escapeHtml(pstr(p, 'newName'))}’(으)로 변경했습니다`,
|
||||
}
|
||||
case 'repo.visibility_changed':
|
||||
case 'project.visibility_changed':
|
||||
return {
|
||||
...base,
|
||||
tone: 'setting',
|
||||
icon: 'lock',
|
||||
html: `<b>${actor}</b>님이 저장소 공개 범위를 <b>${pstr(p, 'visibility') === 'private' ? '비공개' : '공개'}</b>(으)로 변경했습니다`,
|
||||
html: `<b>${actor}</b>님이 프로젝트 공개 범위를 <b>${pstr(p, 'visibility') === 'private' ? '비공개' : '공개'}</b>(으)로 변경했습니다`,
|
||||
}
|
||||
case 'repo.linked':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소 <b>${escapeHtml(pstr(p, 'repoName'))}</b>을(를) 연동했습니다` }
|
||||
case 'repo.unlinked':
|
||||
return { ...base, tone: 'setting', icon: 'repo', html: `<b>${actor}</b>님이 저장소 <b>${escapeHtml(pstr(p, 'repoName'))}</b> 연동을 해제했습니다` }
|
||||
case 'member.invited':
|
||||
return { ...base, tone: 'member', icon: 'member-add', html: `<b>${actor}</b>님이 <b>${target}</b>님을 멤버로 초대했습니다` }
|
||||
case 'member.role_changed':
|
||||
@@ -127,10 +131,10 @@ export const useActivityStore = defineStore('activity', () => {
|
||||
const activityApi = useActivity()
|
||||
|
||||
// 저장소 활동 조회 → 날짜 그룹으로 묶기(목록은 최신순이라 같은 날은 연속)
|
||||
async function fetch(repoId: string): Promise<void> {
|
||||
async function fetch(projectId: string): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const list = await activityApi.list(repoId)
|
||||
const list = await activityApi.list(projectId)
|
||||
const groups: RepoActivityDay[] = []
|
||||
let current: RepoActivityDay | null = null
|
||||
for (const a of list) {
|
||||
|
||||
@@ -43,13 +43,13 @@ export const useMemberStore = defineStore('member', () => {
|
||||
// 멤버 목록 조회 — 백엔드는 페이지네이션이지만, 역할/검색 카운트와 담당자 후보 풀
|
||||
// (TaskCreatePage)이 전수 기준이어야 하므로 모든 페이지를 순회해 전체를 로드한다.
|
||||
// (멤버는 팀 규모로 한정적이라 전체 로드가 안전하다)
|
||||
async function fetchList(repoId: string): Promise<void> {
|
||||
async function fetchList(projectId: string): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const acc: ApiRepoMember[] = []
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const { items, total } = await memberApi.list(repoId, page, DEFAULT_PAGE_SIZE)
|
||||
const { items, total } = await memberApi.list(projectId, page, DEFAULT_PAGE_SIZE)
|
||||
acc.push(...items)
|
||||
if (items.length === 0 || acc.length >= total) break
|
||||
page += 1
|
||||
@@ -61,26 +61,26 @@ export const useMemberStore = defineStore('member', () => {
|
||||
}
|
||||
|
||||
// 멤버 초대 — 성공 시 목록에 추가
|
||||
async function invite(repoId: string, payload: InviteMemberPayload): Promise<void> {
|
||||
const created = await memberApi.invite(repoId, payload)
|
||||
async function invite(projectId: string, payload: InviteMemberPayload): Promise<void> {
|
||||
const created = await memberApi.invite(projectId, payload)
|
||||
members.value.push(toMemberView(created, currentUserId()))
|
||||
}
|
||||
|
||||
// 멤버 역할/직무 변경 — 성공 시 해당 행 교체
|
||||
async function update(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
userId: string,
|
||||
payload: UpdateMemberPayload,
|
||||
): Promise<void> {
|
||||
const updated = await memberApi.update(repoId, userId, payload)
|
||||
const updated = await memberApi.update(projectId, userId, payload)
|
||||
const view = toMemberView(updated, currentUserId())
|
||||
const idx = members.value.findIndex((m) => m.user.id === userId)
|
||||
if (idx >= 0) members.value[idx] = view
|
||||
}
|
||||
|
||||
// 멤버 제거 — 성공 시 목록에서 제외
|
||||
async function remove(repoId: string, userId: string): Promise<void> {
|
||||
await memberApi.remove(repoId, userId)
|
||||
async function remove(projectId: string, userId: string): Promise<void> {
|
||||
await memberApi.remove(projectId, userId)
|
||||
members.value = members.value.filter((m) => m.user.id !== userId)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,10 +47,10 @@ function toAssignedRow(api: ApiMyTaskRow): MyTaskRow {
|
||||
const isDone = api.status === 'done'
|
||||
return {
|
||||
id: api.id,
|
||||
repoId: api.repoId,
|
||||
projectId: api.projectId,
|
||||
title: api.title,
|
||||
status: api.status,
|
||||
repoName: api.repoName,
|
||||
projectName: api.projectName,
|
||||
checklist: total > 0 ? api.checklist : undefined,
|
||||
metaText: `지시 · ${api.issuer?.name ?? '알 수 없음'}`,
|
||||
dueLabel: isDone ? due.listLabel : due.dateLabel || undefined,
|
||||
@@ -71,10 +71,10 @@ function toIssuedRow(api: ApiMyTaskRow): MyTaskRow {
|
||||
const names = api.assignees.map((a) => a.name).join(', ') || '미지정'
|
||||
return {
|
||||
id: api.id,
|
||||
repoId: api.repoId,
|
||||
projectId: api.projectId,
|
||||
title: api.title,
|
||||
status: api.status,
|
||||
repoName: api.repoName,
|
||||
projectName: api.projectName,
|
||||
checklist: total > 0 ? api.checklist : undefined,
|
||||
assignees: api.assignees.map(toUserView),
|
||||
metaText: api.status === 'changes' ? `${names}에게 수정 요청` : names,
|
||||
|
||||
@@ -19,11 +19,14 @@ function pnum(p: Record<string, unknown>, k: string): number | null {
|
||||
function toView(n: ApiNotification): NotificationView {
|
||||
const p = n.payload
|
||||
const actor = escapeHtml(pstr(p, 'actorName') || '누군가')
|
||||
const repoName = escapeHtml(pstr(p, 'repoName'))
|
||||
const projectName = escapeHtml(pstr(p, 'projectName'))
|
||||
const taskTitle = escapeHtml(pstr(p, 'taskTitle'))
|
||||
const repoId = pstr(p, 'repoId')
|
||||
const projectId = pstr(p, 'projectId')
|
||||
const taskSeq = pnum(p, 'taskSeq')
|
||||
const taskTo = repoId && taskSeq !== null ? `/repos/${repoId}/tasks/${taskSeq}` : null
|
||||
const taskTo =
|
||||
projectId && taskSeq !== null
|
||||
? `/projects/${projectId}/tasks/${taskSeq}`
|
||||
: null
|
||||
|
||||
let tone = ''
|
||||
let html = '새 알림'
|
||||
@@ -52,7 +55,7 @@ function toView(n: ApiNotification): NotificationView {
|
||||
}
|
||||
case 'task.removed':
|
||||
tone = 'red'
|
||||
to = repoId ? `/repos/${repoId}` : null // 업무가 삭제되므로 저장소로 이동
|
||||
to = projectId ? `/projects/${projectId}` : null // 업무가 삭제되므로 프로젝트로 이동
|
||||
html = `<b>${actor}</b>님이 <b>${taskTitle}</b> 업무를 삭제했습니다`
|
||||
break
|
||||
case 'task.approval_requested':
|
||||
@@ -73,20 +76,20 @@ function toView(n: ApiNotification): NotificationView {
|
||||
break
|
||||
case 'member.invited':
|
||||
tone = 'accent'
|
||||
to = repoId ? `/repos/${repoId}` : null
|
||||
html = `<b>${actor}</b>님이 <b>${repoName}</b> 저장소에 초대했습니다`
|
||||
to = projectId ? `/projects/${projectId}` : null
|
||||
html = `<b>${actor}</b>님이 <b>${projectName}</b> 프로젝트에 초대했습니다`
|
||||
break
|
||||
case 'member.role_changed': {
|
||||
const role = pstr(p, 'roleType') === 'admin' ? '관리자' : '멤버'
|
||||
tone = 'accent'
|
||||
to = repoId ? `/repos/${repoId}` : null
|
||||
html = `<b>${actor}</b>님이 <b>${repoName}</b>에서 회원님의 역할을 <b>${role}</b>(으)로 변경했습니다`
|
||||
to = projectId ? `/projects/${projectId}` : null
|
||||
html = `<b>${actor}</b>님이 <b>${projectName}</b>에서 회원님의 역할을 <b>${role}</b>(으)로 변경했습니다`
|
||||
break
|
||||
}
|
||||
case 'member.removed':
|
||||
tone = 'red'
|
||||
to = repoId ? `/repos/${repoId}` : null
|
||||
html = `<b>${actor}</b>님이 <b>${repoName}</b> 저장소에서 회원님을 제외했습니다`
|
||||
to = projectId ? `/projects/${projectId}` : null
|
||||
html = `<b>${actor}</b>님이 <b>${projectName}</b> 프로젝트에서 회원님을 제외했습니다`
|
||||
break
|
||||
}
|
||||
return { id: n.id, read: n.read, tone, html, time: relativeTimeKo(n.createdAt), to }
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useProject } from '@/composables/useProject'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import { progressOf, relativeTimeKo } from '@/shared/utils/format'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
import type {
|
||||
ApiProject,
|
||||
CreateProjectPayload,
|
||||
UpdateProjectPayload,
|
||||
} from '@/types/project'
|
||||
import type { Project as ProjectView, User as UserView } from '@/mock/relay.mock'
|
||||
|
||||
// 아바타 스택에 노출할 멤버 수(초과분은 +N 으로 표기)
|
||||
const AVATAR_LIMIT = 3
|
||||
|
||||
function toUserView(member: ApiMember): UserView {
|
||||
return {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
role: member.role ?? '',
|
||||
avatarUrl: member.avatarUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// API 프로젝트 → 화면용 Project(진행률/상대시간/아바타 파생)
|
||||
function toProjectView(api: ApiProject): ProjectView {
|
||||
const prog = progressOf(api.doneCount, api.totalCount)
|
||||
const shown = api.members.slice(0, AVATAR_LIMIT).map(toUserView)
|
||||
return {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
desc: api.description ?? '',
|
||||
visibility: api.visibility,
|
||||
isSystem: api.isSystem,
|
||||
members: shown,
|
||||
moreCount: Math.max(0, api.memberCount - shown.length),
|
||||
updatedAgo: `${relativeTimeKo(api.updatedAt)} 업데이트`,
|
||||
repoCount: api.repoCount,
|
||||
doneCount: api.doneCount,
|
||||
totalCount: api.totalCount,
|
||||
progressPct: prog.pct,
|
||||
progressLevel: prog.level,
|
||||
progressLabel: prog.label,
|
||||
canManage: api.canManage,
|
||||
isOwner: api.isOwner,
|
||||
}
|
||||
}
|
||||
|
||||
// 프로젝트 스토어 — 목록/현재 프로젝트 상태 + CRUD 액션
|
||||
export const useProjectStore = defineStore('project', () => {
|
||||
const projects = ref<ProjectView[]>([])
|
||||
const current = ref<ProjectView | null>(null)
|
||||
const loading = ref(false)
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = computed(() => projects.value.length < total.value)
|
||||
|
||||
const projectApi = useProject()
|
||||
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await projectApi.list(1, DEFAULT_PAGE_SIZE)
|
||||
projects.value = res.items.map(toProjectView)
|
||||
total.value = res.total
|
||||
page.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const res = await projectApi.list(page.value + 1, DEFAULT_PAGE_SIZE)
|
||||
projects.value.push(...res.items.map(toProjectView))
|
||||
total.value = res.total
|
||||
page.value += 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOne(id: string): Promise<ProjectView | null> {
|
||||
loading.value = true
|
||||
try {
|
||||
current.value = toProjectView(await projectApi.get(id))
|
||||
return current.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(payload: CreateProjectPayload): Promise<ProjectView> {
|
||||
return toProjectView(await projectApi.create(payload))
|
||||
}
|
||||
|
||||
async function update(
|
||||
id: string,
|
||||
payload: UpdateProjectPayload,
|
||||
): Promise<ProjectView> {
|
||||
const view = toProjectView(await projectApi.update(id, payload))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
async function remove(id: string): Promise<void> {
|
||||
await projectApi.remove(id)
|
||||
projects.value = projects.value.filter((p) => p.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
if (current.value?.id === id) current.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
projects,
|
||||
current,
|
||||
loading,
|
||||
total,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
fetchList,
|
||||
loadMore,
|
||||
fetchOne,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
}
|
||||
})
|
||||
@@ -1,152 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useRepo } from '@/composables/useRepo'
|
||||
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
|
||||
import { progressOf, relativeTimeKo } from '@/shared/utils/format'
|
||||
import type { ApiMember, ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
|
||||
import type { Repo as RepoView, User as UserView } from '@/mock/relay.mock'
|
||||
|
||||
// 아바타 스택에 노출할 멤버 수(초과분은 +N 으로 표기)
|
||||
const AVATAR_LIMIT = 3
|
||||
|
||||
// API 멤버 → 화면용 User(이니셜/색상 파생)
|
||||
function toUserView(member: ApiMember): UserView {
|
||||
return {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
role: member.role ?? '',
|
||||
avatarUrl: member.avatarUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// API 저장소 → 화면용 Repo(진행률/상대시간/아바타 파생)
|
||||
function toRepoView(api: ApiRepo): RepoView {
|
||||
const prog = progressOf(api.doneCount, api.totalCount)
|
||||
const shown = api.members.slice(0, AVATAR_LIMIT).map(toUserView)
|
||||
return {
|
||||
id: api.id,
|
||||
name: api.name,
|
||||
owner: api.owner,
|
||||
slug: api.slug,
|
||||
desc: api.desc ?? '',
|
||||
visibility: api.visibility,
|
||||
branch: api.branch,
|
||||
members: shown,
|
||||
moreCount: Math.max(0, api.memberCount - shown.length),
|
||||
updatedAgo: `${relativeTimeKo(api.updatedAt)} 업데이트`,
|
||||
doneCount: api.doneCount,
|
||||
totalCount: api.totalCount,
|
||||
progressPct: prog.pct,
|
||||
progressLevel: prog.level,
|
||||
progressLabel: prog.label,
|
||||
cloneUrl: api.cloneUrl,
|
||||
htmlUrl: api.htmlUrl,
|
||||
giteaMissing: api.giteaMissing,
|
||||
canManage: api.canManage,
|
||||
isOwner: api.isOwner,
|
||||
}
|
||||
}
|
||||
|
||||
// 저장소 스토어 — 목록/현재 저장소 상태 + CRUD 액션
|
||||
export const useRepoStore = defineStore('repo', () => {
|
||||
const repos = ref<RepoView[]>([])
|
||||
const current = ref<RepoView | null>(null)
|
||||
const loading = ref(false)
|
||||
// 페이지네이션(더보기) 상태
|
||||
const total = ref(0)
|
||||
const page = ref(0)
|
||||
const loadingMore = ref(false)
|
||||
const hasMore = computed(() => repos.value.length < total.value)
|
||||
// 생성 폼 메타 — 소유자(GITEA_ORG)·템플릿 정보. 최초 1회 조회 후 캐시
|
||||
const owner = ref('')
|
||||
const templateAvailable = ref(false)
|
||||
const templateSlug = ref('')
|
||||
|
||||
const repoApi = useRepo()
|
||||
|
||||
// 생성 폼 메타(소유자/템플릿) 조회 — 이미 있으면 재사용
|
||||
async function fetchCreateMeta(): Promise<void> {
|
||||
if (owner.value) return
|
||||
const meta = await repoApi.meta()
|
||||
owner.value = meta.owner
|
||||
templateAvailable.value = meta.template.available
|
||||
templateSlug.value = meta.template.slug
|
||||
}
|
||||
|
||||
// 목록 첫 페이지 조회(리셋)
|
||||
async function fetchList(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await repoApi.list(1, DEFAULT_PAGE_SIZE)
|
||||
repos.value = res.items.map(toRepoView)
|
||||
total.value = res.total
|
||||
page.value = 1
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 페이지 이어붙이기(더보기)
|
||||
async function loadMore(): Promise<void> {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const res = await repoApi.list(page.value + 1, DEFAULT_PAGE_SIZE)
|
||||
repos.value.push(...res.items.map(toRepoView))
|
||||
total.value = res.total
|
||||
page.value += 1
|
||||
} finally {
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 단건 조회
|
||||
async function fetchOne(id: string): Promise<RepoView | null> {
|
||||
loading.value = true
|
||||
try {
|
||||
current.value = toRepoView(await repoApi.get(id))
|
||||
return current.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 생성
|
||||
async function create(payload: CreateRepoPayload): Promise<RepoView> {
|
||||
return toRepoView(await repoApi.create(payload))
|
||||
}
|
||||
|
||||
// 수정
|
||||
async function update(id: string, payload: UpdateRepoPayload): Promise<RepoView> {
|
||||
const view = toRepoView(await repoApi.update(id, payload))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
// 삭제
|
||||
async function remove(id: string): Promise<void> {
|
||||
await repoApi.remove(id)
|
||||
repos.value = repos.value.filter((r) => r.id !== id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
if (current.value?.id === id) current.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
repos,
|
||||
current,
|
||||
loading,
|
||||
total,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
owner,
|
||||
templateAvailable,
|
||||
templateSlug,
|
||||
fetchCreateMeta,
|
||||
fetchList,
|
||||
loadMore,
|
||||
fetchOne,
|
||||
create,
|
||||
update,
|
||||
remove,
|
||||
}
|
||||
})
|
||||
@@ -170,8 +170,8 @@ function toTaskDetailView(api: ApiTaskDetail): TaskDetailView {
|
||||
const issuerId = api.issuer?.id ?? null
|
||||
return {
|
||||
id: api.id,
|
||||
repoId: api.repoId,
|
||||
repoName: api.repoName,
|
||||
projectId: api.projectId,
|
||||
projectName: api.projectName,
|
||||
title: api.title,
|
||||
status: api.status,
|
||||
statusLabel: taskStatusLabel(api.status),
|
||||
@@ -216,10 +216,10 @@ export const useTaskStore = defineStore('task', () => {
|
||||
const taskApi = useTask()
|
||||
|
||||
// 목록 첫 페이지 조회(리셋) — 세그먼트/검색은 백엔드에서 필터, 카운트도 응답에서 수신
|
||||
async function fetchList(repoId: string, query: TaskListQuery = {}): Promise<void> {
|
||||
async function fetchList(projectId: string, query: TaskListQuery = {}): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await taskApi.list(repoId, query, 1, DEFAULT_PAGE_SIZE)
|
||||
const res = await taskApi.list(projectId, query, 1, DEFAULT_PAGE_SIZE)
|
||||
tasks.value = res.items.map(toRepoTaskView)
|
||||
total.value = res.total
|
||||
counts.value = res.counts
|
||||
@@ -230,11 +230,11 @@ export const useTaskStore = defineStore('task', () => {
|
||||
}
|
||||
|
||||
// 다음 페이지 이어붙이기(더보기) — 같은 세그먼트/검색 조건 유지
|
||||
async function loadMore(repoId: string, query: TaskListQuery = {}): Promise<void> {
|
||||
async function loadMore(projectId: string, query: TaskListQuery = {}): Promise<void> {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
loadingMore.value = true
|
||||
try {
|
||||
const res = await taskApi.list(repoId, query, page.value + 1, DEFAULT_PAGE_SIZE)
|
||||
const res = await taskApi.list(projectId, query, page.value + 1, DEFAULT_PAGE_SIZE)
|
||||
tasks.value.push(...res.items.map(toRepoTaskView))
|
||||
total.value = res.total
|
||||
counts.value = res.counts
|
||||
@@ -245,10 +245,10 @@ export const useTaskStore = defineStore('task', () => {
|
||||
}
|
||||
|
||||
// 단건 상세 조회
|
||||
async function fetchOne(repoId: string, taskId: number): Promise<TaskDetailView | null> {
|
||||
async function fetchOne(projectId: string, taskId: number): Promise<TaskDetailView | null> {
|
||||
loading.value = true
|
||||
try {
|
||||
current.value = toTaskDetailView(await taskApi.get(repoId, taskId))
|
||||
current.value = toTaskDetailView(await taskApi.get(projectId, taskId))
|
||||
return current.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -256,26 +256,26 @@ export const useTaskStore = defineStore('task', () => {
|
||||
}
|
||||
|
||||
// 생성 — 생성된 상세 반환
|
||||
async function create(repoId: string, payload: CreateTaskPayload): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.create(repoId, payload))
|
||||
async function create(projectId: string, payload: CreateTaskPayload): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.create(projectId, payload))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
// 수정
|
||||
async function update(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
payload: UpdateTaskPayload,
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.update(repoId, taskId, payload))
|
||||
const view = toTaskDetailView(await taskApi.update(projectId, taskId, payload))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
// 삭제 — 목록에서 제외하고 총계도 보정(세그먼트 카운트는 목록 재진입 시 재조회로 갱신)
|
||||
async function remove(repoId: string, taskId: number): Promise<void> {
|
||||
await taskApi.remove(repoId, taskId)
|
||||
async function remove(projectId: string, taskId: number): Promise<void> {
|
||||
await taskApi.remove(projectId, taskId)
|
||||
const before = tasks.value.length
|
||||
tasks.value = tasks.value.filter((t) => t.id !== taskId)
|
||||
if (tasks.value.length < before) total.value = Math.max(0, total.value - 1)
|
||||
@@ -284,18 +284,18 @@ export const useTaskStore = defineStore('task', () => {
|
||||
|
||||
// 상태 변경
|
||||
async function changeStatus(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
status: TaskDetailView['status'],
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.changeStatus(repoId, taskId, status))
|
||||
const view = toTaskDetailView(await taskApi.changeStatus(projectId, taskId, status))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
// 부속 변경 후 상세를 다시 받아 일관된 뷰로 갱신하는 공통 헬퍼
|
||||
async function refresh(repoId: string, taskId: number): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.get(repoId, taskId))
|
||||
async function refresh(projectId: string, taskId: number): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.get(projectId, taskId))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
@@ -303,68 +303,68 @@ export const useTaskStore = defineStore('task', () => {
|
||||
/* ===================== 댓글 / 답글 (5단계) ===================== */
|
||||
|
||||
async function addComment(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
text: string,
|
||||
): Promise<TaskDetailView> {
|
||||
await taskApi.addComment(repoId, taskId, text)
|
||||
return refresh(repoId, taskId)
|
||||
await taskApi.addComment(projectId, taskId, text)
|
||||
return refresh(projectId, taskId)
|
||||
}
|
||||
|
||||
async function addReply(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
commentId: string,
|
||||
text: string,
|
||||
): Promise<TaskDetailView> {
|
||||
await taskApi.addReply(repoId, taskId, commentId, text)
|
||||
return refresh(repoId, taskId)
|
||||
await taskApi.addReply(projectId, taskId, commentId, text)
|
||||
return refresh(projectId, taskId)
|
||||
}
|
||||
|
||||
/* ===================== 파일 첨부 (5단계) ===================== */
|
||||
|
||||
async function uploadFile(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
file: File,
|
||||
): Promise<TaskDetailView> {
|
||||
await taskApi.uploadFile(repoId, taskId, file)
|
||||
return refresh(repoId, taskId)
|
||||
await taskApi.uploadFile(projectId, taskId, file)
|
||||
return refresh(projectId, taskId)
|
||||
}
|
||||
|
||||
async function removeFile(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
fileId: string,
|
||||
): Promise<TaskDetailView> {
|
||||
await taskApi.removeFile(repoId, taskId, fileId)
|
||||
return refresh(repoId, taskId)
|
||||
await taskApi.removeFile(projectId, taskId, fileId)
|
||||
return refresh(projectId, taskId)
|
||||
}
|
||||
|
||||
/* ===================== 승인 워크플로우 (5단계) ===================== */
|
||||
|
||||
async function requestApproval(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
note?: string,
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.requestApproval(repoId, taskId, note))
|
||||
const view = toTaskDetailView(await taskApi.requestApproval(projectId, taskId, note))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
async function approve(repoId: string, taskId: number): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.approve(repoId, taskId))
|
||||
async function approve(projectId: string, taskId: number): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.approve(projectId, taskId))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
async function requestChanges(
|
||||
repoId: string,
|
||||
projectId: string,
|
||||
taskId: number,
|
||||
note: string,
|
||||
): Promise<TaskDetailView> {
|
||||
const view = toTaskDetailView(await taskApi.requestChanges(repoId, taskId, note))
|
||||
const view = toTaskDetailView(await taskApi.requestChanges(projectId, taskId, note))
|
||||
current.value = view
|
||||
return view
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 활동 이벤트 타입(백엔드 ActivityType 과 동일 문자열)
|
||||
export type ApiActivityType =
|
||||
| 'repo.created'
|
||||
| 'repo.renamed'
|
||||
| 'repo.visibility_changed'
|
||||
| 'project.created'
|
||||
| 'project.renamed'
|
||||
| 'project.visibility_changed'
|
||||
| 'repo.linked'
|
||||
| 'repo.unlinked'
|
||||
| 'member.invited'
|
||||
| 'member.role_changed'
|
||||
| 'member.removed'
|
||||
|
||||
@@ -5,9 +5,9 @@ import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 내 업무 행(API 원시) — 그룹핑/라벨은 프론트가 파생
|
||||
export interface ApiMyTaskRow {
|
||||
id: number // 저장소 내 순번(seq)
|
||||
repoId: string // slug (라우팅용)
|
||||
repoName: string
|
||||
id: number // 프로젝트 내 순번(seq)
|
||||
projectId: string // projectId (라우팅용)
|
||||
projectName: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
dueDate: string | null
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// 프로젝트 도메인 타입 — 백엔드 ProjectService 응답 / DTO 와 1:1
|
||||
|
||||
import type { Visibility } from '@/mock/relay.mock'
|
||||
import type { ApiMember } from '@/types/repo'
|
||||
|
||||
// 프로젝트(API 원시) — 백엔드 ProjectResponse 와 1:1
|
||||
export interface ApiProject {
|
||||
id: string // UUID (라우팅 식별자)
|
||||
name: string
|
||||
description: string | null
|
||||
visibility: Visibility
|
||||
isSystem: boolean // "미분류" 시스템 프로젝트
|
||||
members: ApiMember[]
|
||||
memberCount: number
|
||||
repoCount: number // 연동 저장소 수
|
||||
doneCount: number
|
||||
totalCount: number
|
||||
updatedAt: string
|
||||
// 단건 조회에서만 — 현재 사용자 권한
|
||||
canManage?: boolean // admin (설정 변경/탭 노출)
|
||||
isOwner?: boolean // 소유자 (프로젝트 삭제)
|
||||
}
|
||||
|
||||
// 프로젝트 생성 요청
|
||||
export interface CreateProjectPayload {
|
||||
name: string
|
||||
description?: string
|
||||
visibility?: Visibility
|
||||
}
|
||||
|
||||
// 프로젝트 수정 요청
|
||||
export interface UpdateProjectPayload {
|
||||
name?: string
|
||||
description?: string
|
||||
visibility?: Visibility
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import type { Paginated } from '@/types/pagination'
|
||||
|
||||
// 업무 목록 행(API 원시) — 백엔드 RepoTaskResponse 와 1:1
|
||||
export interface ApiRepoTask {
|
||||
id: number // 저장소 내 순번(seq)
|
||||
id: number // 프로젝트 내 순번(seq)
|
||||
title: string
|
||||
status: TaskStatus
|
||||
dueDate: string | null
|
||||
@@ -63,8 +63,8 @@ export interface ApiApproval {
|
||||
// 업무 상세(API 원시) — 백엔드 TaskDetailResponse 와 1:1 (activities 는 6단계)
|
||||
export interface ApiTaskDetail {
|
||||
id: number
|
||||
repoId: string
|
||||
repoName: string
|
||||
projectId: string
|
||||
projectName: string
|
||||
title: string
|
||||
status: TaskStatus
|
||||
content: string[]
|
||||
|
||||
Reference in New Issue
Block a user