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:
2026-06-20 19:37:51 +09:00
parent 6ba148314c
commit 7cd6676ef9
35 changed files with 886 additions and 1709 deletions
+1 -1
View File
@@ -37,7 +37,7 @@ async function onLogin() {
password: password.value,
keepLogin: keepLogin.value,
})
await router.push('/repos')
await router.push('/projects')
} catch {
// 에러 메시지는 API 인터셉터에서 전역 처리됨
} finally {
+2 -2
View File
@@ -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
@@ -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>
@@ -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;
@@ -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>
@@ -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"
@@ -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"
-846
View File
@@ -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>
+18 -18
View File
@@ -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 ?? '저장소' }}
+17 -17
View File
@@ -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"