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
+3 -1
View File
@@ -65,4 +65,6 @@ Project (이름/설명/공개범위) ← 작업의 기본 단위(
- Phase 1-a: Project/ProjectMember 모듈 기반(엔티티·서비스·컨트롤러·"미분류" 부팅) ✅ (커밋 2f66cdd)
- Phase 1-b: 백엔드 이전 ✅ — Activity(project FK·listForProject)·Repo(project FK, RepoMember 폐지)·Task(project 종속, seq 프로젝트 내 순번, 권한=ProjectMember) 전환. RepoService 프로젝트 종속(`/projects/:projectId/repos`, 멤버·업무카운트·캐시 제거). Task/Activity 컨트롤러 `/projects/...`. RepoSync→"미분류". AiService 프로젝트 컨텍스트. member.service/controller·repo-member·repo-cache 삭제. 검증 build/lint 0·14 tests.
- **Phase 1 잔여(소소, 후속)**: ProjectResponse 카운트(repoCount/doneCount/totalCount 현재 0 stub) 집계 배선, ProjectMemberService 활동·알림 배선(TODO), TaskService.assigneeCounts→ProjectMemberService taskCount 연결.
- 다음 = Phase 2 프론트.
- Phase 2-a: 프론트 프로젝트 중심 재구성 ✅ — `types/project`·`useProject`·`project.store` 신설. `useTask`/`useMember`/`useActivity``/projects/:projectId/...` 로, task/my-task/activity 타입·스토어를 projectId/projectName 으로 전환. 알림 store 라우팅/문구 프로젝트화. 라우팅 `/projects/...` 전면 교체. 페이지 rename(Repo*→Project*): List/Create(간단 폼)/Detail/Settings/Members/Activity. `RepoHeader``ProjectHeader`, `RepoTabs``ProjectTabs`. AppShell 네비 "프로젝트". 죽은 파일(repo.store/useRepo/GiteaRepoBar) 삭제. 검증 type-check/lint/build 0.
- **Phase 2 잔여(후속)**: **저장소 연동 UI(Phase 2b)** — 프로젝트 내 "연동 저장소" 섹션 + Gitea 저장소 링크/생성 폼(백엔드 `/projects/:id/repos` 준비됨). 일부 화면 잔여 "저장소" 문구 정리.
- 다음 = Phase 1 카운트 배선 + Phase 2b 저장소 연동 UI.
-191
View File
@@ -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>
+3 -3
View File
@@ -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 }
+1 -1
View File
@@ -46,7 +46,7 @@ export function useAi() {
// 할 일(체크리스트) 추천 — 실패는 호출측에서 인라인 처리(silent)
async function suggestChecklist(payload: {
repoId: string
projectId: string
title: string
content?: string[]
}): Promise<SuggestChecklistResult> {
+10 -10
View File
@@ -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 }
+39
View File
@@ -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 }
}
-50
View File
@@ -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 }
}
+28 -28
View File
@@ -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
}
+6 -6
View File
@@ -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>
+31 -5
View File
@@ -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시간 전 승인 요청") */
+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"
+32 -28
View File
@@ -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
+11 -7
View File
@@ -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) {
+8 -8
View File
@@ -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)
}
+4 -4
View File
@@ -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,
+13 -10
View File
@@ -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 }
+131
View File
@@ -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,
}
})
-152
View File
@@ -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,
}
})
+36 -36
View File
@@ -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
}
+5 -3
View File
@@ -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'
+3 -3
View File
@@ -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
+36
View File
@@ -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
}
+3 -3
View File
@@ -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[]