first commit

This commit is contained in:
2026-06-16 17:12:08 +09:00
commit e1bc5a1dfc
257 changed files with 49823 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
.env.development
.env.production
+5
View File
@@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"oxc.oxc-vscode"
]
}
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-alpine AS build-stage
WORKDIR /app
# 호스트의 npm 11 이 작성한 package-lock.json 형식과 호환되도록 npm 을 11 로 업그레이드
RUN npm install -g npm@11
COPY package*.json ./
RUN npm ci
COPY . .
ARG BUILD_MODE=production
ARG VITE_API_BASE_URL
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build-only -- --mode ${BUILD_MODE}
FROM nginx:stable-alpine AS production-stage
# 보안 헤더 포함된 nginx 설정 적용
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build-stage /app/dist /usr/share/nginx/html
# 비-root 실행은 nginx-unprivileged 이미지 도입 시 활성화 (현재는 80 포트 바인드 필요)
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+48
View File
@@ -0,0 +1,48 @@
# frontend
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
npm run test:unit
```
+10
View File
@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
// Vite 환경변수 타입 정의 — VITE_ prefix 만 클라이언트 번들에 포함됨
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+17
View File
@@ -0,0 +1,17 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsEslintConfig from '@vue/eslint-config-typescript'
// Flat config — Vue 3 + TypeScript 표준 권장 룰
export default [
...pluginVue.configs['flat/recommended'],
...vueTsEslintConfig(),
{
rules: {
// 컴포넌트 단어 수 강제 해제 (HomePage 등 단일 컴포넌트도 허용)
'vue/multi-word-component-names': 'off',
},
},
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**'],
},
]
+20
View File
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Relay — 사내 업무 지시 플랫폼" />
<!-- Pretendard 웹폰트 (디자인 시안 기준 서체) -->
<link rel="preconnect" href="https://cdn.jsdelivr.net" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.css"
/>
<title>Relay</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
server {
listen 80;
server_name localhost;
# ----------------------------------------
# 보안 헤더
# ----------------------------------------
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
# ----------------------------------------
# 압축 (Gzip)
# ----------------------------------------
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;
# ----------------------------------------
# SPA 라우팅
# ----------------------------------------
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
# 정적 자원 캐싱 (SPA 빌드 산출물은 해시 파일명이라 장기 캐싱 가능)
location ~* \.(?:js|css|woff2?|ttf|svg|png|jpg|jpeg|gif|ico)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
# 숨김 파일 접근 차단
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}
+7053
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
{
"name": "frontend",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "eslint . --fix",
"format": "oxfmt src/"
},
"dependencies": {
"axios": "^1.14.0",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitejs/plugin-vue-jsx": "^5.1.5",
"@vue/eslint-config-typescript": "^14.2.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.1",
"eslint": "^9.18.0",
"eslint-plugin-vue": "^9.32.0",
"jsdom": "^29.0.1",
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.42.0",
"typescript": "~6.0.0",
"vite": "^8.0.3",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.2",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
// 최상위 레이아웃 — 라우터 뷰만 마운트, 도메인 별 레이아웃은 라우트 단위로 분리
import { RouterView } from 'vue-router'
</script>
<template>
<RouterView />
</template>
<style scoped></style>
+598
View File
@@ -0,0 +1,598 @@
/**
* Relay 디자인 시스템 — 전역 스타일
* - 디자인 토큰(CSS 변수)과 화면 전반에서 공유하는 공통 컴포넌트 클래스를 정의한다.
* - 시안(px) → rem 변환(기준 16px = 1rem). 단, 1px 경계선/그림자는 px 유지(헤어라인 관례).
* - 화면 고유 스타일은 각 SFC 의 <style scoped> 에 둔다.
*/
/* ============================================================
* 1. 디자인 토큰
* ============================================================ */
:root {
/* 표면 / 경계 */
--bg: #f4f5f7;
--panel: #ffffff;
--border: #e6e8ec;
--border-strong: #d7dae0;
/* 텍스트 */
--text: #16181d;
--text-2: #5b626e;
--text-3: #9298a3;
/* 강조(보라) */
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-weak: #eef0fe;
--accent-border: #c7ccfb;
/* 상태 색상 */
--green: #1f9d57;
--green-weak: #e8f6ee;
--green-border: #cde8d8;
--blue: #1d4ed8;
--blue-weak: #e8efff;
--blue-border: #c9dbff;
--red: #d6455d;
--red-weak: #fdeef0;
--red-border: #f1c0c8;
--amber: #b7791f;
--amber-weak: #fdf4e3;
--amber-border: #f3e2bd;
/* 소셜 */
--kakao: #fee500;
--kakao-text: #191600;
/* 형태 */
--radius: 6px;
--radius-sm: 4px;
/* 그림자 */
--shadow-sm: 0 1px 2px rgba(20, 24, 33, 0.05);
--shadow-md: 0 4px 16px rgba(20, 24, 33, 0.1), 0 1px 3px rgba(20, 24, 33, 0.06);
--shadow-pop: 0 8px 28px rgba(20, 24, 33, 0.16), 0 2px 6px rgba(20, 24, 33, 0.08);
}
/* ============================================================
* 2. 리셋 / 베이스
* ============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
background: var(--bg);
color: var(--text);
font-family: 'Pretendard', -apple-system, system-ui, sans-serif;
font-size: 14px; /* 기준 본문 크기(시안 기준) */
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body {
min-height: 100vh;
}
#app {
min-height: 100vh;
}
/* ============================================================
* 3. 페이지 컨테이너 / 브레드크럼
* ============================================================ */
.page {
max-width: 67.5rem; /* 1080px */
margin: 0 auto;
padding: 0 1.5rem 4.375rem;
}
.crumb {
display: flex;
align-items: center;
gap: 0.4375rem;
color: var(--text-3);
font-size: 0.781rem;
padding: 1.125rem 0 0;
white-space: nowrap;
}
.crumb .lnk {
color: var(--text-2);
font-weight: 600;
text-decoration: none;
}
.crumb .lnk:hover {
color: var(--accent);
}
.crumb .sep {
opacity: 0.5;
}
.crumb b {
color: var(--text-2);
font-weight: 600;
}
/* ============================================================
* 4. 버튼
* ============================================================ */
.btn {
height: 2.25rem;
padding: 0 0.9375rem;
border-radius: var(--radius);
font-size: 0.844rem;
font-weight: 600;
border: 1px solid var(--border-strong);
background: var(--panel);
color: var(--text-2);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.375rem;
line-height: 1;
text-decoration: none;
white-space: nowrap;
font-family: inherit;
}
.btn:hover {
background: #f7f8fa;
color: var(--text);
}
.btn svg {
width: 0.9375rem;
height: 0.9375rem;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.btn.primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
color: #fff;
}
.btn.ghost {
border-color: transparent;
background: transparent;
}
.btn.ghost:hover {
background: #eceef1;
}
.btn.sm {
height: 1.875rem;
padding: 0 0.75rem;
font-size: 0.781rem;
}
.btn.icon {
width: 2.125rem;
padding: 0;
justify-content: center;
}
/* ============================================================
* 5. 아바타
* ============================================================ */
.avatar {
width: 1.3125rem;
height: 1.3125rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.625rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.av-a {
background: #e0518d;
}
.av-b {
background: #3d8bf2;
}
.av-c {
background: #16a37b;
}
.av-d {
background: #b3631f;
}
.av-e {
background: #7c5cf0;
}
.av-f {
background: #d0982a;
}
.av-more {
background: #eceef1;
color: var(--text-2);
box-shadow: 0 0 0 2px #fff;
}
/* 겹쳐 쌓는 아바타 스택 */
.avstack {
display: flex;
align-items: center;
}
.avstack .avatar {
margin-left: -0.4375rem;
box-shadow: 0 0 0 2px #fff;
}
.avstack .avatar:first-child {
margin-left: 0;
}
/* ============================================================
* 6. 상태 배지 / 상태 점 / 공개여부 배지 / 카운트 필
* ============================================================ */
.st-badge {
font-size: 0.719rem;
font-weight: 600;
padding: 0.1875rem 0.625rem;
border-radius: 20px;
white-space: nowrap;
text-align: center;
}
.st-badge.prog {
color: var(--blue);
background: var(--blue-weak);
}
.st-badge.review {
color: var(--amber);
background: var(--amber-weak);
}
.st-badge.changes {
color: var(--red);
background: var(--red-weak);
}
.st-badge.done {
color: var(--green);
background: var(--green-weak);
}
.st-badge.todo {
color: var(--text-2);
background: #f1f2f4;
}
/* 상태 점(목록/그룹 헤더 공용) */
.st-dot {
width: 0.5625rem;
height: 0.5625rem;
border-radius: 50%;
flex-shrink: 0;
}
.st-dot.done {
background: var(--green);
}
.st-dot.prog {
background: var(--blue);
}
.st-dot.review {
background: var(--amber);
}
.st-dot.todo {
background: #c4c8cf;
}
.st-dot.changes {
background: var(--red);
}
/* 공개여부 배지 */
.vis {
font-size: 0.6875rem;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 20px;
display: inline-flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
line-height: 1.5;
}
.vis svg {
width: 0.6875rem;
height: 0.6875rem;
}
.vis.private {
color: var(--text-2);
background: #f1f2f4;
border: 1px solid var(--border);
}
.vis.public {
color: #1b7a3d;
background: var(--green-weak);
border: 1px solid var(--green-border);
}
/* 카운트 필 */
.count-pill {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 20px;
padding: 0.125rem 0.6875rem;
}
/* ============================================================
* 7. 진행률 바
* ============================================================ */
.pbar {
height: 0.375rem;
border-radius: 4px;
background: #eceef1;
overflow: hidden;
}
.pbar > i {
display: block;
height: 100%;
border-radius: 4px;
background: var(--green);
}
.pbar > i.mid {
background: var(--accent);
}
.pbar > i.low {
background: var(--amber);
}
/* ============================================================
* 8. 툴바 구성요소 (검색 / 세그먼트 / 정렬)
* ============================================================ */
.toolbar {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.875rem;
}
.search {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.25rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.6875rem;
background: #fff;
width: 17.5rem;
}
.search:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.search svg {
width: 1rem;
height: 1rem;
color: var(--text-3);
}
.search input {
border: none;
outline: none;
flex: 1;
min-width: 0;
font-family: inherit;
font-size: 0.844rem;
background: transparent;
}
.search input::placeholder {
color: var(--text-3);
}
.segmented {
display: flex;
background: #eceef1;
border-radius: var(--radius);
padding: 0.1875rem;
gap: 0.125rem;
}
.segmented button {
border: none;
background: transparent;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
padding: 0.3125rem 0.8125rem;
border-radius: 4px;
cursor: pointer;
white-space: nowrap;
display: flex;
align-items: center;
gap: 0.375rem;
}
.segmented button.active {
background: #fff;
color: var(--text);
box-shadow: var(--shadow-sm);
}
.segmented button .n {
font-size: 0.6875rem;
color: var(--text-3);
font-weight: 600;
}
.segmented button.active .n {
color: var(--accent);
}
.sort {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.375rem;
height: 2.25rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.6875rem;
background: #fff;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
}
.sort svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
/* ============================================================
* 9. 카드 / 목록 컨테이너
* ============================================================ */
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
}
.list {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
overflow: hidden;
}
/* ============================================================
* 10. 모달 공통 백드롭 (세부 모달은 각 화면에서 정의)
* ============================================================ */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(20, 24, 33, 0.5);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
padding: 1.5rem;
}
.modal-backdrop.open {
display: flex;
}
.modal {
width: 100%;
max-width: 27.5rem;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
overflow: hidden;
}
.modal-body {
padding: 1.625rem 1.5rem 0.5rem;
text-align: center;
}
.modal-body.left {
text-align: left;
padding: 1.375rem 1.5rem 0.5rem;
}
.modal-ico {
width: 2.875rem;
height: 2.875rem;
border-radius: 50%;
background: var(--red-weak);
color: var(--red);
display: grid;
place-items: center;
margin: 0 auto 0.875rem;
}
.modal-ico.approve {
background: var(--green-weak);
color: var(--green);
}
.modal-ico svg {
width: 1.375rem;
height: 1.375rem;
}
.modal-title {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.01875rem;
}
.modal-desc {
font-size: 0.8125rem;
color: var(--text-2);
line-height: 1.6;
margin-top: 0.5rem;
}
.modal-desc.spaced {
margin: 0.5rem 0 0.875rem;
}
.modal-desc b {
color: var(--text);
font-weight: 600;
}
.modal-foot {
display: flex;
gap: 0.5625rem;
padding: 1.125rem 1.5rem 1.25rem;
}
.mbtn {
flex: 1;
height: 2.5rem;
border-radius: var(--radius);
font-size: 0.844rem;
font-weight: 600;
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text-2);
cursor: pointer;
font-family: inherit;
}
.mbtn:hover {
background: #f1f2f4;
color: var(--text);
}
.mbtn.danger {
background: var(--red);
border-color: var(--red);
color: #fff;
box-shadow: 0 1px 2px rgba(214, 69, 93, 0.4);
}
.mbtn.danger:hover {
background: #c23a51;
}
.mbtn.approve {
background: var(--green);
border-color: var(--green);
color: #fff;
box-shadow: 0 1px 2px rgba(31, 157, 87, 0.4);
}
.mbtn.approve:hover {
background: #188a4b;
}
.mbtn.request-send {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.mbtn.request-send:hover {
background: var(--accent-hover);
}
.modal-textarea {
width: 100%;
min-height: 5.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.625rem 0.75rem;
font-family: inherit;
font-size: 0.844rem;
line-height: 1.6;
color: var(--text);
resize: vertical;
}
.modal-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.modal-textarea::placeholder {
color: var(--text-3);
}
+223
View File
@@ -0,0 +1,223 @@
<script setup lang="ts">
// 저장소 헤더 카드 — 상세/멤버/활동/설정 화면이 공유
import { computed } from 'vue'
import { RouterLink } from 'vue-router'
import type { Repo, User } from '@/mock/relay.mock'
interface Props {
repo: Repo
/** 메타 우측 텍스트(예: "2시간 전 업데이트", "멤버 5명") */
metaText: string
/** 아바타 스택 override (기본: repo.members) */
members?: User[]
/** +N 표기 (기본: repo.moreCount) */
moreCount?: number
}
const props = defineProps<Props>()
const avatars = computed(() => props.members ?? props.repo.members)
const extra = computed(() => props.moreCount ?? props.repo.moreCount)
</script>
<template>
<div class="repo-head">
<div class="repo-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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>
</div>
<div class="rh-main">
<div class="rh-title">
<span class="rh-name">{{ repo.name }}</span>
<span
class="vis"
:class="repo.visibility"
>
<svg
v-if="repo.visibility === 'private'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.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>
{{ 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"
:key="m.id"
class="avatar"
:class="m.color"
>{{ m.initial }}</span>
<span
v-if="extra > 0"
class="avatar av-more"
>+{{ extra }}</span>
</span>
<span class="mi">{{ metaText }}</span>
</div>
<!-- 진행률 스트립 추가 영역 -->
<slot name="extra" />
</div>
<div class="rh-actions">
<slot name="actions">
<RouterLink
class="btn primary"
:to="`/repos/${repo.id}/tasks/new`"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14M12 5v14" />
</svg>
업무
</RouterLink>
</slot>
</div>
</div>
</template>
<style scoped>
.repo-head {
display: flex;
align-items: flex-start;
gap: 1rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
padding: 1.25rem 1.375rem;
box-shadow: var(--shadow-sm);
}
.repo-ico {
width: 2.875rem;
height: 2.875rem;
border-radius: 0.625rem;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
flex-shrink: 0;
}
.repo-ico svg {
width: 1.5rem;
height: 1.5rem;
}
.rh-main {
flex: 1;
min-width: 0;
}
.rh-title {
display: flex;
align-items: center;
gap: 0.625rem;
}
.rh-name {
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.01875rem;
white-space: nowrap;
}
.rh-slug {
font-size: 0.781rem;
color: var(--text-3);
margin-top: 0.3125rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.rh-desc {
font-size: 0.844rem;
color: var(--text-2);
margin-top: 0.375rem;
}
.rh-meta {
display: flex;
align-items: center;
gap: 0.9375rem;
margin-top: 0.75rem;
font-size: 0.75rem;
color: var(--text-3);
}
.rh-meta .mi {
display: flex;
align-items: center;
gap: 0.3125rem;
white-space: nowrap;
}
.rh-meta .mi svg {
width: 0.8125rem;
height: 0.8125rem;
}
.rh-actions {
display: flex;
align-items: center;
gap: 0.5rem;
flex-shrink: 0;
}
</style>
+88
View File
@@ -0,0 +1,88 @@
<script setup lang="ts">
// 저장소 서브탭 — 업무/멤버/활동/설정
import { RouterLink } from 'vue-router'
interface Props {
repoId: string
active: 'tasks' | 'members' | 'activity' | 'settings'
taskCount: number
memberCount: number
}
defineProps<Props>()
</script>
<template>
<nav class="tabs">
<RouterLink
:to="`/repos/${repoId}`"
:class="{ active: active === 'tasks' }"
>
<span class="tb-label">업무</span> <span class="tb-count">{{ taskCount }}</span>
</RouterLink>
<RouterLink
:to="`/repos/${repoId}/members`"
:class="{ active: active === 'members' }"
>
<span class="tb-label">멤버</span> <span class="tb-count">{{ memberCount }}</span>
</RouterLink>
<RouterLink
:to="`/repos/${repoId}/activity`"
:class="{ active: active === 'activity' }"
>
<span class="tb-label">활동</span>
</RouterLink>
<RouterLink
:to="`/repos/${repoId}/settings`"
:class="{ active: active === 'settings' }"
>
<span class="tb-label">설정</span>
</RouterLink>
</nav>
</template>
<style scoped>
.tabs {
display: flex;
gap: 0.125rem;
border-bottom: 1px solid var(--border);
margin: 1.25rem 0 1.125rem;
}
.tabs a {
padding: 0.625rem 0.875rem;
font-size: 0.844rem;
font-weight: 600;
color: var(--text-2);
text-decoration: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
display: flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
}
.tabs a:hover {
color: var(--text);
}
.tabs a.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tabs a .tb-count {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-2);
background: #f1f2f4;
border-radius: 20px;
padding: 0 0.4375rem;
height: 1.0625rem;
min-width: 1.0625rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.tabs a.active .tb-count {
background: var(--accent-weak);
color: var(--accent);
}
</style>
+128
View File
@@ -0,0 +1,128 @@
import axios, {
type AxiosInstance,
type AxiosResponse,
type InternalAxiosRequestConfig,
} from 'axios'
// axios 요청 설정 확장 — 호출 단위 옵션
declare module 'axios' {
export interface AxiosRequestConfig {
/** true 면 에러 발생 시 전역 알림(UI) 을 띄우지 않는다 (예: 부트스트랩용 /auth/me) */
silent?: boolean
/** 내부용 — 401 재시도(토큰 refresh) 1회 수행 여부 플래그 */
_retry?: boolean
}
}
// 표준 응답 포맷 타입 (Global Response Wrapper) — 백엔드 TransformInterceptor와 동일 구조
export interface StandardResponse<T = unknown> {
success: boolean
data?: T
error?: {
code: string
message: string
}
}
// 에러 코드 사전 — CLAUDE.md / 백엔드 HttpExceptionFilter 와 1:1 매핑
const ErrorCodeLexicon: Record<string, string> = {
SYS_001: '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.',
AUTH_001: '로그인이 필요한 서비스입니다. 로그인 후 이용해 주세요.',
AUTH_002: '안전을 위해 로그아웃 되었습니다. 다시 로그인해 주세요.',
AUTH_003: '해당 메뉴나 기능에 접근할 수 있는 권한이 없습니다.',
VAL_001: '입력하신 정보를 다시 확인해 주세요. (필수값 누락 또는 형식 오류)',
RES_001: '요청하신 정보나 페이지를 찾을 수 없습니다.',
BIZ_001: '요청하신 작업을 완료하지 못했습니다. 다시 시도해 주세요.',
}
// 사용자 노출용 알림 — Toast 라이브러리 도입 시 이 함수만 교체하면 됨
const showErrorUI = (message: string) => {
window.alert(message)
}
// 에러 코드 → 메시지 변환 (미정의 코드는 SYS_001 로 폴백, 항상 문자열 보장)
const SYSTEM_ERROR_MESSAGE =
ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.'
const resolveErrorMessage = (code: string): string => ErrorCodeLexicon[code] ?? SYSTEM_ERROR_MESSAGE
// 사용자 노출 메시지 결정 — 비즈니스 예외(BIZ_001)는 서버가 내려준 구체 문구를 우선 사용
// (CLAUDE.md: "비즈니스 예외는 message 로 사용자 노출 문구를 재정의할 수 있다")
const pickMessage = (code: string, serverMessage?: string): string =>
code === 'BIZ_001' && serverMessage ? serverMessage : resolveErrorMessage(code)
// 중앙 Axios 인스턴스
const api: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
timeout: 10000,
// HttpOnly/Secure 쿠키 인증 시 자격 증명을 함께 전송
withCredentials: true,
})
// 요청 인터셉터 — CSRF, 추가 헤더 등 필요 시 확장
api.interceptors.request.use(
(config: InternalAxiosRequestConfig) => config,
(error: unknown) => Promise.reject(error),
)
// 인증 만료 응답으로 판단할지 — /auth/* 자체 호출은 재시도 대상에서 제외
const isAuthEndpoint = (url?: string): boolean => (url ?? '').includes('/auth/')
// 응답 인터셉터 — Global Response Wrapper 언래핑 + 401 자동 refresh + 에러코드 매핑
api.interceptors.response.use(
(response: AxiosResponse<StandardResponse>) => {
const payload = response.data
if (payload && typeof payload.success === 'boolean') {
if (payload.success) {
// 성공 시 data 만 언래핑하여 반환
return payload.data as never
}
// HTTP 200 이지만 비즈니스 로직상 실패인 경우
const errCode = payload.error?.code || 'BIZ_001'
if (!response.config.silent) showErrorUI(pickMessage(errCode, payload.error?.message))
return Promise.reject(payload.error)
}
return payload as never
},
async (error) => {
const config = error?.config as (InternalAxiosRequestConfig & { _retry?: boolean }) | undefined
const status: number | undefined = error?.response?.status
// access 토큰 만료(401) → refresh 1회 시도 후 원요청 재시도
if (status === 401 && config && !config._retry && !isAuthEndpoint(config.url)) {
config._retry = true
try {
await api.post('/auth/refresh', null, { silent: true })
return api(config)
} catch {
// refresh 실패 → 세션 만료로 간주, 아래 일반 에러 처리로 진행
}
}
let errCode = 'SYS_001'
let serverMessage: string | undefined
if (error?.response) {
const payload = error.response.data as StandardResponse | undefined
serverMessage = payload?.error?.message
if (payload?.error?.code) {
errCode = payload.error.code
} else if (status === 401) errCode = 'AUTH_001'
else if (status === 403) errCode = 'AUTH_003'
else if (status === 400 || status === 422) errCode = 'VAL_001'
else if (status === 404) errCode = 'RES_001'
}
if (!config?.silent) showErrorUI(pickMessage(errCode, serverMessage))
return Promise.reject(error)
},
)
/**
* useApi — 컴포넌트에서 axios 인스턴스를 가져올 때 사용하는 composable
* 직접 import 대신 useApi() 사용을 권장 (테스트 시 모킹 용이)
*/
export function useApi() {
return api
}
export default api
+30
View File
@@ -0,0 +1,30 @@
import { useApi } from './useApi'
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
// 인증 도메인 API Composable — 인터셉터가 success/data 를 언래핑하므로 data 타입으로 캐스팅
export function useAuth() {
const api = useApi()
// 회원가입 (성공 시 백엔드가 인증 쿠키를 설정해 자동 로그인됨)
async function signup(payload: SignupPayload): Promise<AuthUser> {
return (await api.post('/auth/signup', payload)) as unknown as AuthUser
}
// 로그인
async function login(payload: LoginPayload): Promise<AuthUser> {
return (await api.post('/auth/login', payload)) as unknown as AuthUser
}
// 로그아웃 (쿠키 제거)
async function logout(): Promise<void> {
await api.post('/auth/logout')
}
// 현재 로그인 사용자 조회 — 부트스트랩/가드용. 미인증이면 throw 되므로 호출측에서 처리
async function fetchMe(): Promise<AuthUser> {
// silent: 미인증(401) 시 전역 알림을 띄우지 않는다
return (await api.get('/auth/me', { silent: true })) as unknown as AuthUser
}
return { signup, login, logout, fetchMe }
}
+29
View File
@@ -0,0 +1,29 @@
import { useApi } from './useApi'
import type { ApiRepo, CreateRepoPayload, UpdateRepoPayload } from '@/types/repo'
// 저장소 도메인 API Composable — 인터셉터가 success/data 를 언래핑
export function useRepo() {
const api = useApi()
async function list(): Promise<ApiRepo[]> {
return (await api.get('/repos')) as unknown as ApiRepo[]
}
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, get, create, update, remove }
}
+301
View File
@@ -0,0 +1,301 @@
<script setup lang="ts">
// 앱 공통 셸 — 상단 탑바(브랜드/네비/우측 액션) + 본문 슬롯
// 모든 인증 이후 화면이 이 레이아웃을 공유한다.
import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
// 현재 경로 기준으로 상단 네비 활성 항목을 판별
const activeNav = computed<'tasks' | 'repos'>(() =>
route.path.startsWith('/repos') ? 'repos' : 'tasks',
)
// 현재 로그인 사용자 표시(아바타 이니셜 = 이름 첫 글자)
const me = computed(() => authStore.user)
const meInitial = computed(() => me.value?.name?.charAt(0) ?? '?')
// 사용자 메뉴 토글
const menuOpen = ref(false)
// 로그아웃 → 로그인 화면으로
async function onLogout() {
menuOpen.value = false
await authStore.logout()
await router.push('/login')
}
</script>
<template>
<header class="topbar">
<RouterLink
to="/repos"
class="brand"
>
<div class="logo">
R
</div>
Relay
</RouterLink>
<nav class="topnav">
<RouterLink
to="/tasks"
:class="{ active: activeNav === 'tasks' }"
>
업무
</RouterLink>
<RouterLink
to="/repos"
:class="{ active: activeNav === 'repos' }"
>
저장소
</RouterLink>
</nav>
<div class="topbar-right">
<!-- 화면별 추가 액션(: AI 에이전트 버튼) 끼워 넣는 슬롯 -->
<slot name="actions" />
<button
class="icon-btn"
type="button"
title="검색"
aria-label="검색"
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle
cx="11"
cy="11"
r="7"
/>
<path d="m21 21-4-4" />
</svg>
</button>
<button
class="icon-btn"
type="button"
title="알림"
aria-label="알림"
>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
</button>
<div class="me-wrap">
<button
class="me"
type="button"
:title="me?.name ?? '사용자'"
:aria-label="`${me?.name ?? '사용자'} 메뉴`"
aria-haspopup="menu"
:aria-expanded="menuOpen"
@click="menuOpen = !menuOpen"
>
{{ meInitial }}
</button>
<!-- 사용자 드롭다운 -->
<div
v-if="menuOpen"
class="me-menu"
role="menu"
>
<div class="me-info">
<div class="me-name">
{{ me?.name }}
</div>
<div class="me-email">
{{ me?.email }}
</div>
</div>
<button
class="me-action"
type="button"
role="menuitem"
@click="onLogout"
>
로그아웃
</button>
</div>
<!-- 메뉴 바깥 클릭 닫기 -->
<div
v-if="menuOpen"
class="me-overlay"
@click="menuOpen = false"
/>
</div>
</div>
</header>
<slot />
</template>
<style scoped>
.topbar {
height: 3.25rem;
background: var(--panel);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 1.75rem;
padding: 0 1.25rem;
position: sticky;
top: 0;
z-index: 50;
}
.brand {
display: flex;
align-items: center;
gap: 0.5625rem;
font-weight: 700;
font-size: 0.9375rem;
letter-spacing: -0.0125rem;
color: var(--text);
text-decoration: none;
}
.brand .logo {
width: 1.625rem;
height: 1.625rem;
border-radius: 0.4375rem;
background: linear-gradient(135deg, #5b54f0, #4338ca);
display: grid;
place-items: center;
color: #fff;
font-size: 0.9375rem;
font-weight: 800;
box-shadow: 0 2px 6px rgba(79, 70, 229, 0.35);
}
.topnav {
display: flex;
align-items: center;
gap: 0.125rem;
}
.topnav a {
color: var(--text-2);
text-decoration: none;
font-size: 0.844rem;
font-weight: 500;
padding: 0.4375rem 0.75rem;
border-radius: var(--radius-sm);
line-height: 1;
white-space: nowrap;
}
.topnav a:hover {
background: #f1f2f4;
color: var(--text);
}
.topnav a.active {
color: var(--accent);
background: var(--accent-weak);
font-weight: 600;
}
.topbar-right {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.icon-btn {
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-2);
display: grid;
place-items: center;
cursor: pointer;
}
.icon-btn:hover {
background: #f1f2f4;
color: var(--text);
}
.me-wrap {
position: relative;
margin-left: 0.25rem;
}
.me {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.719rem;
font-weight: 700;
color: #fff;
background: #0ea5a3;
border: none;
cursor: pointer;
padding: 0;
}
/* 드롭다운 메뉴 */
.me-menu {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 60;
min-width: 11rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
overflow: hidden;
}
.me-info {
padding: 0.75rem 0.875rem;
border-bottom: 1px solid var(--border);
}
.me-name {
font-size: 0.844rem;
font-weight: 600;
color: var(--text);
}
.me-email {
margin-top: 0.125rem;
font-size: 0.75rem;
color: var(--text-3);
}
.me-action {
width: 100%;
text-align: left;
padding: 0.625rem 0.875rem;
border: none;
background: transparent;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
cursor: pointer;
}
.me-action:hover {
background: #f1f2f4;
}
/* 메뉴 바깥 클릭 감지용 투명 오버레이 */
.me-overlay {
position: fixed;
inset: 0;
z-index: 55;
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
// Relay 디자인 시스템 전역 스타일(토큰 + 공통 컴포넌트 클래스)
import '@/assets/styles/relay.css'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
+910
View File
@@ -0,0 +1,910 @@
/**
* Relay 목업 데이터 — 프론트 UI 우선 구성용 정적 데이터
* 추후 backend API 연동 시 composable/store 에서 이 데이터를 실제 응답으로 교체한다.
*/
/** 아바타 색상 키 (relay.css 의 .av-* 클래스와 대응) */
export type AvatarColor = 'av-a' | 'av-b' | 'av-c' | 'av-d' | 'av-e' | 'av-f'
/** 업무 상태 */
export type TaskStatus = 'todo' | 'prog' | 'review' | 'done' | 'changes'
/** 저장소 공개 여부 */
export type Visibility = 'private' | 'public'
/** 사용자(멤버) */
export interface User {
id: string
name: string
role: string
initial: string
color: AvatarColor
}
/** 첨부파일 */
export interface Attachment {
name: string
size: string
/** 파일 아이콘 종류 */
kind: 'pdf' | 'zip' | 'doc' | 'img'
}
/** 체크리스트 항목 */
export interface ChecklistItem {
text: string
done: boolean
}
/** 저장소 */
export interface Repo {
/** 라우팅 식별자(slug 의 마지막 segment) */
id: string
name: string
owner: string
slug: string
desc: string
visibility: Visibility
branch: string
members: User[]
/** 멤버 스택에서 추가로 더 있는 인원 수(+N) */
moreCount: number
updatedAgo: string
doneCount: number
totalCount: number
progressPct: number
/** 진행률 바 색상 단계 */
progressLevel: 'done' | 'mid' | 'low'
/** 진행률 텍스트(예: "67% 완료", "시작 전") */
progressLabel: string
}
/** 저장소 상세의 업무 목록 행 */
export interface RepoTask {
id: number
title: string
status: TaskStatus
/** 마감 라벨(D-3 / 2일 지남 / 6월 9일 완료 등) */
dueLabel?: string
overdue?: boolean
/** 체크리스트 [완료, 전체] */
checklist?: [number, number]
/** 코멘트 수 */
comments?: number
assignees: User[]
}
/** 댓글의 답글 */
export interface Reply {
author: User
time: string
text: string
}
/** 댓글 */
export interface Comment {
author: User
/** 작성자 역할 배지(예: 지시자) */
roleBadge?: string
time: string
text: string
file?: string
replies: Reply[]
}
/** 활동 타임라인 항목 */
export interface Activity {
/** 아이콘 색상 톤 */
tone: '' | 'green' | 'amber' | 'blue' | 'accent'
/** 아이콘 종류 */
icon: 'check-circle' | 'check' | 'paperclip' | 'calendar' | 'users' | 'flag'
/** 본문(굵게 처리는 컴포넌트에서 b 태그로) */
html: string
time: string
file?: string
statusFrom?: string
statusTo?: string
}
/** 업무 상세 */
export interface TaskDetail {
id: number
repoId: string
repoName: string
title: string
status: TaskStatus
statusLabel: string
/** 본문 문단 (멘션은 @로 시작) */
content: string[]
checklist: ChecklistItem[]
comments: Comment[]
activities: Activity[]
assignees: User[]
issuer: User
dueLabel: string
dday: string
files: Attachment[]
/** 승인 요청 정보(승인 대기 상태일 때) */
approval?: {
requester: string
requestedAgo: string
note: string
}
issuedLabel: string
}
/* ============================================================
* 사용자
* ============================================================ */
// satisfies 로 선언해 각 키 접근이 `User | undefined` 로 넓어지지 않도록 한다
// (tsconfig 의 noUncheckedIndexedAccess 대응)
export const USERS = {
kim: { id: 'kim', name: '김서연', role: '콘텐츠 기획', initial: '김', color: 'av-a' },
park: { id: 'park', name: '박지훈', role: '퍼포먼스 마케팅', initial: '박', color: 'av-b' },
lee: { id: 'lee', name: '이도윤', role: '카피라이팅', initial: '이', color: 'av-c' },
choi: { id: 'choi', name: '최유진', role: 'UX 디자인', initial: '최', color: 'av-d' },
han: { id: 'han', name: '한승우', role: '브랜드 전략', initial: '한', color: 'av-e' },
jung: { id: 'jung', name: '정태경', role: '마케팅 그룹 리드', initial: '정', color: 'av-f' },
} satisfies Record<string, User>
/* ============================================================
* 저장소 목록
* ============================================================ */
export const REPOS: Repo[] = [
{
id: 'q3-launch-campaign',
name: '3분기 신제품 런칭 캠페인',
owner: 'marketing-team',
slug: 'marketing-team/q3-launch-campaign',
desc: '3분기 신제품(라인 클렌저) 런칭 캠페인 소재 제작 및 검수',
visibility: 'private',
branch: 'main',
members: [USERS.kim, USERS.park, USERS.lee],
moreCount: 2,
updatedAgo: '2시간 전 업데이트',
doneCount: 8,
totalCount: 12,
progressPct: 67,
progressLevel: 'mid',
progressLabel: '67% 완료',
},
{
id: 'brand-refresh-2026',
name: '브랜드 리뉴얼 2026',
owner: 'marketing-team',
slug: 'marketing-team/brand-refresh-2026',
desc: '브랜드 아이덴티티 리뉴얼 및 가이드라인 개편 프로젝트',
visibility: 'private',
branch: 'main',
members: [USERS.choi, USERS.jung, USERS.han],
moreCount: 1,
updatedAgo: '어제 업데이트',
doneCount: 5,
totalCount: 9,
progressPct: 56,
progressLevel: 'mid',
progressLabel: '56% 완료',
},
{
id: 'monthly-newsletter',
name: '월간 뉴스레터 운영',
owner: 'marketing-team',
slug: 'marketing-team/monthly-newsletter',
desc: '월간 뉴스레터 기획 · 콘텐츠 작성 · 발송 정기 운영',
visibility: 'public',
branch: 'main',
members: [USERS.lee, USERS.park, USERS.kim],
moreCount: 0,
updatedAgo: '3일 전 업데이트',
doneCount: 14,
totalCount: 14,
progressPct: 100,
progressLevel: 'done',
progressLabel: '100% 완료',
},
{
id: 'seo-content-hub',
name: 'SEO 콘텐츠 허브',
owner: 'marketing-team',
slug: 'marketing-team/seo-content-hub',
desc: '검색 유입 강화를 위한 SEO 콘텐츠 기획 및 제작 허브',
visibility: 'private',
branch: 'main',
members: [USERS.park, USERS.han, USERS.lee],
moreCount: 3,
updatedAgo: '5시간 전 업데이트',
doneCount: 2,
totalCount: 11,
progressPct: 18,
progressLevel: 'low',
progressLabel: '18% 완료',
},
{
id: 'event-handbook',
name: '사내 행사 핸드북',
owner: 'marketing-team',
slug: 'marketing-team/event-handbook',
desc: '사내 행사 운영 가이드 및 준비 체크리스트 모음',
visibility: 'public',
branch: 'main',
members: [USERS.jung, USERS.kim],
moreCount: 0,
updatedAgo: '1주 전 업데이트',
doneCount: 0,
totalCount: 4,
progressPct: 0,
progressLevel: 'low',
progressLabel: '시작 전',
},
{
id: 'design-system-assets',
name: '디자인 시스템 에셋',
owner: 'marketing-team',
slug: 'marketing-team/design-system-assets',
desc: '공통 디자인 시스템 에셋 및 컴포넌트 관리',
visibility: 'private',
branch: 'develop',
members: [USERS.kim, USERS.choi, USERS.lee],
moreCount: 1,
updatedAgo: '30분 전 업데이트',
doneCount: 9,
totalCount: 10,
progressPct: 90,
progressLevel: 'done',
progressLabel: '90% 완료',
},
]
/* ============================================================
* 저장소 상세 — 업무 목록 (q3-launch-campaign 기준)
* ============================================================ */
export const REPO_TASKS: Record<string, RepoTask[]> = {
'q3-launch-campaign': [
{
id: 11,
title: '법무 검토 요청 및 회신 반영',
status: 'prog',
dueLabel: '2일 지남',
overdue: true,
checklist: [1, 3],
assignees: [USERS.lee],
},
{
id: 9,
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
status: 'review',
dueLabel: 'D-3',
checklist: [1, 3],
comments: 2,
assignees: [USERS.park, USERS.choi],
},
{
id: 8,
title: '상세페이지 카피라이팅',
status: 'todo',
dueLabel: 'D-5',
checklist: [0, 3],
assignees: [USERS.lee],
},
{
id: 7,
title: 'SNS 콘텐츠 1차 시안',
status: 'todo',
dueLabel: 'D-8',
checklist: [0, 4],
assignees: [USERS.han],
},
{
id: 6,
title: '캠페인 핵심 메시지 시안 작성',
status: 'done',
dueLabel: '6월 9일 완료',
checklist: [3, 3],
assignees: [USERS.kim],
},
{
id: 5,
title: '레퍼런스 리서치 및 무드보드 정리',
status: 'done',
dueLabel: '6월 6일 완료',
assignees: [USERS.kim],
},
{
id: 4,
title: '퍼포먼스 광고 소재 세팅',
status: 'done',
dueLabel: '6월 5일 완료',
checklist: [2, 2],
assignees: [USERS.park],
},
{
id: 3,
title: '타깃 오디언스 정의 및 페르소나 정리',
status: 'done',
dueLabel: '6월 4일 완료',
assignees: [USERS.park],
},
{
id: 1,
title: '킥오프 미팅 및 일정 수립',
status: 'done',
dueLabel: '6월 2일 완료',
assignees: [USERS.jung],
},
],
}
/* ============================================================
* 업무 상세 (#9)
* ============================================================ */
export const TASK_DETAIL: TaskDetail = {
id: 9,
repoId: 'q3-launch-campaign',
repoName: '3분기 신제품 런칭 캠페인',
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
status: 'review',
statusLabel: '승인 대기',
content: [
'9월 신제품 런칭 캠페인에 사용할 메인 배너를 가로형/세로형 2종으로 제작합니다. 톤앤매너는 @브랜드_가이드라인.pdf 기준을 따라주세요.',
'퍼포먼스 광고와 자사몰 상단에 동시 노출되므로, 두 비율 모두 핵심 메시지("민감 피부 저자극")가 명확히 보이도록 구성해주세요. 1차 시안 확정 후 모바일 적응형까지 진행합니다.',
],
checklist: [
{ text: '가로형 메인 배너 1920×1080 디자인', done: true },
{ text: '세로형 메인 배너 1080×1920 디자인', done: false },
{ text: '모바일 적응형 600×600 제작', done: false },
],
comments: [
{
author: USERS.jung,
roleBadge: '지시자',
time: '2일 전',
text: '가로형부터 확정하고 세로형 진행해주세요. 카피 위치는 좌측 정렬 기준입니다.',
replies: [
{ author: USERS.park, time: '2일 전', text: '네, 가로형 정렬 기준으로 먼저 잡겠습니다.' },
],
},
{
author: USERS.park,
time: '1일 전',
text: '가로형 1차 시안 업로드했습니다. 검토 부탁드려요.',
file: 'main_banner_h_v1.png',
replies: [],
},
{
author: USERS.choi,
time: '3시간 전',
text: '세로형은 내일 오전까지 공유하겠습니다.',
replies: [],
},
],
activities: [
{
tone: 'amber',
icon: 'check-circle',
html: '<b>박지훈</b>님이 승인을 요청했습니다',
statusFrom: '진행 중',
statusTo: '승인 대기',
time: '2시간 전 · 오후 3:24',
},
{
tone: 'green',
icon: 'check',
html: '<b>박지훈</b>님이 <b>가로형 메인 배너 1920×1080 디자인</b> 항목을 완료했습니다',
time: '1일 전 · 오후 6:10',
},
{
tone: '',
icon: 'paperclip',
html: '<b>박지훈</b>님이 파일을 첨부했습니다',
file: 'main_banner_h_v1.png',
time: '1일 전 · 오후 6:08',
},
{
tone: 'blue',
icon: 'calendar',
html: '<b>정태경</b>님이 마감기한을 <b>6월 18일 (목)</b>로 설정했습니다',
time: '6월 8일 · 오전 10:32',
},
{
tone: 'blue',
icon: 'users',
html: '<b>정태경</b>님이 <b>박지훈</b>, <b>최유진</b>님을 담당자로 지정했습니다',
time: '6월 8일 · 오전 10:31',
},
{
tone: 'accent',
icon: 'flag',
html: '<b>정태경</b>님이 업무를 생성했습니다',
time: '6월 8일 · 오전 10:30',
},
],
assignees: [USERS.park, USERS.choi],
issuer: USERS.jung,
dueLabel: '6월 18일 (목)',
dday: 'D-3',
files: [
{ name: '레퍼런스_무드보드.png', size: '3.2 MB', kind: 'img' },
{ name: '배너_소스_파일.zip', size: '18.4 MB', kind: 'zip' },
],
approval: {
requester: '박지훈',
requestedAgo: '2시간 전',
note: '가로형/세로형 메인 배너 1차 제작을 완료했습니다. 모바일 적응형은 컨펌 후 진행 예정이며, 우선 두 비율 검토 부탁드립니다.',
},
issuedLabel: '6월 8일 지시 · #9',
}
/* ============================================================
* 조회 헬퍼
* ============================================================ */
export function findRepo(id: string): Repo | undefined {
return REPOS.find((r) => r.id === id)
}
export function findRepoTasks(id: string): RepoTask[] {
return REPO_TASKS[id] ?? []
}
/* ============================================================
* 내 업무 (담당 / 지시) — 상태 그룹 단위 정적 데이터
* ============================================================ */
/** D-day 강조 단계 */
export type DdayLevel = 'urgent' | 'soon' | 'calm'
/** 내 업무 목록 행 */
export interface MyTaskRow {
id: number
title: string
status: TaskStatus
repoName: string
/** 담당자 미니 아바타(지시 뷰) */
assignees?: User[]
/** 보조 텍스트(예: "지시 · 한승우", "정민호", "박지훈님이 2시간 전 승인 요청") */
metaText?: string
/** metaText 를 amber 강조로 표시할지 (승인 요청 등) */
metaAttn?: boolean
/** 체크리스트 [완료, 전체] */
checklist?: [number, number]
/** 마감/완료 라벨(예: "6월 18일", "6월 9일 승인됨") */
dueLabel?: string
dday?: string
ddayLevel?: DdayLevel
/** 마감 지남(빨강 강조) */
overdue?: boolean
/** 배지 텍스트(예: 진행 중 / 승인 대기). reviewBtn 이 true 면 미표시 */
statusLabel?: string
/** 검토하기 버튼 표시(지시 뷰의 승인 대기) */
reviewBtn?: boolean
/** 완료 행 스타일 */
done?: boolean
}
/** 상태 그룹 */
export interface MyTaskGroup {
key: TaskStatus
label: string
/** 그룹 헤더 우측 보조 문구(예: "· 내 승인 필요") */
action?: string
/** 강조 목록 스타일(amber 테두리) */
attn?: boolean
rows: MyTaskRow[]
}
/** 담당 업무 */
export const MY_TASKS_ASSIGNED: MyTaskGroup[] = [
{
key: 'changes',
label: '수정 요청',
rows: [
{
id: 14,
title: '3분기 캠페인 예산안 재작성',
status: 'changes',
repoName: '3분기 신제품 런칭 캠페인',
metaText: '지시 · 한승우',
dueLabel: '6월 16일',
dday: 'D-1',
ddayLevel: 'urgent',
statusLabel: '수정 요청',
},
],
},
{
key: 'prog',
label: '진행 중',
rows: [
{
id: 12,
title: '브랜드 리뉴얼 킥오프 기획서 작성',
status: 'prog',
repoName: '브랜드 리뉴얼 2026',
checklist: [2, 5],
metaText: '지시 · 한승우',
dueLabel: '6월 18일',
dday: 'D-3',
ddayLevel: 'soon',
statusLabel: '진행 중',
},
{
id: 10,
title: '9월 신제품 GTM 전략 수립',
status: 'prog',
repoName: '3분기 신제품 런칭 캠페인',
checklist: [1, 4],
metaText: '지시 · 한승우',
dueLabel: '6월 21일',
dday: 'D-6',
ddayLevel: 'calm',
statusLabel: '진행 중',
},
],
},
{
key: 'todo',
label: '할 일',
rows: [
{
id: 15,
title: '월간 뉴스레터 8월호 주제 선정',
status: 'todo',
repoName: '월간 뉴스레터',
metaText: '지시 · 한승우',
dueLabel: '6월 24일',
dday: 'D-9',
ddayLevel: 'calm',
statusLabel: '할 일',
},
{
id: 13,
title: 'SEO 키워드 전략 분기 리뷰',
status: 'todo',
repoName: 'SEO 콘텐츠 허브',
metaText: '지시 · 한승우',
dueLabel: '6월 27일',
dday: 'D-12',
ddayLevel: 'calm',
statusLabel: '할 일',
},
],
},
{
key: 'review',
label: '승인 대기',
rows: [
{
id: 2,
title: '브랜드 무드보드 방향 정리',
status: 'review',
repoName: '브랜드 리뉴얼 2026',
metaText: '한승우님 검토 중 · 1일 전 요청',
dueLabel: '6월 17일',
dday: 'D-2',
ddayLevel: 'soon',
statusLabel: '승인 대기',
},
],
},
{
key: 'done',
label: '완료',
rows: [
{
id: 6,
title: '2분기 캠페인 성과 회고 정리',
status: 'done',
repoName: '월간 뉴스레터',
metaText: '지시 · 한승우',
dueLabel: '6월 11일 승인됨',
statusLabel: '완료',
done: true,
},
{
id: 5,
title: '신제품 네이밍 후보 정리',
status: 'done',
repoName: '3분기 신제품 런칭 캠페인',
metaText: '지시 · 한승우',
dueLabel: '6월 5일 승인됨',
statusLabel: '완료',
done: true,
},
],
},
]
/** 지시한 업무 */
export const MY_TASKS_ISSUED: MyTaskGroup[] = [
{
key: 'review',
label: '승인 대기',
action: '· 내 승인 필요',
attn: true,
rows: [
{
id: 9,
title: '메인 배너 이미지 2종 (가로/세로) 디자인',
status: 'review',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.park, USERS.choi],
metaText: '박지훈님이 2시간 전 승인 요청',
metaAttn: true,
dueLabel: '6월 18일',
dday: 'D-3',
ddayLevel: 'soon',
reviewBtn: true,
},
],
},
{
key: 'prog',
label: '진행 중',
rows: [
{
id: 11,
title: '법무 검토 요청 및 회신 반영',
status: 'prog',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.lee],
metaText: '이도윤',
dueLabel: '6월 12일',
dday: '3일 지남',
ddayLevel: 'urgent',
overdue: true,
statusLabel: '진행 중',
},
{
id: 16,
title: 'SNS 콘텐츠 2차 시안 제작',
status: 'prog',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.han],
metaText: '정민호',
checklist: [3, 6],
dueLabel: '6월 19일',
dday: 'D-4',
ddayLevel: 'soon',
statusLabel: '진행 중',
},
],
},
{
key: 'changes',
label: '수정 요청함',
rows: [
{
id: 8,
title: '상세페이지 카피라이팅',
status: 'changes',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.lee],
metaText: '이도윤에게 수정 요청 · 1일 전',
dueLabel: '6월 20일',
dday: 'D-5',
ddayLevel: 'soon',
statusLabel: '수정 요청',
},
],
},
{
key: 'todo',
label: '할 일',
rows: [
{
id: 7,
title: 'SNS 콘텐츠 1차 시안',
status: 'todo',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.han],
metaText: '정민호',
dueLabel: '6월 23일',
dday: 'D-8',
ddayLevel: 'calm',
statusLabel: '할 일',
},
],
},
{
key: 'done',
label: '완료',
rows: [
{
id: 6,
title: '캠페인 핵심 메시지 시안 작성',
status: 'done',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.kim],
metaText: '김서연',
dueLabel: '6월 9일 승인됨',
statusLabel: '완료',
done: true,
},
{
id: 4,
title: '퍼포먼스 광고 소재 세팅',
status: 'done',
repoName: '3분기 신제품 런칭 캠페인',
assignees: [USERS.park],
metaText: '박지훈',
dueLabel: '6월 5일 승인됨',
statusLabel: '완료',
done: true,
},
],
},
]
/** 비완료(활성) 업무 수 — 뷰 탭 카운트용 */
export function countActive(groups: MyTaskGroup[]): number {
return groups.filter((g) => g.key !== 'done').reduce((sum, g) => sum + g.rows.length, 0)
}
/* ============================================================
* 저장소 멤버
* ============================================================ */
export interface RepoMember {
user: User
email: string
/** 저장소 내 직무 표기(예: 리드, 콘텐츠 디자인) */
subRole: string
/** 담당 업무 수 */
taskCount: number
/** 권한 역할 */
roleType: 'admin' | 'member'
/** 현재 로그인 사용자 여부 */
isYou?: boolean
/** 소유자(역할 변경/제거 불가) */
owner?: boolean
}
export const REPO_MEMBERS: Record<string, RepoMember[]> = {
'q3-launch-campaign': [
{ user: USERS.jung, email: 'tkjung@acme.co', subRole: '리드', taskCount: 1, roleType: 'admin', isYou: true, owner: true },
{ user: USERS.kim, email: 'sykim@acme.co', subRole: '콘텐츠 디자인', taskCount: 2, roleType: 'member' },
{ user: USERS.park, email: 'jhpark@acme.co', subRole: '퍼포먼스 마케팅', taskCount: 2, roleType: 'member' },
{ user: USERS.lee, email: 'dylee@acme.co', subRole: '카피라이터', taskCount: 2, roleType: 'member' },
{ user: USERS.choi, email: 'yjchoi@acme.co', subRole: 'UX 디자인', taskCount: 1, roleType: 'member' },
],
}
export function findRepoMembers(id: string): RepoMember[] {
return REPO_MEMBERS[id] ?? []
}
/* ============================================================
* 저장소 활동 피드
* ============================================================ */
/** 활동 아이콘 종류(모양) */
export type ActivityIcon =
| 'check'
| 'pencil'
| 'member-add'
| 'member-check'
| 'member-remove'
| 'lock'
| 'repo'
/** 활동 항목 */
export interface RepoActivityItem {
/** 아이콘 색상 톤 */
tone: 'task' | 'member' | 'setting'
/** 아이콘 모양 */
icon: ActivityIcon
/** 행위자 아바타 */
initial: string
color: AvatarColor
/** 본문 HTML(내부 생성, 신뢰 가능 마크업) */
html: string
time: string
}
/** 날짜 그룹 */
export interface RepoActivityDay {
day: string
items: RepoActivityItem[]
}
export const REPO_ACTIVITY: RepoActivityDay[] = [
{
day: '오늘',
items: [
{
tone: 'task',
icon: 'check',
initial: '이',
color: 'av-c',
html: '<b>이도윤</b>님이 <a class="tgt" href="#">법무 검토 요청 및 회신 반영</a> 업무를 <span class="st prog">진행 중</span>으로 변경했습니다',
time: '2시간 전',
},
{
tone: 'task',
icon: 'check',
initial: '박',
color: 'av-b',
html: '<b>박지훈</b>님이 <a class="tgt" href="#">메인 배너 이미지 2종 디자인</a>의 체크리스트 ‘가로형 메인 배너’를 완료했습니다',
time: '3시간 전',
},
{
tone: 'member',
icon: 'member-add',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 <b>한지민</b>님을 멤버로 초대했습니다',
time: '5시간 전',
},
{
tone: 'member',
icon: 'member-check',
initial: '한',
color: 'av-d',
html: '<b>한지민</b>님이 멤버로 합류했습니다',
time: '5시간 전',
},
],
},
{
day: '어제',
items: [
{
tone: 'task',
icon: 'check',
initial: '최',
color: 'av-d',
html: '<b>최유진</b>님이 <a class="tgt" href="#">메인 비주얼 컨셉 확정</a> 업무를 <span class="st done">완료</span>했습니다',
time: '어제 18:40',
},
{
tone: 'task',
icon: 'pencil',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 <a class="tgt" href="#">SNS 콘텐츠 1차 시안</a> 업무를 지시했습니다 <span style="color:var(--text-3)">· 담당 정민호</span>',
time: '어제 14:10',
},
],
},
{
day: '이번 주',
items: [
{
tone: 'setting',
icon: 'lock',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 저장소 공개 범위를 <b>비공개</b>로 변경했습니다',
time: '6월 11일',
},
{
tone: 'member',
icon: 'member-remove',
initial: '박',
color: 'av-b',
html: '<b>박지훈</b>님의 역할이 <b>멤버</b>로 설정되었습니다',
time: '6월 11일',
},
{
tone: 'setting',
icon: 'pencil',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 표시 제목을 ‘3분기 신제품 런칭 캠페인’으로 변경했습니다',
time: '6월 10일',
},
{
tone: 'task',
icon: 'check',
initial: '김',
color: 'av-a',
html: '<b>김서연</b>님이 <a class="tgt" href="#">캠페인 핵심 메시지 시안 작성</a> 업무를 <span class="st done">완료</span>했습니다',
time: '6월 9일',
},
{
tone: 'setting',
icon: 'repo',
initial: '정',
color: 'av-f',
html: '<b>정태경</b>님이 저장소를 생성했습니다',
time: '6월 2일',
},
],
},
]
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
// 홈 페이지 — 템플릿 기본 진입점
import { ref } from 'vue'
const message = ref<string>('You did it!')
</script>
<template>
<main class="home">
<h1>{{ message }}</h1>
<p>
Visit
<a
href="https://vuejs.org/"
target="_blank"
rel="noopener"
>vuejs.org</a>
to read the documentation
</p>
</main>
</template>
<style scoped>
.home {
padding: 2rem;
}
</style>
+23
View File
@@ -0,0 +1,23 @@
<script setup lang="ts">
// 404 페이지 — 라우팅 매칭 실패 시 노출
</script>
<template>
<main
class="not-found"
role="alert"
>
<h1>404</h1>
<p>요청하신 정보나 페이지를 찾을 없습니다.</p>
<RouterLink to="/">
홈으로 돌아가기
</RouterLink>
</main>
</template>
<style scoped>
.not-found {
padding: 2rem;
text-align: center;
}
</style>
+687
View File
@@ -0,0 +1,687 @@
<script setup lang="ts">
// 로그인 — 좌측 브랜드 패널 + 우측 로그인 폼
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// 폼 상태
const email = ref('')
const password = ref('')
const showPassword = ref(false)
const keepLogin = ref(true)
const submitting = ref(false)
// 로그인 처리 — 성공 시 원래 목적지(redirect) 또는 저장소 목록으로 이동
async function onLogin() {
if (submitting.value) return
submitting.value = true
try {
await authStore.login({
email: email.value.trim(),
password: password.value,
keepLogin: keepLogin.value,
})
const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/repos'
await router.push(redirect)
} catch {
// 에러 메시지는 API 인터셉터에서 전역 처리됨
} finally {
submitting.value = false
}
}
// 소셜 로그인 — 8단계에서 구현 예정
function onSocial() {
window.alert('소셜 로그인은 준비 중입니다. 이메일로 로그인해 주세요.')
}
// 브랜드 패널 강조 포인트
const points = [
'저장소 · 업무 · 담당자로 이어지는 명확한 구조',
'체크리스트와 마감기한으로 진행 관리',
'Gitea 연동으로 안전하게',
]
</script>
<template>
<div class="auth">
<!-- 브랜드 패널 -->
<aside class="brand-panel">
<div class="blob b1" />
<div class="blob b2" />
<div class="ring" />
<div class="bp-logo">
<span class="logo">R</span> Relay
</div>
<div class="bp-body">
<div class="bp-head">
지시부터 완료까지,<br>팀의 업무 흐름을 하나로
</div>
<div class="bp-sub">
저장소 단위로 업무를 정리하고, 담당자에게 명확하게 지시하세요. 진행 상황은 한눈에
추적됩니다.
</div>
<div class="bp-points">
<div
v-for="point in points"
:key="point"
class="bp-point"
>
<span class="ic">
<svg
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>
</span>
{{ point }}
</div>
</div>
</div>
<div class="bp-foot">
© 2026 Relay · 사내 업무 지시 플랫폼
</div>
</aside>
<!-- 패널 -->
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo">
<span class="logo">R</span> Relay
</div>
<h1 class="ac-title">
로그인
</h1>
<p class="ac-sub">
업무 지시를 시작하려면 로그인하세요.
</p>
<!-- 소셜 로그인 -->
<div class="socials">
<button
class="sbtn"
type="button"
@click="onSocial"
>
<span class="sico">
<svg
width="20"
height="20"
viewBox="0 0 48 48"
>
<path
fill="#FFC107"
d="M43.6 20.5H42V20H24v8h11.3C33.7 32.4 29.2 36 24 36c-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 12.9 4 4 12.9 4 24s8.9 20 20 20 20-8.9 20-20c0-1.3-.1-2.3-.4-3.5z"
/>
<path
fill="#FF3D00"
d="M6.3 14.7l6.6 4.8C14.7 15.1 19 12 24 12c3.1 0 5.9 1.2 8 3.1l5.7-5.7C34.5 6.5 29.5 4 24 4 16.3 4 9.7 8.3 6.3 14.7z"
/>
<path
fill="#4CAF50"
d="M24 44c5.4 0 10.3-2.1 14-5.4l-6.5-5.5C29.6 34.6 26.9 36 24 36c-5.2 0-9.6-3.5-11.2-8.3l-6.5 5C9.6 39.6 16.2 44 24 44z"
/>
<path
fill="#1976D2"
d="M43.6 20.5H42V20H24v8h11.3c-.8 2.2-2.2 4.1-4.1 5.5l6.5 5.5C41.4 36 44 30.5 44 24c0-1.3-.1-2.3-.4-3.5z"
/>
</svg>
</span>
Google로 계속하기
</button>
<button
class="sbtn kakao"
type="button"
@click="onSocial"
>
<span class="sico">
<svg
width="19"
height="19"
viewBox="0 0 24 24"
fill="#191600"
>
<path d="M12 3C6.5 3 2 6.5 2 10.8c0 2.8 1.9 5.2 4.7 6.6-.2.7-.7 2.6-.8 3-.1.5.2.5.4.4.2-.1 2.6-1.8 3.7-2.5.6.1 1.3.1 2 .1 5.5 0 10-3.5 10-7.8C22 6.5 17.5 3 12 3z" />
</svg>
</span>
카카오로 계속하기
</button>
</div>
<div class="divider">
또는 이메일로 로그인
</div>
<!-- 이메일 로그인 -->
<form @submit.prevent="onLogin">
<div class="field">
<label
class="flabel"
for="login-email"
>이메일</label>
<input
id="login-email"
v-model="email"
class="tinput"
type="email"
placeholder="name@acme.co"
autocomplete="email"
>
</div>
<div class="field">
<label
class="flabel"
for="login-pw"
>비밀번호</label>
<div class="pw-wrap">
<input
id="login-pw"
v-model="password"
class="tinput"
:type="showPassword ? 'text' : 'password'"
placeholder="비밀번호 입력"
autocomplete="current-password"
>
<button
class="pw-toggle"
type="button"
:title="showPassword ? '비밀번호 숨기기' : '비밀번호 표시'"
:aria-label="showPassword ? '비밀번호 숨기기' : '비밀번호 표시'"
@click="showPassword = !showPassword"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle
cx="12"
cy="12"
r="3"
/>
</svg>
</button>
</div>
</div>
<div class="options">
<button
class="keep"
type="button"
@click="keepLogin = !keepLogin"
>
<span
class="box"
:class="{ on: keepLogin }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
로그인 상태 유지
</button>
<a
class="link"
href="#"
>비밀번호 찾기</a>
</div>
<button
class="submit"
type="submit"
:disabled="submitting"
>
{{ submitting ? '로그인 중…' : '로그인' }}
</button>
</form>
<div class="signup">
계정이 없으신가요? <RouterLink to="/signup">
회원가입
</RouterLink>
</div>
<div class="verify-note">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 6 12 13 2 6" />
<rect
x="2"
y="4"
width="20"
height="16"
rx="2"
/>
</svg>
이메일 회원가입은 인증 메일 확인 완료됩니다
</div>
</div>
</main>
</div>
</template>
<style scoped>
/* 로그인 화면은 본문보다 큰 모서리 반경을 사용 */
.auth {
--radius: 8px;
display: flex;
min-height: 100vh;
}
/* ---- 브랜드 패널 ---- */
.brand-panel {
flex: 0 0 46%;
max-width: 38.75rem;
position: relative;
overflow: hidden;
background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%);
color: #fff;
padding: 2.75rem 3rem;
display: flex;
flex-direction: column;
}
.brand-panel .blob {
position: absolute;
border-radius: 50%;
filter: blur(2px);
pointer-events: none;
}
.brand-panel .b1 {
width: 22.5rem;
height: 22.5rem;
right: -7.5rem;
top: -5.625rem;
background: rgba(255, 255, 255, 0.1);
}
.brand-panel .b2 {
width: 16.25rem;
height: 16.25rem;
left: -5.625rem;
bottom: -4.375rem;
background: rgba(255, 255, 255, 0.07);
}
.brand-panel .ring {
position: absolute;
right: 3.75rem;
bottom: 5rem;
width: 11.25rem;
height: 11.25rem;
border: 1.5px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
}
.brand-panel .ring::after {
content: '';
position: absolute;
inset: 2.125rem;
border: 1.5px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.bp-logo {
display: flex;
align-items: center;
gap: 0.625rem;
font-weight: 700;
font-size: 1.125rem;
letter-spacing: -0.0125rem;
position: relative;
z-index: 1;
}
.bp-logo .logo {
width: 2rem;
height: 2rem;
border-radius: 0.5625rem;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.22);
display: grid;
place-items: center;
font-size: 1.0625rem;
font-weight: 800;
}
.bp-body {
margin-top: auto;
position: relative;
z-index: 1;
}
.bp-head {
font-size: 2.125rem;
font-weight: 700;
line-height: 1.32;
letter-spacing: -0.05rem;
text-wrap: balance;
}
.bp-sub {
margin-top: 1.125rem;
font-size: 0.9375rem;
line-height: 1.7;
color: rgba(255, 255, 255, 0.82);
max-width: 25rem;
}
.bp-points {
margin-top: 1.875rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bp-point {
display: flex;
align-items: center;
gap: 0.6875rem;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.92);
}
.bp-point .ic {
width: 1.375rem;
height: 1.375rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
display: grid;
place-items: center;
flex-shrink: 0;
}
.bp-point .ic svg {
width: 0.8125rem;
height: 0.8125rem;
}
.bp-foot {
margin-top: 2.5rem;
font-size: 0.781rem;
color: rgba(255, 255, 255, 0.6);
position: relative;
z-index: 1;
}
/* ---- 폼 패널 ---- */
.form-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
}
.auth-card {
width: 100%;
max-width: 24rem;
}
.ac-logo {
display: none;
}
.ac-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.03125rem;
}
.ac-sub {
color: var(--text-2);
font-size: 0.875rem;
margin-top: 0.4375rem;
}
.socials {
display: flex;
flex-direction: column;
gap: 0.625rem;
margin-top: 1.75rem;
}
.sbtn {
height: 2.875rem;
border-radius: var(--radius);
border: 1px solid var(--border-strong);
background: #fff;
display: flex;
align-items: center;
justify-content: center;
gap: 0.625rem;
cursor: pointer;
font-family: inherit;
font-size: 0.906rem;
font-weight: 600;
color: var(--text);
text-decoration: none;
position: relative;
transition:
background 0.12s,
border-color 0.12s;
}
.sbtn:hover {
background: #f7f8fa;
}
.sbtn .sico {
position: absolute;
left: 1rem;
display: grid;
place-items: center;
}
.sbtn.kakao {
background: var(--kakao);
border-color: var(--kakao);
color: var(--kakao-text);
}
.sbtn.kakao:hover {
background: #f2d900;
}
.divider {
display: flex;
align-items: center;
gap: 0.875rem;
margin: 1.375rem 0;
color: var(--text-3);
font-size: 0.781rem;
}
.divider::before,
.divider::after {
content: '';
height: 1px;
background: var(--border);
flex: 1;
}
.field {
margin-bottom: 0.875rem;
}
.flabel {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.4375rem;
display: block;
}
.tinput {
width: 100%;
height: 2.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.8125rem;
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);
}
.pw-wrap {
position: relative;
}
.pw-wrap .tinput {
padding-right: 2.75rem;
}
.pw-toggle {
position: absolute;
right: 0.375rem;
top: 50%;
transform: translateY(-50%);
width: 2.125rem;
height: 2.125rem;
border: none;
background: transparent;
color: var(--text-3);
display: grid;
place-items: center;
cursor: pointer;
border-radius: 0.375rem;
}
.pw-toggle:hover {
background: #f1f2f4;
color: var(--text-2);
}
.pw-toggle svg {
width: 1.125rem;
height: 1.125rem;
}
.options {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0.25rem 0 1.375rem;
}
.keep {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-size: 0.8125rem;
color: var(--text-2);
user-select: none;
white-space: nowrap;
background: none;
border: none;
font-family: inherit;
padding: 0;
}
.keep .box {
width: 1.0625rem;
height: 1.0625rem;
border-radius: 0.3125rem;
border: 1.5px solid var(--border-strong);
display: grid;
place-items: center;
background: #fff;
}
.keep .box.on {
background: var(--accent);
border-color: var(--accent);
}
.keep .box svg {
width: 0.6875rem;
height: 0.6875rem;
color: #fff;
opacity: 0;
}
.keep .box.on svg {
opacity: 1;
}
.link {
color: var(--accent);
font-size: 0.8125rem;
font-weight: 600;
text-decoration: none;
white-space: nowrap;
}
.link:hover {
text-decoration: underline;
}
.submit {
width: 100%;
height: 2.875rem;
border-radius: var(--radius);
border: none;
background: var(--accent);
color: #fff;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.submit:hover {
background: var(--accent-hover);
}
.signup {
margin-top: 1.5rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-2);
}
.signup a {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.signup a:hover {
text-decoration: underline;
}
.verify-note {
margin-top: 0.375rem;
text-align: center;
font-size: 0.75rem;
color: var(--text-3);
display: flex;
align-items: center;
justify-content: center;
gap: 0.3125rem;
}
.verify-note svg {
width: 0.8125rem;
height: 0.8125rem;
}
/* 좁은 화면 — 브랜드 패널 숨기고 카드 로고 노출 */
@media (max-width: 55rem) {
.brand-panel {
display: none;
}
.ac-logo {
display: flex;
align-items: center;
gap: 0.5625rem;
font-weight: 700;
font-size: 1.0625rem;
margin-bottom: 1.625rem;
}
.ac-logo .logo {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, #5b54f0, #4338ca);
display: grid;
place-items: center;
color: #fff;
font-size: 0.9375rem;
font-weight: 800;
}
}
</style>
+751
View File
@@ -0,0 +1,751 @@
<script setup lang="ts">
// 내 업무 — 담당/지시 두 뷰를 탭으로 전환, 상태 그룹별 업무 목록
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { MY_TASKS_ASSIGNED, MY_TASKS_ISSUED, countActive, type MyTaskRow } from '@/mock/relay.mock'
type View = 'assigned' | 'issued'
const view = ref<View>('assigned')
const REPO = 'q3-launch-campaign'
// 행 클릭 시 이동 경로
// - 담당 뷰: 담당자 관점 업무 상세(상태별 분기)
// - 지시 뷰: 지시자 관점 업무 상세(TaskDetailPage)
function taskLink(row: MyTaskRow): string {
if (view.value === 'issued') return `/repos/${REPO}/tasks/${row.id}`
const state = row.status === 'changes' ? 'changes' : row.status === 'done' ? 'approved' : 'request'
return `/repos/${REPO}/tasks/${row.id}/view?state=${state}`
}
const groups = computed(() => (view.value === 'assigned' ? MY_TASKS_ASSIGNED : MY_TASKS_ISSUED))
const assignedCount = countActive(MY_TASKS_ASSIGNED)
const issuedCount = countActive(MY_TASKS_ISSUED)
// 지시 뷰의 승인 대기(내 승인 필요) 건수
const issuedReviewCount = computed(
() => MY_TASKS_ISSUED.find((g) => g.key === 'review')?.rows.length ?? 0,
)
// 승인 대기 강조 배너는 지시 뷰에서 표시
const showPendingNote = computed(() => view.value === 'issued' && issuedReviewCount.value > 0)
</script>
<template>
<AppShell>
<div class="page">
<div class="pagehead">
<h1> 업무</h1>
<span class="sub">나에게 배정되었거나 내가 지시한 업무를 한곳에서 관리하세요.</span>
<RouterLink
class="new-btn"
to="/repos/q3-launch-campaign/tasks/new"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M5 12h14M12 5v14" />
</svg>
업무 지시
</RouterLink>
</div>
<!-- 담당 / 지시 토글 -->
<div class="viewtabs">
<button
class="viewtab"
:class="{ active: view === 'assigned' }"
type="button"
@click="view = 'assigned'"
>
<span class="tb-label">담당 업무</span>
<span class="vt-count">{{ assignedCount }}</span>
</button>
<button
class="viewtab"
:class="{ active: view === 'issued' }"
type="button"
@click="view = 'issued'"
>
<span class="tb-label">지시한 업무</span>
<span class="vt-count">{{ issuedCount }}</span>
<span
v-if="issuedReviewCount > 0"
class="vt-flag"
>승인 대기 {{ issuedReviewCount }}</span>
</button>
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle
cx="11"
cy="11"
r="7"
/><path d="m21 21-4-4" />
</svg>
<input
type="text"
placeholder="업무 검색…"
>
</div>
<div class="repofilter">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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>
모든 저장소
<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>
</div>
<div class="sort">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
</svg>
마감 임박순
</div>
</div>
<!-- 승인 대기 강조 배너 (지시 ) -->
<div
v-if="showPendingNote"
class="pending-note"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 8v4l3 2" /><circle
cx="12"
cy="12"
r="9"
/>
</svg>
<span><b>{{ issuedReviewCount }}</b> 업무가 승인을 기다리고 있어요. 검토 승인하거나 수정을 요청하세요.</span>
</div>
<!-- 상태 그룹 -->
<div
v-for="group in groups"
:key="group.key"
class="group"
>
<div class="group-head">
<span
class="gh-dot"
:class="group.key"
/>
<span class="gh-label">{{ group.label }}</span>
<span class="gh-count">{{ group.rows.length }}</span>
<span
v-if="group.action"
class="gh-action"
>{{ group.action }}</span>
</div>
<div
class="list"
:class="{ attn: group.attn }"
>
<RouterLink
v-for="row in group.rows"
:key="row.id"
class="task-row"
:class="{ 'is-done': row.done }"
:to="taskLink(row)"
>
<span
class="st-dot"
:class="row.status"
/>
<span class="task-main">
<span class="task-title">
<span class="ttext">{{ row.title }}</span>
<span class="tid">#{{ row.id }}</span>
</span>
<span class="task-meta">
<span class="repo-chip">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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 }}
</span>
<span class="meta-sep" />
<span
v-if="row.checklist"
class="mi"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
{{ row.checklist[0] }}/{{ row.checklist[1] }}
</span>
<span
v-if="row.checklist"
class="meta-sep"
/>
<!-- 지시 : 담당자 아바타 + (강조 아닌) 보조 텍스트 -->
<span
v-if="row.assignees && !row.metaAttn"
class="mi"
>
<span class="mini-stack">
<span
v-for="a in row.assignees"
:key="a.id"
class="mini-av"
:class="a.color"
>{{ a.initial }}</span>
</span>
{{ row.metaText }}
</span>
<!-- 지시 승인대기: 아바타와 강조 텍스트를 분리 표기 -->
<template v-else-if="row.assignees && row.metaAttn">
<span class="mi">
<span class="mini-stack">
<span
v-for="a in row.assignees"
:key="a.id"
class="mini-av"
:class="a.color"
>{{ a.initial }}</span>
</span>
</span>
<span class="meta-sep" />
<span class="mi attn">{{ row.metaText }}</span>
</template>
<!-- 담당 : 보조 텍스트만 -->
<span
v-else-if="row.metaText"
class="mi"
>{{ row.metaText }}</span>
</span>
</span>
<span class="task-right">
<span
class="due"
:class="{ overdue: row.overdue }"
>
<template v-if="row.dday">
<svg
v-if="row.overdue"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="12"
cy="12"
r="9"
/><path d="M12 7v5l3 2" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="3"
y="4"
width="18"
height="18"
rx="2"
/><path d="M16 2v4M8 2v4M3 10h18" />
</svg>
{{ row.dueLabel }} <span
class="dday"
:class="row.ddayLevel"
>{{ row.dday }}</span>
</template>
<template v-else>{{ row.dueLabel }}</template>
</span>
<span
v-if="row.reviewBtn"
class="review-btn"
>검토하기</span>
<span
v-else-if="row.statusLabel"
class="st-badge"
:class="row.status"
>{{ row.statusLabel }}</span>
</span>
</RouterLink>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.pagehead {
display: flex;
align-items: flex-end;
gap: 0.75rem;
padding: 1.5rem 0 1rem;
}
.pagehead h1 {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.025rem;
white-space: nowrap;
}
.pagehead .sub {
font-size: 0.8125rem;
color: var(--text-2);
padding-bottom: 0.125rem;
white-space: nowrap;
}
.new-btn {
margin-left: auto;
height: 2.25rem;
padding: 0 0.9375rem;
border-radius: var(--radius);
font-size: 0.844rem;
font-weight: 600;
border: 1px solid var(--accent);
background: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.375rem;
text-decoration: none;
white-space: nowrap;
}
.new-btn:hover {
background: var(--accent-hover);
}
.new-btn svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* 담당/지시 토글 */
.viewtabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--border);
margin-bottom: 1.125rem;
}
.viewtab {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.25rem;
margin-right: 1.125rem;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-2);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
white-space: nowrap;
font-family: inherit;
}
.viewtab:hover {
color: var(--text);
}
.viewtab.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.viewtab .vt-count {
font-size: 0.6875rem;
font-weight: 600;
color: var(--text-2);
background: #f1f2f4;
border-radius: 20px;
height: 1.0625rem;
min-width: 1.0625rem;
padding: 0 0.375rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.viewtab.active .vt-count {
background: var(--accent-weak);
color: var(--accent);
}
.viewtab .vt-flag {
font-size: 0.6875rem;
font-weight: 600;
color: var(--amber);
background: var(--amber-weak);
border: 1px solid var(--amber-border);
border-radius: 20px;
padding: 0.0625rem 0.4375rem;
line-height: 1.4;
white-space: nowrap;
}
.toolbar .search {
width: 16.25rem;
}
.repofilter {
display: flex;
align-items: center;
gap: 0.4375rem;
height: 2.25rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.6875rem;
background: #fff;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
}
.repofilter svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
/* 승인 대기 강조 배너 */
.pending-note {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.6875rem 0.9375rem;
background: var(--amber-weak);
border: 1px solid var(--amber-border);
border-radius: 0.5625rem;
margin-bottom: 1.375rem;
font-size: 0.8125rem;
color: #8a5a12;
}
.pending-note svg {
width: 1.0625rem;
height: 1.0625rem;
color: var(--amber);
flex-shrink: 0;
}
.pending-note b {
font-weight: 700;
}
/* 상태 그룹 */
.group {
margin-bottom: 1.375rem;
}
.group-head {
display: flex;
align-items: center;
gap: 0.5625rem;
margin-bottom: 0.5625rem;
padding-left: 0.125rem;
}
.gh-dot {
width: 0.5625rem;
height: 0.5625rem;
border-radius: 50%;
flex-shrink: 0;
}
.gh-dot.changes {
background: var(--red);
}
.gh-dot.prog {
background: var(--blue);
}
.gh-dot.todo {
background: #c4c8cf;
}
.gh-dot.review {
background: var(--amber);
}
.gh-dot.done {
background: var(--green);
}
.gh-label {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text);
white-space: nowrap;
}
.gh-count {
font-size: 0.719rem;
font-weight: 600;
color: var(--text-2);
background: #eceef1;
border-radius: 20px;
height: 1.125rem;
min-width: 1.125rem;
padding: 0 0.375rem;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.gh-action {
margin-left: 0.25rem;
font-size: 0.719rem;
font-weight: 600;
color: var(--amber);
white-space: nowrap;
}
.list.attn {
border-color: var(--amber-border);
box-shadow:
0 0 0 3px rgba(183, 121, 31, 0.08),
var(--shadow-sm);
}
/* 업무 행 */
.task-row {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.8125rem 1.125rem;
border-top: 1px solid var(--border);
cursor: pointer;
text-decoration: none;
color: inherit;
}
.task-row:first-child {
border-top: none;
}
.task-row:hover {
background: #fafbfc;
}
.task-main {
flex: 1;
min-width: 0;
}
.task-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-title .ttext {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.task-title .tid {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-3);
flex-shrink: 0;
}
.task-row:hover .task-title .ttext {
color: var(--accent);
}
.task-row.is-done .task-title {
color: var(--text-2);
}
.task-meta {
display: flex;
align-items: center;
gap: 0.75rem;
margin-top: 0.25rem;
font-size: 0.75rem;
color: var(--text-3);
}
.repo-chip {
display: inline-flex;
align-items: center;
gap: 0.3125rem;
font-weight: 600;
color: var(--text-2);
white-space: nowrap;
}
.repo-chip svg {
width: 0.8125rem;
height: 0.8125rem;
color: var(--accent);
flex-shrink: 0;
}
.mi {
display: inline-flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
}
.mi svg {
width: 0.8125rem;
height: 0.8125rem;
}
.mi.attn {
color: var(--amber);
font-weight: 600;
}
.meta-sep {
width: 0.1875rem;
height: 0.1875rem;
border-radius: 50%;
background: #d7dae0;
flex-shrink: 0;
}
/* 담당자 미니 아바타 스택 */
.mini-stack {
display: inline-flex;
align-items: center;
}
.mini-av {
width: 1.125rem;
height: 1.125rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.5625rem;
font-weight: 700;
color: #fff;
box-shadow: 0 0 0 1.5px #fff;
flex-shrink: 0;
}
.mini-av.av-a {
background: #e0518d;
}
.mini-av.av-b {
background: #3d8bf2;
}
.mini-av.av-c {
background: #16a37b;
}
.mini-av.av-d {
background: #b3631f;
}
.mini-av.av-e {
background: #7c5cf0;
}
.mini-av.av-f {
background: #d0982a;
}
.mini-stack .mini-av + .mini-av {
margin-left: -0.3125rem;
}
.task-right {
display: flex;
align-items: center;
gap: 0.875rem;
flex-shrink: 0;
}
.due {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.781rem;
color: var(--text-2);
font-weight: 500;
white-space: nowrap;
}
.due svg {
width: 0.875rem;
height: 0.875rem;
color: var(--text-3);
}
.due.overdue {
color: var(--red);
font-weight: 600;
}
.due.overdue svg {
color: var(--red);
}
.dday {
font-size: 0.6875rem;
font-weight: 700;
padding: 0.0625rem 0.4375rem;
border-radius: 20px;
white-space: nowrap;
line-height: 1.5;
}
.dday.urgent {
color: var(--red);
background: var(--red-weak);
border: 1px solid var(--red-border);
}
.dday.soon {
color: var(--amber);
background: var(--amber-weak);
border: 1px solid var(--amber-border);
}
.dday.calm {
color: var(--text-2);
background: #f1f2f4;
border: 1px solid var(--border);
}
.task-right .st-badge {
min-width: 4rem;
}
.review-btn {
font-size: 0.75rem;
font-weight: 600;
color: #fff;
background: var(--amber);
border: 1px solid var(--amber);
border-radius: var(--radius);
padding: 0.3125rem 0.6875rem;
white-space: nowrap;
}
</style>
@@ -0,0 +1,354 @@
<script setup lang="ts">
// 저장소 활동 — 날짜 그룹 단위 활동 피드
import { computed, defineComponent, h, ref } 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 { REPO_ACTIVITY, findRepo, findRepoMembers, type ActivityIcon } from '@/mock/relay.mock'
type Filter = 'all' | 'task' | 'member' | 'setting'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repo = computed(() => findRepo(repoId.value))
const headerMembers = computed(() => findRepoMembers(repoId.value).map((m) => m.user))
const filter = ref<Filter>('all')
// 필터 적용 후 빈 날짜 그룹은 제거
const days = computed(() =>
REPO_ACTIVITY.map((d) => ({
day: d.day,
items: d.items.filter((it) => filter.value === 'all' || it.tone === filter.value),
})).filter((d) => d.items.length > 0),
)
// 활동 아이콘 — name 으로 SVG path 분기
const ACT_PATHS: Record<ActivityIcon, string> = {
check: '<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
pencil: '<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
'member-add': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M19 8v6M22 11h-6"/>',
'member-check': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="m17 11 2 2 4-4"/>',
'member-remove': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 11h-6"/>',
lock: '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
repo: '<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"/>',
}
const ActIcon = defineComponent({
props: { name: { type: String, required: true } },
setup(props) {
return () =>
h('svg', {
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
'stroke-width': '2',
'stroke-linecap': 'round',
'stroke-linejoin': 'round',
innerHTML: ACT_PATHS[props.name as ActivityIcon] ?? '',
})
},
})
</script>
<template>
<AppShell>
<div
v-if="repo"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
class="lnk"
>
저장소
</RouterLink>
<span class="sep">/</span>
<RouterLink
:to="`/repos/${repo.id}`"
class="lnk"
>
{{ repo.name }}
</RouterLink>
<span class="sep">/</span>
<b>활동</b>
</div>
<RepoHeader
:repo="repo"
meta-text="2시간 업데이트"
:members="headerMembers"
:more-count="0"
/>
<RepoTabs
:repo-id="repo.id"
active="activity"
:task-count="12"
:member-count="headerMembers.length"
/>
<!-- 툴바 -->
<div class="toolbar">
<div class="segmented">
<button
:class="{ active: filter === 'all' }"
@click="filter = 'all'"
>
전체
</button>
<button
:class="{ active: filter === 'task' }"
@click="filter = 'task'"
>
업무
</button>
<button
:class="{ active: filter === 'member' }"
@click="filter = 'member'"
>
멤버
</button>
<button
:class="{ active: filter === 'setting' }"
@click="filter = 'setting'"
>
설정
</button>
</div>
<div class="mfilter">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" /><circle
cx="12"
cy="7"
r="4"
/>
</svg>
모든 멤버
<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>
</div>
</div>
<!-- 활동 피드 -->
<!-- act-text v-html 내부에서 생성한 신뢰 가능한 활동 마크업(목업) 렌더한다 (XSS 위험 없음) -->
<!-- eslint-disable vue/no-v-html -->
<div class="feed">
<div class="feed-inner">
<template
v-for="group in days"
:key="group.day"
>
<div class="day">
{{ group.day }}
</div>
<div
v-for="(item, i) in group.items"
:key="group.day + i"
class="act"
:class="{ 'last-in-group': i === group.items.length - 1 }"
>
<span
class="act-ico"
:class="item.tone"
><ActIcon :name="item.icon" /></span>
<div class="act-main">
<span
class="act-av"
:class="item.color"
>{{ item.initial }}</span>
<span
class="act-text"
v-html="item.html"
/>
</div>
<span class="act-time">{{ item.time }}</span>
</div>
</template>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.crumb {
padding: 1.125rem 0 0.75rem;
}
.toolbar {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.5rem;
}
.mfilter {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.4375rem;
height: 2.25rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.6875rem;
background: #fff;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
}
.mfilter svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
/* 활동 피드 */
.feed {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 0.375rem 1.25rem 1.125rem;
}
.feed-inner {
position: relative;
}
.day {
font-size: 0.75rem;
font-weight: 700;
color: var(--text-3);
letter-spacing: 0.0125rem;
padding: 1.125rem 0 0.375rem;
}
.act {
display: flex;
align-items: center;
gap: 0.8125rem;
padding: 0.5625rem 0;
position: relative;
}
.act::before {
content: '';
position: absolute;
left: 0.969rem;
top: 50%;
bottom: -0.5625rem;
width: 1.5px;
background: var(--border);
z-index: 0;
}
.act.last-in-group::before {
display: none;
}
.act-ico {
width: 2rem;
height: 2rem;
border-radius: 50%;
display: grid;
place-items: center;
flex-shrink: 0;
z-index: 1;
}
.act-ico svg {
width: 1rem;
height: 1rem;
}
.act-ico.task {
background: var(--blue-weak);
color: var(--blue);
}
.act-ico.member {
background: var(--green-weak);
color: var(--green);
}
.act-ico.setting {
background: var(--amber-weak);
color: var(--amber);
}
.act-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.act-av {
width: 1.375rem;
height: 1.375rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.625rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.act-av.av-a {
background: #e0518d;
}
.act-av.av-b {
background: #3d8bf2;
}
.act-av.av-c {
background: #16a37b;
}
.act-av.av-d {
background: #b3631f;
}
.act-av.av-e {
background: #7c5cf0;
}
.act-av.av-f {
background: #d0982a;
}
.act-text {
font-size: 0.844rem;
color: var(--text-2);
line-height: 1.5;
}
.act-text :deep(b) {
color: var(--text);
font-weight: 600;
}
.act-text :deep(.tgt) {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.act-text :deep(.tgt:hover) {
text-decoration: underline;
}
.act-text :deep(.st) {
font-weight: 600;
}
.act-text :deep(.st.prog) {
color: var(--blue);
}
.act-text :deep(.st.done) {
color: var(--green);
}
.act-time {
font-size: 0.75rem;
color: var(--text-3);
white-space: nowrap;
flex-shrink: 0;
margin-left: 0.75rem;
}
</style>
+760
View File
@@ -0,0 +1,760 @@
<script setup lang="ts">
// 새 저장소 만들기 — 저장소 생성 폼
import { ref } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { useRepoStore } from '@/stores/repo.store'
import type { Visibility } from '@/mock/relay.mock'
const router = useRouter()
const repoStore = useRepoStore()
// 폼 상태
const repoName = ref('')
const displayTitle = ref('')
const visibility = ref<Visibility>('private')
const description = ref('')
const template = ref('없음 (빈 저장소)')
const branch = ref('main')
const submitting = ref(false)
const templates = ['없음 (빈 저장소)', '기본 업무 템플릿', '디자인 프로젝트 템플릿', '개발 프로젝트 템플릿']
// 생성 — 성공 시 새 저장소 상세로 이동
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
}
submitting.value = true
try {
const repo = await repoStore.create({
slug: repoName.value.trim(),
name: displayTitle.value.trim(),
visibility: visibility.value,
description: description.value.trim() || undefined,
branch: branch.value.trim() || undefined,
})
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="소유자는 변경할 수 없습니다"
>
<span class="owner-avatar">M</span>
marketing-team
<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 class="avail">
<svg
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>
사용 가능한 이름입니다
</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">
프로젝트 설명
</div>
<div class="fhint">
저장소 목록과 상단에 표시됩니다. (선택)
</div>
<textarea
v-model="description"
class="tinput tarea"
placeholder="이 프로젝트의 목적과 범위를 간단히 적어주세요."
/>
</div>
<!-- 템플릿 -->
<div class="fblock">
<div class="flabel">
템플릿
</div>
<div class="fhint">
선택한 템플릿의 폴더 구조와 기본 업무가 저장소에 복제됩니다.
</div>
<div class="select-wrap">
<select v-model="template">
<option
v-for="t in templates"
:key="t"
>
{{ t }}
</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;
}
.owner-avatar {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.3125rem;
background: #0ea5a3;
color: #fff;
display: grid;
place-items: center;
font-size: 0.656rem;
font-weight: 700;
}
.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;
color: var(--green);
margin-top: 0.5625rem;
}
.avail svg {
width: 0.875rem;
height: 0.875rem;
}
.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>
+475
View File
@@ -0,0 +1,475 @@
<script setup lang="ts">
// 저장소 상세 — 저장소 헤더(공통) + 진행률 + 서브탭(공통) + 업무 목록
import { computed, 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 { useRepoStore } from '@/stores/repo.store'
import { findRepoTasks, type Repo, type TaskStatus } from '@/mock/relay.mock'
type Segment = 'all' | 'prog' | 'todo' | 'done'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repoStore = useRepoStore()
// 저장소 헤더/진행률 — API 연동
const repo = ref<Repo | null>(null)
async function loadRepo() {
try {
repo.value = await repoStore.fetchOne(repoId.value)
} catch {
repo.value = null // 404/403 등은 인터셉터가 알림 처리
}
}
watch(repoId, loadRepo, { immediate: true })
// 업무 목록 — 4단계(업무 API)에서 교체 예정, 현재는 목업
const tasks = computed(() => findRepoTasks(repoId.value))
const memberCount = computed(() =>
repo.value ? repo.value.members.length + repo.value.moreCount : 0,
)
const keyword = ref('')
const segment = ref<Segment>('all')
// 세그먼트별 분류 규칙
function inSegment(status: TaskStatus, seg: Segment): boolean {
if (seg === 'all') return true
if (seg === 'prog') return status === 'prog' || status === 'review'
if (seg === 'todo') return status === 'todo'
return status === 'done'
}
const counts = computed(() => ({
all: tasks.value.length,
prog: tasks.value.filter((t) => inSegment(t.status, 'prog')).length,
todo: tasks.value.filter((t) => inSegment(t.status, 'todo')).length,
done: tasks.value.filter((t) => inSegment(t.status, 'done')).length,
}))
const filteredTasks = computed(() =>
tasks.value.filter(
(t) =>
inSegment(t.status, segment.value) && (!keyword.value || t.title.includes(keyword.value)),
),
)
// 지연 건수
const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
// 업무 상태 → 배지 라벨
const STATUS_LABEL: Record<TaskStatus, string> = {
prog: '진행 중',
review: '승인 대기',
todo: '대기',
done: '완료',
changes: '수정 요청',
}
</script>
<template>
<AppShell>
<div
v-if="repo"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
class="lnk"
>
저장소
</RouterLink>
<span class="sep">/</span>
<b>{{ repo.name }}</b>
</div>
<!-- 저장소 헤더(공통) 진행률 스트립을 extra 슬롯으로 주입 -->
<RepoHeader
:repo="repo"
:meta-text="repo.updatedAgo"
>
<template #extra>
<div class="prog-strip">
<span class="prog-txt">{{ repo.doneCount }} <span>/ {{ repo.totalCount }} 업무 완료</span> · {{ repo.progressPct }}%</span>
<div class="pbar">
<i :style="{ width: repo.progressPct + '%' }" />
</div>
<span
v-if="overdueCount > 0"
class="prog-warn"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="12"
cy="12"
r="9"
/><path d="M12 7v5l3 2" />
</svg>
지연 {{ overdueCount }}
</span>
</div>
</template>
</RepoHeader>
<!-- 서브탭(공통) -->
<RepoTabs
:repo-id="repo.id"
active="tasks"
:task-count="counts.all"
:member-count="memberCount"
/>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle
cx="11"
cy="11"
r="7"
/><path d="m21 21-4-4" />
</svg>
<input
v-model="keyword"
type="text"
placeholder="업무 검색…"
>
</div>
<div class="segmented">
<button
:class="{ active: segment === 'all' }"
@click="segment = 'all'"
>
전체 <span class="n">{{ counts.all }}</span>
</button>
<button
:class="{ active: segment === 'prog' }"
@click="segment = 'prog'"
>
진행 <span class="n">{{ counts.prog }}</span>
</button>
<button
:class="{ active: segment === 'todo' }"
@click="segment = 'todo'"
>
대기 <span class="n">{{ counts.todo }}</span>
</button>
<button
:class="{ active: segment === 'done' }"
@click="segment = 'done'"
>
완료 <span class="n">{{ counts.done }}</span>
</button>
</div>
<div class="sort">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
</svg>
마감 임박순
</div>
</div>
<!-- 업무 목록 -->
<div class="list">
<RouterLink
v-for="task in filteredTasks"
:key="task.id"
class="task-row"
:class="{ 'is-done': task.status === 'done' }"
:to="`/repos/${repo.id}/tasks/${task.id}`"
>
<span
class="st-dot"
:class="task.status"
/>
<span class="task-main">
<span class="task-title">{{ task.title }} <span class="tid">#{{ task.id }}</span></span>
<span class="task-meta">
<span
v-if="task.overdue"
class="mi overdue"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="12"
cy="12"
r="9"
/><path d="M12 7v5l3 2" />
</svg>
{{ task.dueLabel }}
</span>
<span
v-else-if="task.status !== 'done' && task.dueLabel"
class="mi"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="3"
y="4"
width="18"
height="18"
rx="2"
/><path d="M16 2v4M8 2v4M3 10h18" />
</svg>
{{ task.dueLabel }}
</span>
<span
v-if="task.checklist"
class="mi"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
</svg>
{{ task.checklist[0] }}/{{ task.checklist[1] }}
</span>
<span
v-if="task.comments"
class="mi"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
</svg>
{{ task.comments }}
</span>
<span
v-if="task.status === 'done'"
class="mi"
>{{ task.dueLabel }}</span>
</span>
</span>
<span class="task-right">
<span class="avstack">
<span
v-for="a in task.assignees"
:key="a.id"
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
</span>
<span
class="st-badge"
:class="task.status"
>{{ STATUS_LABEL[task.status] }}</span>
<span class="chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
</span>
</span>
</RouterLink>
</div>
</div>
<!-- 저장소를 찾지 못한 경우 -->
<div
v-else
class="page"
>
<p class="not-found-msg">
요청하신 저장소를 찾을 없습니다.
</p>
</div>
</AppShell>
</template>
<style scoped>
.crumb {
padding: 1.125rem 0 0.75rem;
}
/* 진행률 스트립 (RepoHeader 의 extra 슬롯에 주입 — 부모 스코프 스타일 적용) */
.prog-strip {
display: flex;
align-items: center;
gap: 1rem;
margin-top: 1rem;
padding-top: 0.9375rem;
border-top: 1px solid var(--border);
}
.prog-strip .pbar {
flex: 1;
height: 0.5rem;
max-width: 22.5rem;
}
.prog-txt {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.prog-txt span {
color: var(--text-3);
font-weight: 500;
}
.prog-warn {
font-size: 0.781rem;
font-weight: 600;
color: var(--red);
display: flex;
align-items: center;
gap: 0.3125rem;
white-space: nowrap;
}
.prog-warn svg {
width: 0.875rem;
height: 0.875rem;
}
.toolbar .search {
width: 15rem;
}
/* 업무 행 */
.task-row {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1.125rem;
border-top: 1px solid var(--border);
cursor: pointer;
text-decoration: none;
color: inherit;
}
.task-row:first-child {
border-top: none;
}
.task-row:hover {
background: #fafbfc;
}
.task-main {
flex: 1;
min-width: 0;
}
.task-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-title .tid {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-3);
}
.task-row.is-done .task-title {
color: var(--text-2);
}
.task-meta {
display: flex;
align-items: center;
gap: 0.8125rem;
margin-top: 0.1875rem;
font-size: 0.75rem;
color: var(--text-3);
}
.task-meta .mi {
display: flex;
align-items: center;
gap: 0.3125rem;
white-space: nowrap;
}
.task-meta .mi svg {
width: 0.8125rem;
height: 0.8125rem;
}
.task-meta .mi.overdue {
color: var(--red);
font-weight: 600;
}
.task-right {
display: flex;
align-items: center;
gap: 0.875rem;
flex-shrink: 0;
}
.task-right .st-badge {
min-width: 3.5rem;
border: 1px solid transparent;
}
.task-right .st-badge.done {
border-color: var(--green-border);
}
.task-right .st-badge.prog {
border-color: var(--blue-border);
}
.task-right .st-badge.review {
border-color: var(--amber-border);
}
.task-right .st-badge.todo {
border-color: var(--border);
}
.chev {
color: var(--text-3);
display: grid;
place-items: center;
}
.chev svg {
width: 1.125rem;
height: 1.125rem;
}
.not-found-msg {
padding: 3rem 0;
text-align: center;
color: var(--text-2);
}
</style>
+470
View File
@@ -0,0 +1,470 @@
<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 { useRepoStore } from '@/stores/repo.store'
import type { Visibility } from '@/mock/relay.mock'
type Filter = 'all' | Visibility
const keyword = ref('')
const filter = ref<Filter>('all')
const repoStore = useRepoStore()
const { repos, loading } = storeToRefs(repoStore)
onMounted(() => {
void repoStore.fetchList()
})
// 검색어 + 공개여부 필터 적용
const filteredRepos = computed(() =>
repos.value.filter((repo) => {
const matchKeyword =
!keyword.value ||
repo.name.includes(keyword.value) ||
repo.slug.includes(keyword.value)
const matchFilter = filter.value === 'all' || repo.visibility === filter.value
return matchKeyword && matchFilter
}),
)
</script>
<template>
<AppShell>
<div class="page">
<div class="crumb">
<b>저장소</b>
</div>
<div class="pagehead">
<h1>저장소</h1>
<span class="count-pill">{{ repos.length }}</span>
<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">
<b>marketing-team</b> 조직의 저장소입니다. 저장소를 열어 업무를 작성하고 담당자에게
전달하세요.
</p>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle
cx="11"
cy="11"
r="7"
/>
<path d="m21 21-4-4" />
</svg>
<input
v-model="keyword"
type="text"
placeholder="저장소 검색…"
>
</div>
<div class="segmented">
<button
:class="{ active: filter === 'all' }"
@click="filter = 'all'"
>
전체
</button>
<button
:class="{ active: filter === 'private' }"
@click="filter = 'private'"
>
비공개
</button>
<button
:class="{ active: filter === 'public' }"
@click="filter = 'public'"
>
공개
</button>
</div>
<div class="sort">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M11 5h10M11 9h7M11 13h4M3 17l3 3 3-3M6 18V4" />
</svg>
최근 업데이트순
</div>
<RouterLink
class="btn primary"
to="/repos/new"
>
<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>
저장소
</RouterLink>
</div>
<!-- 목록 -->
<div class="list">
<RouterLink
v-for="repo in filteredRepos"
:key="repo.id"
class="repo-row"
:to="`/repos/${repo.id}`"
>
<div class="repo-ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<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>
</div>
<div class="repo-main">
<div class="repo-title">
<span class="repo-name">{{ repo.name }}</span>
<span class="repo-slug">{{ repo.slug }}</span>
<span
class="vis"
:class="repo.visibility"
>
<svg
v-if="repo.visibility === 'private'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/>
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<svg
v-else
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.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>
{{ repo.visibility === 'private' ? '비공개' : '공개' }}
</span>
</div>
<div class="repo-desc">
{{ repo.desc }}
</div>
<div class="repo-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 repo.members"
:key="m.id"
class="avatar"
:class="m.color"
>{{ m.initial }}</span>
<span
v-if="repo.moreCount > 0"
class="avatar av-more"
>+{{ repo.moreCount }}</span>
</span>
<span class="mi">{{ repo.updatedAgo }}</span>
</div>
</div>
<div class="repo-stat">
<div class="stat-top">
<b>{{ repo.doneCount }}</b> / {{ repo.totalCount }} 업무
</div>
<div class="pbar">
<i
:class="repo.progressLevel === 'mid' ? 'mid' : repo.progressLevel === 'low' ? 'low' : ''"
:style="{ width: repo.progressPct + '%' }"
/>
</div>
<div class="stat-pct">
{{ repo.progressLabel }}
</div>
</div>
<span class="chev">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="m9 18 6-6-6-6" />
</svg>
</span>
</RouterLink>
<!-- 로딩 / 상태 -->
<div
v-if="loading && repos.length === 0"
class="list-empty"
>
불러오는
</div>
<div
v-else-if="filteredRepos.length === 0"
class="list-empty"
>
{{ repos.length === 0 ? '아직 저장소가 없습니다. 새 저장소를 만들어 보세요.' : '검색 결과가 없습니다.' }}
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.pagehead {
display: flex;
align-items: center;
gap: 0.6875rem;
padding: 0.5625rem 0 0.25rem;
}
.pagehead h1 {
font-size: 1.375rem;
font-weight: 700;
letter-spacing: -0.025rem;
white-space: nowrap;
}
.gitea-badge {
margin-left: auto;
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: 0.25rem 0 1.125rem;
}
.lede b {
color: var(--text-2);
}
/* 저장소 행 */
.repo-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-top: 1px solid var(--border);
cursor: pointer;
text-decoration: none;
color: inherit;
}
.repo-row:first-child {
border-top: none;
}
.repo-row:hover {
background: #fafbfc;
}
.repo-ico {
width: 2.5rem;
height: 2.5rem;
border-radius: 0.5625rem;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
flex-shrink: 0;
align-self: flex-start;
margin-top: 0.125rem;
}
.repo-ico svg {
width: 1.3125rem;
height: 1.3125rem;
}
.repo-main {
flex: 1;
min-width: 0;
}
.repo-title {
display: flex;
align-items: center;
column-gap: 0.5625rem;
row-gap: 0.125rem;
flex-wrap: wrap;
}
.repo-name {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.repo-slug {
order: 2;
flex-basis: 100%;
font-size: 0.75rem;
font-weight: 500;
color: var(--text-3);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.repo-row:hover .repo-name {
color: var(--accent);
}
.repo-desc {
font-size: 0.8125rem;
color: var(--text-2);
margin-top: 0.25rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 30rem;
}
.repo-meta {
display: flex;
align-items: center;
gap: 0.9375rem;
margin-top: 0.625rem;
font-size: 0.75rem;
color: var(--text-3);
}
.repo-meta .mi {
display: flex;
align-items: center;
gap: 0.3125rem;
white-space: nowrap;
}
.repo-meta .mi svg {
width: 0.8125rem;
height: 0.8125rem;
}
.repo-stat {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.375rem;
width: 10.5rem;
flex-shrink: 0;
}
.stat-top {
font-size: 0.781rem;
color: var(--text-2);
font-weight: 600;
white-space: nowrap;
}
.stat-top b {
color: var(--text);
}
.repo-stat .pbar {
width: 9.75rem;
}
.stat-pct {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.chev {
color: var(--text-3);
flex-shrink: 0;
display: grid;
place-items: center;
}
.chev svg {
width: 1.125rem;
height: 1.125rem;
}
.list-empty {
padding: 2.5rem 1.25rem;
text-align: center;
color: var(--text-3);
font-size: 0.844rem;
}
</style>
@@ -0,0 +1,841 @@
<script setup lang="ts">
// 저장소 멤버 — 멤버 목록 + 역할/제거 + 멤버 초대 모달
import { computed, onMounted, onUnmounted, ref } 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 { findRepo, findRepoMembers } from '@/mock/relay.mock'
type RoleFilter = 'all' | 'admin' | 'member'
const route = useRoute()
const repoId = computed(() => String(route.params.repoId))
const repo = computed(() => findRepo(repoId.value))
const members = computed(() => findRepoMembers(repoId.value))
// 헤더 아바타용 멤버(전체 로스터)
const headerMembers = computed(() => members.value.map((m) => m.user))
const keyword = ref('')
const filter = ref<RoleFilter>('all')
const counts = computed(() => ({
all: members.value.length,
admin: members.value.filter((m) => m.roleType === 'admin').length,
member: members.value.filter((m) => m.roleType === 'member').length,
}))
const filteredMembers = computed(() =>
members.value.filter((m) => {
const matchRole = filter.value === 'all' || m.roleType === filter.value
const kw = keyword.value.trim()
const matchKeyword = !kw || m.user.name.includes(kw) || m.email.includes(kw)
return matchRole && matchKeyword
}),
)
// 초대 모달
const showInvite = ref(false)
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') showInvite.value = false
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</script>
<template>
<AppShell>
<div
v-if="repo"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
class="lnk"
>
저장소
</RouterLink>
<span class="sep">/</span>
<RouterLink
:to="`/repos/${repo.id}`"
class="lnk"
>
{{ repo.name }}
</RouterLink>
<span class="sep">/</span>
<b>멤버</b>
</div>
<RepoHeader
:repo="repo"
meta-text="멤버 5명"
:members="headerMembers"
:more-count="0"
/>
<RepoTabs
:repo-id="repo.id"
active="members"
:task-count="12"
:member-count="counts.all"
/>
<div class="tab-note">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="12"
cy="12"
r="10"
/><path d="M12 16v-4M12 8h.01" />
</svg>
저장소의 멤버만 업무 담당자로 지정할 있습니다. <b>관리자</b> 업무 지시·멤버 관리가 가능하고, <b>멤버</b> 담당자로 배정되어 업무를 수행합니다.
</div>
<!-- 툴바 -->
<div class="toolbar">
<div class="search">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle
cx="11"
cy="11"
r="7"
/><path d="m21 21-4-4" />
</svg>
<input
v-model="keyword"
type="text"
placeholder="이름·이메일 검색…"
>
</div>
<div class="segmented">
<button
:class="{ active: filter === 'all' }"
@click="filter = 'all'"
>
전체 <span class="n">{{ counts.all }}</span>
</button>
<button
:class="{ active: filter === 'admin' }"
@click="filter = 'admin'"
>
관리자 <span class="n">{{ counts.admin }}</span>
</button>
<button
:class="{ active: filter === 'member' }"
@click="filter = 'member'"
>
멤버 <span class="n">{{ counts.member }}</span>
</button>
</div>
<button
class="btn primary invite"
type="button"
@click="showInvite = true"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle
cx="9"
cy="7"
r="4"
/><path d="M19 8v6M22 11h-6" />
</svg>
멤버 초대
</button>
</div>
<!-- 멤버 목록 -->
<div class="list">
<div
v-for="m in filteredMembers"
:key="m.user.id"
class="mem-row"
>
<span
class="mem-av"
:class="m.user.color"
>{{ m.user.initial }}</span>
<div class="mem-info">
<div class="mem-name">
{{ m.user.name }} <span
v-if="m.isYou"
class="you-pill"
></span>
</div>
<div class="mem-sub">
{{ m.email }} <span class="dot">·</span> {{ m.subRole }}
</div>
</div>
<div
class="mem-tasks"
:class="{ zero: m.taskCount === 0 }"
>
<b>{{ m.taskCount }}</b> 담당
</div>
<div
v-if="m.owner"
class="role fixed admin"
>
<span class="role-dot" />관리자
<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>
<button
v-else
class="role"
type="button"
>
<span class="role-dot" />{{ m.roleType === 'admin' ? '관리자' : '멤버' }}
<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>
</button>
<button
class="mem-remove"
:class="{ disabled: m.owner }"
type="button"
:title="m.owner ? '소유자는 제거할 수 없습니다' : '멤버 제거'"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
</div>
</div>
<!-- 멤버 초대 모달 -->
<Teleport to="body">
<div
class="modal-backdrop"
:class="{ open: showInvite }"
@click.self="showInvite = false"
>
<div
class="invite-modal"
role="dialog"
aria-modal="true"
>
<div class="modal-head">
<div>
<h2>멤버 초대</h2>
<div class="mh-sub">
<b>{{ repo?.name }}</b> 저장소에 멤버를 추가합니다.
</div>
</div>
<button
class="modal-close"
type="button"
@click="showInvite = false"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div class="modal-body">
<label class="m-label">초대할 사람</label>
<div class="icombo">
<div class="icombo-field">
<span class="ichip"><span class="avatar av-f mini"></span>한지민 <span class="x">×</span></span>
<span class="ichip ext">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><rect
x="2"
y="4"
width="20"
height="16"
rx="2"
/><path d="m22 6-10 7L2 6" /></svg>
partner@vendor.com <span class="x">×</span>
</span>
<input
class="icombo-input"
placeholder="이름 또는 이메일 입력…"
>
</div>
</div>
<div class="m-row">
<div class="m-role">
<label class="m-label">역할</label>
<div class="select-wrap">
<select>
<option>멤버</option>
<option>관리자</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="m-grow">
<label class="m-label">초대 메시지 <span class="muted">(선택)</span></label>
<input
class="m-input"
type="text"
placeholder="함께 캠페인 준비해요!"
>
</div>
</div>
<div class="role-hint">
<b>멤버</b> 담당자로 배정되어 업무를 수행하고, <b>관리자</b> 업무 지시와 멤버 관리도 있습니다.
</div>
</div>
<div class="modal-foot">
<span class="fnote">초대받은 사람에게 메일이 발송됩니다.</span>
<button
class="mbtn"
type="button"
@click="showInvite = false"
>
취소
</button>
<button
class="mbtn primary"
type="button"
@click="showInvite = false"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
초대 보내기 (2)
</button>
</div>
</div>
</div>
</Teleport>
</AppShell>
</template>
<style scoped>
.crumb {
padding: 1.125rem 0 0.75rem;
}
.tab-note {
font-size: 0.781rem;
color: var(--text-3);
margin: 0.75rem 0 1rem;
display: flex;
align-items: center;
gap: 0.4375rem;
}
.tab-note svg {
width: 0.875rem;
height: 0.875rem;
color: var(--text-3);
flex-shrink: 0;
}
.tab-note b {
color: var(--text-2);
font-weight: 600;
white-space: nowrap;
}
.toolbar .search {
width: 15rem;
}
.invite {
margin-left: auto;
border: none;
}
/* 멤버 행 */
.mem-row {
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0.875rem 1.125rem;
border-top: 1px solid var(--border);
}
.mem-row:first-child {
border-top: none;
}
.mem-row:hover {
background: #fafbfc;
}
.mem-av {
width: 2.375rem;
height: 2.375rem;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 0.875rem;
font-weight: 700;
color: #fff;
flex-shrink: 0;
}
.mem-av.av-a {
background: #e0518d;
}
.mem-av.av-b {
background: #3d8bf2;
}
.mem-av.av-c {
background: #16a37b;
}
.mem-av.av-d {
background: #b3631f;
}
.mem-av.av-e {
background: #7c5cf0;
}
.mem-av.av-f {
background: #d0982a;
}
.mem-info {
flex: 1;
min-width: 0;
}
.mem-name {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.you-pill {
font-size: 0.656rem;
font-weight: 700;
color: var(--accent);
background: var(--accent-weak);
border: 1px solid var(--accent-border);
border-radius: 20px;
padding: 0.0625rem 0.4375rem;
line-height: 1.5;
}
.mem-sub {
font-size: 0.781rem;
color: var(--text-3);
margin-top: 0.125rem;
}
.mem-sub .dot {
margin: 0 0.375rem;
opacity: 0.5;
}
.mem-tasks {
font-size: 0.781rem;
color: var(--text-2);
width: 5.375rem;
text-align: right;
flex-shrink: 0;
white-space: nowrap;
}
.mem-tasks b {
color: var(--text);
}
.mem-tasks.zero {
color: var(--text-3);
}
/* 역할 컨트롤 */
.role {
display: inline-flex;
align-items: center;
gap: 0.4375rem;
height: 2rem;
padding: 0 0.6875rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
background: #fff;
cursor: pointer;
white-space: nowrap;
width: 6.5rem;
font-family: inherit;
}
.role:hover {
background: #f7f8fa;
}
.role .role-dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
background: var(--text-3);
flex-shrink: 0;
}
.role.admin {
color: var(--accent);
border-color: var(--accent-border);
background: var(--accent-weak);
}
.role.admin .role-dot {
background: var(--accent);
}
.role .caret {
margin-left: auto;
color: var(--text-3);
display: grid;
place-items: center;
}
.role .caret svg {
width: 0.875rem;
height: 0.875rem;
}
.role.fixed {
background: #f1f2f4;
cursor: not-allowed;
color: var(--text-2);
border-color: var(--border-strong);
}
.role.fixed .role-dot {
background: var(--accent);
}
.role.fixed .lock {
margin-left: auto;
color: var(--text-3);
display: grid;
place-items: center;
}
.role.fixed .lock svg {
width: 0.8125rem;
height: 0.8125rem;
}
.mem-remove {
width: 2rem;
height: 2rem;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-3);
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
opacity: 0;
}
.mem-row:hover .mem-remove {
opacity: 1;
}
.mem-remove:hover {
background: #f1f2f4;
color: var(--red);
}
.mem-remove svg {
width: 1rem;
height: 1rem;
}
.mem-remove.disabled {
opacity: 0 !important;
pointer-events: none;
}
</style>
<!-- 초대 모달은 Teleport body 렌더되므로 -scoped 전역 스타일 -->
<style>
.invite-modal {
width: 100%;
max-width: 32.75rem;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
overflow: hidden;
}
.invite-modal .modal-head {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1.375rem 1.5rem 1rem;
}
.invite-modal .modal-head h2 {
font-size: 1.125rem;
font-weight: 700;
letter-spacing: -0.01875rem;
}
.invite-modal .mh-sub {
font-size: 0.8125rem;
color: var(--text-2);
margin-top: 0.25rem;
}
.invite-modal .mh-sub b {
color: var(--text-2);
font-weight: 600;
}
.invite-modal .modal-close {
margin-left: auto;
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
display: grid;
place-items: center;
cursor: pointer;
flex-shrink: 0;
}
.invite-modal .modal-close:hover {
background: #f1f2f4;
color: var(--text);
}
.invite-modal .modal-close svg {
width: 1.125rem;
height: 1.125rem;
}
.invite-modal .modal-body {
padding: 0.25rem 1.5rem 0.5rem;
}
.invite-modal .m-label {
font-size: 0.781rem;
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.5rem;
display: block;
}
.invite-modal .m-label .muted {
color: var(--text-3);
font-weight: 500;
}
.invite-modal .icombo {
position: relative;
}
.invite-modal .icombo-field {
border: 1px solid var(--accent);
border-radius: var(--radius);
padding: 0.4375rem 0.5rem;
background: #fff;
box-shadow: 0 0 0 3px var(--accent-weak);
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.invite-modal .ichip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 20px;
padding: 0.1875rem 0.5rem 0.1875rem 0.25rem;
font-size: 0.781rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-modal .ichip .avatar.mini {
width: 1.125rem;
height: 1.125rem;
font-size: 0.5625rem;
}
.invite-modal .ichip.ext {
background: var(--accent-weak);
border-color: var(--accent-border);
color: var(--accent);
}
.invite-modal .ichip .x {
color: var(--text-3);
cursor: pointer;
font-size: 0.8125rem;
line-height: 1;
}
.invite-modal .ichip .x:hover {
color: var(--red);
}
.invite-modal .icombo-input {
flex: 1;
min-width: 7.5rem;
border: none;
outline: none;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.25rem 0.125rem;
background: transparent;
}
.invite-modal .icombo-input::placeholder {
color: var(--text-3);
}
.invite-modal .m-row {
display: flex;
gap: 0.75rem;
margin-top: 1.125rem;
}
.invite-modal .m-role {
flex: 0 0 9.375rem;
}
.invite-modal .m-grow {
flex: 1;
}
.invite-modal .select-wrap {
position: relative;
}
.invite-modal .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.125rem 0 0.75rem;
font-family: inherit;
font-size: 0.844rem;
font-weight: 600;
color: var(--text);
background: #fff;
cursor: pointer;
}
.invite-modal .select-wrap select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.invite-modal .select-wrap .caret {
position: absolute;
right: 0.6875rem;
top: 50%;
transform: translateY(-50%);
color: var(--text-3);
pointer-events: none;
display: grid;
place-items: center;
}
.invite-modal .select-wrap .caret svg {
width: 0.9375rem;
height: 0.9375rem;
}
.invite-modal .m-input {
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.844rem;
color: var(--text);
background: #fff;
}
.invite-modal .m-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.invite-modal .m-input::placeholder {
color: var(--text-3);
}
.invite-modal .role-hint {
font-size: 0.75rem;
color: var(--text-3);
margin-top: 0.5rem;
line-height: 1.5;
}
.invite-modal .role-hint b {
color: var(--text-2);
font-weight: 600;
}
.invite-modal .modal-foot {
display: flex;
align-items: center;
gap: 0.5625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
background: #fafbfc;
margin-top: 1rem;
}
.invite-modal .modal-foot .fnote {
font-size: 0.75rem;
color: var(--text-3);
margin-right: auto;
}
.invite-modal .mbtn {
height: 2.375rem;
padding: 0 1rem;
border-radius: var(--radius);
font-size: 0.844rem;
font-weight: 600;
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text-2);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-family: inherit;
}
.invite-modal .mbtn:hover {
background: #f1f2f4;
color: var(--text);
}
.invite-modal .mbtn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.invite-modal .mbtn.primary:hover {
background: var(--accent-hover);
}
.invite-modal .mbtn svg {
width: 0.9375rem;
height: 0.9375rem;
}
</style>
@@ -0,0 +1,679 @@
<script setup lang="ts">
// 저장소 설정 — 일반 설정 + 위험 구역 (관리자 전용)
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 { useRepoStore } from '@/stores/repo.store'
import { type Repo, type Visibility } from '@/mock/relay.mock'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const repoStore = useRepoStore()
const repo = ref<Repo | null>(null)
const headerMembers = computed(() => repo.value?.members ?? [])
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)
} catch {
repo.value = null
return
}
if (!repo.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 })
// 변경 저장
async function save() {
if (saving.value || !repo.value) return
saving.value = true
try {
repo.value = await repoStore.update(repoId.value, {
name: displayTitle.value.trim(),
description: description.value.trim(),
visibility: visibility.value,
branch: branch.value.trim() || 'main',
})
window.alert('변경 사항을 저장했습니다.')
} catch {
// 인터셉터 전역 처리
} finally {
saving.value = false
}
}
// 저장소 삭제
async function removeRepo() {
if (!repo.value) return
if (!window.confirm('저장소와 그 안의 모든 업무·활동 기록이 영구 삭제됩니다. 계속할까요?')) return
try {
await repoStore.remove(repoId.value)
await router.push('/repos')
} catch {
// 인터셉터 전역 처리
}
}
// 저장소 이름(slug) 변경 — 8단계 이후 지원 예정
function renameRepo() {
window.alert('저장소 이름(slug) 변경은 준비 중입니다.')
}
</script>
<template>
<AppShell>
<div
v-if="repo"
class="page"
>
<div class="crumb">
<RouterLink
to="/repos"
class="lnk"
>
저장소
</RouterLink>
<span class="sep">/</span>
<RouterLink
:to="`/repos/${repo.id}`"
class="lnk"
>
{{ repo.name }}
</RouterLink>
<span class="sep">/</span>
<b>설정</b>
</div>
<RepoHeader
:repo="repo"
:meta-text="`멤버 ${memberCount}`"
:members="headerMembers"
:more-count="repo.moreCount"
/>
<RepoTabs
:repo-id="repo.id"
active="settings"
:task-count="repo.totalCount"
:member-count="memberCount"
/>
<div class="settings">
<div class="admin-note">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="3"
y="11"
width="18"
height="11"
rx="2"
/><path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
저장소 설정은 <b>관리자</b> 변경할 있습니다.
</div>
<!-- 일반 -->
<h2 class="sec-title">
일반
</h2>
<div class="card">
<div class="fblock">
<div class="flabel">
표시 제목
</div>
<div class="fhint">
Relay 목록과 화면에 표시되는 이름입니다. (한글 가능)
</div>
<input
v-model="displayTitle"
class="tinput"
type="text"
maxlength="40"
>
</div>
<div class="fblock">
<div class="flabel">
소유자 / Gitea 저장소 이름 <span class="fixed-pill">소유자 고정</span>
</div>
<div class="fhint">
Gitea 저장소 이름(영문) 주소에 사용됩니다. 변경은 아래 위험 구역에서 있습니다.
</div>
<div class="readonly">
<span class="owner-avatar">M</span> {{ 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>
</div>
<div class="fblock">
<div class="flabel">
프로젝트 설명
</div>
<textarea
v-model="description"
class="tinput tarea"
/>
</div>
<div class="fblock">
<div class="flabel">
공개 범위
</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">
메인 브랜치
</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"
type="button"
:disabled="saving"
@click="save"
>
{{ saving ? '저장 중…' : '변경 저장' }}
</button>
</div>
</div>
<!-- 위험 구역 -->
<h2 class="sec-title danger">
위험 구역
</h2>
<div class="danger-card">
<div class="danger-row">
<div class="dr-main">
<div class="dr-title">
Gitea 저장소 이름 변경
</div>
<div class="dr-desc">
영문 저장소 이름과 Gitea 주소가 함께 바뀝니다. 기존 링크가 동작하지 않을 있습니다.
</div>
</div>
<button
class="btn-danger"
type="button"
@click="renameRepo"
>
이름 변경
</button>
</div>
<div class="danger-row">
<div class="dr-main">
<div class="dr-title">
저장소 삭제
</div>
<div class="dr-desc">
저장소와 안의 모든 업무·활동 기록이 영구 삭제됩니다. 되돌릴 없습니다.
</div>
</div>
<button
class="btn-danger solid"
type="button"
@click="removeRepo"
>
저장소 삭제
</button>
</div>
</div>
</div>
</div>
</AppShell>
</template>
<style scoped>
.crumb {
padding: 1.125rem 0 0.75rem;
}
.settings {
max-width: 45rem;
}
.admin-note {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.781rem;
color: var(--text-3);
margin-bottom: 1.125rem;
}
.admin-note svg {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
.admin-note b {
color: var(--text-2);
font-weight: 600;
white-space: nowrap;
}
.sec-title {
font-size: 0.9375rem;
font-weight: 700;
letter-spacing: -0.0125rem;
margin: 0 0 0.75rem;
}
.sec-title.danger {
color: var(--red);
}
.card {
margin-bottom: 1.875rem;
}
.fblock {
padding: 1.25rem 1.375rem;
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.3125rem;
display: flex;
align-items: center;
gap: 0.4375rem;
}
.fhint {
font-size: 0.781rem;
color: var(--text-3);
margin-bottom: 0.6875rem;
line-height: 1.5;
}
.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;
}
.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);
}
textarea.tarea {
min-height: 5.25rem;
padding: 0.625rem 0.75rem;
line-height: 1.6;
resize: vertical;
height: auto;
}
.readonly {
display: flex;
align-items: center;
gap: 0.5625rem;
height: 2.5rem;
padding: 0 0.8125rem;
background: #f5f6f8;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-2);
font-size: 0.875rem;
font-weight: 500;
cursor: not-allowed;
}
.readonly .owner-avatar {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.3125rem;
background: #0ea5a3;
color: #fff;
display: grid;
place-items: center;
font-size: 0.656rem;
font-weight: 700;
}
.readonly .slash {
color: var(--text-3);
}
.readonly .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--text);
font-weight: 600;
}
.readonly .lock {
margin-left: auto;
color: var(--text-3);
display: grid;
place-items: center;
}
.readonly .lock svg {
width: 0.875rem;
height: 0.875rem;
}
/* 공개 범위 라디오 카드 */
.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-ico {
width: 2rem;
height: 2rem;
border-radius: 0.5rem;
background: #f1f2f4;
color: var(--text-2);
display: grid;
place-items: center;
flex-shrink: 0;
}
.rc-ico svg {
width: 1.0625rem;
height: 1.0625rem;
}
.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.4375rem;
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);
}
.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;
max-width: 18.75rem;
}
.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);
}
.card-foot {
display: flex;
justify-content: flex-end;
padding: 0.875rem 1.375rem;
border-top: 1px solid var(--border);
background: #fafbfc;
border-radius: 0 0 0.625rem 0.625rem;
}
/* 위험 구역 */
.danger-card {
background: #fff;
border: 1px solid var(--red-border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.danger-row {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
border-top: 1px solid #f6dde2;
}
.danger-row:first-child {
border-top: none;
}
.dr-main {
flex: 1;
min-width: 0;
}
.dr-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
}
.dr-desc {
font-size: 0.781rem;
color: var(--text-2);
margin-top: 0.1875rem;
line-height: 1.5;
}
.btn-danger {
height: 2.125rem;
padding: 0 0.875rem;
border-radius: var(--radius);
font-size: 0.8125rem;
font-weight: 600;
border: 1px solid var(--red-border);
background: #fff;
color: var(--red);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
font-family: inherit;
}
.btn-danger:hover {
background: var(--red-weak);
}
.btn-danger.solid {
background: var(--red);
border-color: var(--red);
color: #fff;
}
.btn-danger.solid:hover {
background: #c23a51;
}
</style>
+860
View File
@@ -0,0 +1,860 @@
<script setup lang="ts">
// 회원가입 — 가입 폼 (1단계: 이메일+JWT. 이메일 인증은 8단계 예정 → 가입 즉시 자동 로그인)
import { computed, ref } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
const router = useRouter()
const authStore = useAuthStore()
// 폼 상태
const name = ref('')
const email = ref('')
const password = ref('')
const passwordConfirm = ref('')
const showPassword = ref(false)
const showPasswordConfirm = ref(false)
const agreeTerms = ref(true)
const submitting = ref(false)
// 화면 단계: 'form'(가입 폼) → 'verify'(인증 메일 전송됨, 8단계에서 활성화)
const step = ref<'form' | 'verify'>('form')
const sentEmail = computed(() => email.value.trim() || 'name@acme.co')
// 가입 처리 — 검증 후 API 호출, 성공 시 자동 로그인되어 저장소 목록으로 이동
async function submit() {
if (submitting.value) return
// 클라이언트 1차 검증 (서버 DTO 가 최종 검증)
if (!name.value.trim() || !email.value.trim() || !password.value) {
window.alert('이름, 이메일, 비밀번호를 모두 입력해 주세요.')
return
}
if (password.value.length < 8) {
window.alert('비밀번호는 최소 8자 이상이어야 합니다.')
return
}
if (password.value !== passwordConfirm.value) {
window.alert('비밀번호가 일치하지 않습니다.')
return
}
if (!agreeTerms.value) {
window.alert('이용약관 및 개인정보 처리방침에 동의해 주세요.')
return
}
submitting.value = true
try {
await authStore.signup({
name: name.value.trim(),
email: email.value.trim(),
password: password.value,
})
await router.push('/repos')
} catch {
// 에러 메시지는 API 인터셉터에서 전역 처리됨
} finally {
submitting.value = false
}
}
function backToForm() {
step.value = 'form'
}
const points = [
'이메일로 1분이면 가입 완료',
'이메일 인증으로 안전한 가입',
'가입 후 바로 업무 흐름에 참여',
]
</script>
<template>
<div class="auth">
<!-- 브랜드 패널 -->
<aside class="brand-panel">
<div class="blob b1" />
<div class="blob b2" />
<div class="ring" />
<div class="bp-logo">
<span class="logo">R</span> Relay
</div>
<div class="bp-body">
<div class="bp-head">
팀에 합류하고<br>업무 지시를 시작하세요
</div>
<div class="bp-sub">
계정을 만들면 저장소에 초대되어, 받은 업무를 확인하고 직접 업무를 지시할 있습니다.
</div>
<div class="bp-points">
<div
v-for="point in points"
:key="point"
class="bp-point"
>
<span class="ic">
<svg
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>
</span>
{{ point }}
</div>
</div>
</div>
<div class="bp-foot">
© 2026 Relay · 사내 업무 지시 플랫폼
</div>
</aside>
<!-- 패널 -->
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo">
<span class="logo">R</span> Relay
</div>
<!-- 상태 1: 가입 -->
<form
v-if="step === 'form'"
class="signup-form"
@submit.prevent="submit"
>
<h1 class="ac-title">
이메일로 회원가입
</h1>
<p class="ac-sub">
구글·카카오 계정은 <RouterLink
class="inline-link"
to="/login"
>
로그인 화면
</RouterLink>에서 바로 시작할 있어요.
</p>
<div class="field first">
<label
class="flabel"
for="su-name"
>이름</label>
<input
id="su-name"
v-model="name"
class="tinput"
type="text"
placeholder="홍길동"
autocomplete="name"
>
</div>
<div class="field">
<label
class="flabel"
for="su-email"
>이메일</label>
<input
id="su-email"
v-model="email"
class="tinput"
type="email"
placeholder="name@acme.co"
autocomplete="email"
>
<div class="field-hint">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="2"
y="4"
width="20"
height="16"
rx="2"
/><path d="m22 6-10 7L2 6" />
</svg>
가입 주소로 인증 메일이 발송됩니다.
</div>
</div>
<div class="field">
<label
class="flabel"
for="su-pw"
>비밀번호</label>
<div class="pw-wrap">
<input
id="su-pw"
v-model="password"
class="tinput"
:type="showPassword ? 'text' : 'password'"
placeholder="비밀번호 입력"
autocomplete="new-password"
>
<button
class="pw-toggle"
type="button"
:aria-label="showPassword ? '비밀번호 숨기기' : '비밀번호 표시'"
@click="showPassword = !showPassword"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
cx="12"
cy="12"
r="3"
/>
</svg>
</button>
</div>
<div class="field-hint">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle
cx="12"
cy="12"
r="10"
/><path d="M12 16v-4M12 8h.01" />
</svg>
8 이상, 영문과 숫자를 포함해주세요.
</div>
</div>
<div class="field">
<label
class="flabel"
for="su-pw2"
>비밀번호 확인</label>
<div class="pw-wrap">
<input
id="su-pw2"
v-model="passwordConfirm"
class="tinput"
:type="showPasswordConfirm ? 'text' : 'password'"
placeholder="비밀번호 다시 입력"
autocomplete="new-password"
>
<button
class="pw-toggle"
type="button"
:aria-label="showPasswordConfirm ? '비밀번호 숨기기' : '비밀번호 표시'"
@click="showPasswordConfirm = !showPasswordConfirm"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
cx="12"
cy="12"
r="3"
/>
</svg>
</button>
</div>
</div>
<button
class="terms"
type="button"
@click="agreeTerms = !agreeTerms"
>
<span
class="box"
:class="{ on: agreeTerms }"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
<span><a href="#">이용약관</a> <a href="#">개인정보 처리방침</a> 동의합니다. (필수)</span>
</button>
<button
class="submit"
type="submit"
:disabled="submitting"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="2"
y="4"
width="20"
height="16"
rx="2"
/><path d="m22 6-10 7L2 6" />
</svg>
{{ submitting ? '가입 중…' : '가입하고 시작하기' }}
</button>
<div class="alt">
이미 계정이 있으신가요? <RouterLink to="/login">
로그인
</RouterLink>
</div>
</form>
<!-- 상태 2: 인증 메일 전송됨 -->
<div
v-else
class="verify"
>
<div class="vicon">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="2"
y="4"
width="20"
height="16"
rx="2"
/><path d="m22 6-10 7L2 6" />
</svg>
</div>
<h1>이메일을 확인해주세요</h1>
<div class="vtext">
아래 주소로 인증 메일을 보냈습니다.<br>메일 링크를 클릭하면 가입이 완료됩니다.
</div>
<div class="vmail">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect
x="2"
y="4"
width="20"
height="16"
rx="2"
/><path d="m22 6-10 7L2 6" />
</svg>
<span>{{ sentEmail }}</span>
</div>
<div class="vspam">
메일이 보이지 않나요? 스팸함을 확인하거나 잠시 다시 시도해주세요.
</div>
<div class="vactions">
<button
class="btn-ghost"
type="button"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" />
</svg>
인증 메일 다시 보내기
</button>
<button
class="vchange"
type="button"
@click="backToForm"
>
다른 이메일로 가입하기
</button>
</div>
</div>
</div>
</main>
</div>
</template>
<style scoped>
/* 회원가입도 로그인과 동일한 auth 레이아웃(큰 모서리 반경) */
.auth {
--radius: 8px;
display: flex;
min-height: 100vh;
}
/* ---- 브랜드 패널 ---- */
.brand-panel {
flex: 0 0 46%;
max-width: 38.75rem;
position: relative;
overflow: hidden;
background: linear-gradient(155deg, #4f46e5 0%, #4338ca 52%, #2e2a8f 100%);
color: #fff;
padding: 2.75rem 3rem;
display: flex;
flex-direction: column;
}
.brand-panel .blob {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.brand-panel .b1 {
width: 22.5rem;
height: 22.5rem;
right: -7.5rem;
top: -5.625rem;
background: rgba(255, 255, 255, 0.1);
}
.brand-panel .b2 {
width: 16.25rem;
height: 16.25rem;
left: -5.625rem;
bottom: -4.375rem;
background: rgba(255, 255, 255, 0.07);
}
.brand-panel .ring {
position: absolute;
right: 3.75rem;
bottom: 5rem;
width: 11.25rem;
height: 11.25rem;
border: 1.5px solid rgba(255, 255, 255, 0.14);
border-radius: 50%;
}
.brand-panel .ring::after {
content: '';
position: absolute;
inset: 2.125rem;
border: 1.5px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
}
.bp-logo {
display: flex;
align-items: center;
gap: 0.625rem;
font-weight: 700;
font-size: 1.125rem;
letter-spacing: -0.0125rem;
position: relative;
z-index: 1;
}
.bp-logo .logo {
width: 2rem;
height: 2rem;
border-radius: 0.5625rem;
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.22);
display: grid;
place-items: center;
font-size: 1.0625rem;
font-weight: 800;
}
.bp-body {
margin-top: auto;
position: relative;
z-index: 1;
}
.bp-head {
font-size: 2.125rem;
font-weight: 700;
line-height: 1.32;
letter-spacing: -0.05rem;
text-wrap: balance;
}
.bp-sub {
margin-top: 1.125rem;
font-size: 0.9375rem;
line-height: 1.7;
color: rgba(255, 255, 255, 0.82);
max-width: 25rem;
}
.bp-points {
margin-top: 1.875rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bp-point {
display: flex;
align-items: center;
gap: 0.6875rem;
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.92);
}
.bp-point .ic {
width: 1.375rem;
height: 1.375rem;
border-radius: 50%;
background: rgba(255, 255, 255, 0.16);
display: grid;
place-items: center;
flex-shrink: 0;
}
.bp-point .ic svg {
width: 0.8125rem;
height: 0.8125rem;
}
.bp-foot {
margin-top: 2.5rem;
font-size: 0.781rem;
color: rgba(255, 255, 255, 0.6);
position: relative;
z-index: 1;
}
/* ---- 폼 패널 ---- */
.form-panel {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2.5rem 1.5rem;
}
.auth-card {
width: 100%;
max-width: 24rem;
}
.ac-logo {
display: none;
}
.ac-title {
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.03125rem;
}
.ac-sub {
color: var(--text-2);
font-size: 0.875rem;
margin-top: 0.4375rem;
}
.inline-link {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.field {
margin-bottom: 0.8125rem;
}
.field.first {
margin-top: 1.625rem;
}
.flabel {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.4375rem;
display: block;
}
.tinput {
width: 100%;
height: 2.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.8125rem;
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);
}
.pw-wrap {
position: relative;
}
.pw-wrap .tinput {
padding-right: 2.75rem;
}
.pw-toggle {
position: absolute;
right: 0.375rem;
top: 50%;
transform: translateY(-50%);
width: 2.125rem;
height: 2.125rem;
border: none;
background: transparent;
color: var(--text-3);
display: grid;
place-items: center;
cursor: pointer;
border-radius: 0.375rem;
}
.pw-toggle:hover {
background: #f1f2f4;
color: var(--text-2);
}
.pw-toggle svg {
width: 1.125rem;
height: 1.125rem;
}
.field-hint {
font-size: 0.75rem;
color: var(--text-3);
margin-top: 0.4375rem;
display: flex;
align-items: center;
gap: 0.3125rem;
}
.field-hint svg {
width: 0.8125rem;
height: 0.8125rem;
flex-shrink: 0;
}
.terms {
display: flex;
align-items: flex-start;
gap: 0.5625rem;
margin: 0.375rem 0 1.125rem;
font-size: 0.8125rem;
color: var(--text-2);
cursor: pointer;
user-select: none;
line-height: 1.5;
background: none;
border: none;
font-family: inherit;
text-align: left;
padding: 0;
}
.terms .box {
width: 1.125rem;
height: 1.125rem;
border-radius: 0.3125rem;
border: 1.5px solid var(--border-strong);
display: grid;
place-items: center;
background: #fff;
flex-shrink: 0;
margin-top: 1px;
}
.terms .box.on {
background: var(--accent);
border-color: var(--accent);
}
.terms .box svg {
width: 0.75rem;
height: 0.75rem;
color: #fff;
opacity: 0;
}
.terms .box.on svg {
opacity: 1;
}
.terms a {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.terms a:hover {
text-decoration: underline;
}
.submit {
width: 100%;
height: 2.875rem;
border-radius: var(--radius);
border: none;
background: var(--accent);
color: #fff;
font-family: inherit;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
display: flex;
align-items: center;
justify-content: center;
gap: 0.4375rem;
}
.submit:hover {
background: var(--accent-hover);
}
.submit svg {
width: 1.0625rem;
height: 1.0625rem;
}
.alt {
margin-top: 1.375rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-2);
}
.alt a {
color: var(--accent);
font-weight: 600;
text-decoration: none;
}
.alt a:hover {
text-decoration: underline;
}
/* 인증 메일 전송 상태 */
.verify {
text-align: center;
}
.verify .vicon {
width: 4.125rem;
height: 4.125rem;
border-radius: 50%;
background: var(--accent-weak);
color: var(--accent);
display: grid;
place-items: center;
margin: 0 auto 1.375rem;
}
.verify .vicon svg {
width: 1.875rem;
height: 1.875rem;
}
.verify h1 {
font-size: 1.4375rem;
font-weight: 700;
letter-spacing: -0.025rem;
}
.verify .vtext {
color: var(--text-2);
font-size: 0.875rem;
line-height: 1.7;
margin-top: 0.75rem;
}
.verify .vmail {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin-top: 1rem;
padding: 0.5625rem 0.875rem;
background: #f7f8fa;
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.875rem;
font-weight: 600;
color: var(--text);
}
.verify .vmail svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
.verify .vspam {
font-size: 0.781rem;
color: var(--text-3);
margin-top: 1.125rem;
line-height: 1.6;
}
.verify .vactions {
margin-top: 1.625rem;
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.btn-ghost {
height: 2.75rem;
border-radius: var(--radius);
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text);
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.4375rem;
}
.btn-ghost:hover {
background: #f7f8fa;
}
.btn-ghost svg {
width: 1rem;
height: 1rem;
}
.vchange {
background: none;
border: none;
font-family: inherit;
font-size: 0.8125rem;
color: var(--text-2);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.vchange:hover {
color: var(--text);
}
/* 좁은 화면 — 브랜드 패널 숨기고 카드 로고 노출 */
@media (max-width: 55rem) {
.brand-panel {
display: none;
}
.ac-logo {
display: flex;
align-items: center;
gap: 0.5625rem;
font-weight: 700;
font-size: 1.0625rem;
margin-bottom: 1.5rem;
}
.ac-logo .logo {
width: 1.75rem;
height: 1.75rem;
border-radius: 0.5rem;
background: linear-gradient(135deg, #5b54f0, #4338ca);
display: grid;
place-items: center;
color: #fff;
font-size: 0.9375rem;
font-weight: 800;
}
}
</style>
File diff suppressed because it is too large Load Diff
+987
View File
@@ -0,0 +1,987 @@
<script setup lang="ts">
// 새 업무 작성 — 제목/내용/체크리스트 + 사이드(담당자/마감/첨부/지시자)
import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import { findRepo, USERS, type Attachment, type ChecklistItem, type User } from '@/mock/relay.mock'
const route = useRoute()
const router = useRouter()
const repoId = computed(() => String(route.params.repoId))
const repo = computed(() => findRepo(repoId.value))
// 제목
const title = ref('3분기 신제품 런칭 캠페인 소재 제작')
// 담당자 (칩)
const assignees = ref<User[]>([USERS.kim, USERS.park, USERS.lee])
function removeAssignee(id: string) {
assignees.value = assignees.value.filter((a) => a.id !== id)
}
// 체크리스트
const checklist = ref<ChecklistItem[]>([
{ text: '캠페인 핵심 메시지 3개 시안 작성', done: true },
{ text: '레퍼런스 리서치 및 무드보드 정리', done: true },
{ text: '메인 배너 이미지 2종 (가로/세로) 디자인', done: false },
{ text: '상세페이지 카피라이팅 및 법무 검토', done: false },
{ text: '최종 산출물 공유 드라이브 업로드', done: false },
])
const newItem = ref('')
const doneCount = computed(() => checklist.value.filter((c) => c.done).length)
const progressPct = computed(() =>
checklist.value.length ? Math.round((doneCount.value / checklist.value.length) * 100) : 0,
)
function toggleItem(item: ChecklistItem) {
item.done = !item.done
}
function removeItem(index: number) {
checklist.value.splice(index, 1)
}
function addItem() {
const text = newItem.value.trim()
if (!text) return
checklist.value.push({ text, done: false })
newItem.value = ''
}
// 첨부파일
const files = ref<Attachment[]>([
{ name: '캠페인_브리프_v2.pdf', size: '2.4 MB', kind: 'pdf' },
{ name: '브랜드_가이드라인.pdf', size: '8.1 MB', kind: 'pdf' },
{ name: '레퍼런스_이미지.zip', size: '34.7 MB', kind: 'zip' },
])
function removeFile(index: number) {
files.value.splice(index, 1)
}
// 파일 종류 → 아이콘 클래스/라벨
const FILE_META: Record<Attachment['kind'], { cls: string; label: string }> = {
pdf: { cls: 'fi-pdf', label: 'PDF' },
zip: { cls: 'fi-zip', label: 'ZIP' },
doc: { cls: 'fi-doc', label: 'DOC' },
img: { cls: 'fi-doc', label: 'IMG' },
}
// 취소 / 지시 보내기 — 추후 API 연동, 현재는 저장소 상세로 이동
function goBack() {
router.push(`/repos/${repoId.value}`)
}
</script>
<template>
<AppShell>
<div class="page">
<div class="crumb">
<RouterLink
to="/repos"
class="lnk"
>
저장소
</RouterLink>
<span class="sep">/</span>
<RouterLink
:to="`/repos/${repoId}`"
class="lnk"
>
{{ repo?.name ?? '저장소' }}
</RouterLink>
<span class="sep">/</span>
업무
</div>
<div class="pagehead">
<h1> 업무 작성</h1>
<span class="draft-tag">임시저장됨 · 방금</span>
<div class="actions">
<button
class="btn ghost"
type="button"
@click="goBack"
>
취소
</button>
<button
class="btn"
type="button"
>
임시저장
</button>
<button
class="btn primary"
type="button"
@click="goBack"
>
<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="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
지시 보내기
</button>
</div>
</div>
<div class="layout">
<!-- 메인 -->
<section class="card main-card">
<input
v-model="title"
class="title-input"
placeholder="업무 제목을 입력하세요"
>
<div class="divider" />
<!-- 업무 내용 -->
<div class="field">
<div class="field-label">
<span class="req">*</span> 업무 내용
</div>
<div class="editor">
<div class="editor-toolbar">
<button
class="tb"
type="button"
style="font-weight: 800"
>
B
</button>
<button
class="tb"
type="button"
style="font-style: italic; font-family: Georgia, serif"
>
I
</button>
<button
class="tb"
type="button"
style="text-decoration: underline"
>
U
</button>
<span class="tb-sep" />
<button
class="tb"
type="button"
title="목록"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" /></svg>
</button>
<button
class="tb"
type="button"
title="번호 목록"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4l2-3H4" /></svg>
</button>
<span class="tb-sep" />
<button
class="tb"
type="button"
title="링크"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1" /><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1" /></svg>
</button>
<button
class="tb"
type="button"
title="멘션"
>
@
</button>
<button
class="tb"
type="button"
title="코드"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="m16 18 6-6-6-6M8 6l-6 6 6 6" /></svg>
</button>
</div>
<div class="editor-body">
<p>9 신제품(라인 클렌저) 정식 출시에 맞춰 사용할 캠페인 소재 일체를 제작합니다. 톤앤매너는 <span class="mention">@브랜드_가이드라인.pdf</span> 기준을 따라주세요.</p>
<p>퍼포먼스 광고용 배너와 자사몰 상세페이지를 우선순위로 진행하고, SNS 콘텐츠는 1 시안 확정 착수합니다. 카피는 "민감 피부 저자극" 메시지를 핵심으로 잡되, 과장 광고 표현은 법무 검토를 반드시 거쳐주세요.</p>
<p> 산출물은 아래 체크리스트 기준으로 관리하며, 진행 상황은 업무 상세에서 코멘트로 공유 바랍니다.</p>
</div>
</div>
</div>
<!-- 체크리스트 -->
<div class="field">
<div class="check-head">
<div
class="field-label"
style="margin-bottom: 0"
>
해야
</div>
<div class="progress-wrap">
<div class="progress-bar">
<i :style="{ width: progressPct + '%' }" />
</div>
<span class="progress-txt">{{ doneCount }} / {{ checklist.length }} 완료</span>
</div>
</div>
<div class="checklist">
<div
v-for="(item, index) in checklist"
:key="index"
class="check-item"
:class="{ done: item.done }"
>
<span
class="cbox"
:class="{ done: item.done }"
@click="toggleItem(item)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3.5"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</span>
<span class="txt">{{ item.text }}</span>
<span
class="row-del"
@click="removeItem(index)"
>
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
</div>
</div>
<div class="add-row">
<span class="plus">+</span>
<input
v-model="newItem"
class="add-input"
placeholder="항목 추가"
@keydown.enter="addItem"
>
</div>
</div>
</section>
<!-- 사이드바 -->
<aside class="side">
<div class="card side-card">
<div class="side-field">
<div class="side-label">
<span class="req">*</span> 담당자 <span class="muted">· {{ assignees.length }}</span>
</div>
<div class="combo">
<div class="combo-field">
<span
v-for="a in assignees"
:key="a.id"
class="chip"
>
<span
class="avatar"
:class="a.color"
>{{ a.initial }}</span>
{{ a.name }}
<span
class="x"
@click="removeAssignee(a.id)"
>×</span>
</span>
<input
class="combo-input"
placeholder="이름 검색…"
>
<span class="combo-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>
</div>
<div class="card side-card">
<div class="side-field">
<div class="side-label">
<span class="req">*</span> 마감기한
</div>
<div class="input">
<span class="ico">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><rect
x="3"
y="4"
width="18"
height="18"
rx="2"
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
</span>
2026 6 30 () 18:00
</div>
<div class="date-meta">
<span class="dot" /> 마감까지 15 남음
</div>
</div>
<div class="side-field">
<div class="side-label">
첨부파일 <span class="muted">· {{ files.length }}</span>
</div>
<div class="files">
<div
v-for="(file, index) in files"
:key="file.name"
class="file"
>
<span
class="file-ico"
:class="FILE_META[file.kind].cls"
>{{ FILE_META[file.kind].label }}</span>
<span class="file-info">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ file.size }}</span>
</span>
<span
class="x"
@click="removeFile(index)"
>
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</span>
</div>
</div>
<div class="dropzone">
파일을 끌어다 놓거나 <b>찾아보기</b>
</div>
</div>
</div>
<div class="card side-card">
<div class="side-field bare">
<div class="side-label">
지시자
</div>
<div class="issuer">
<span class="avatar av-f"></span>
<span>
<div class="nm">정태경</div>
<div class="rl">마케팅 그룹 리드</div>
</span>
</div>
<div class="side-foot">
지시를 보내면 담당자 {{ assignees.length }}명에게 알림이 전송되고, 업무가 각자의 업무 추가됩니다.
</div>
</div>
</div>
</aside>
</div>
</div>
</AppShell>
</template>
<style scoped>
.page {
max-width: 77.5rem;
}
.crumb {
padding: 1rem 0 0;
}
.pagehead {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0 1.125rem;
}
.pagehead h1 {
font-size: 1.3125rem;
font-weight: 700;
letter-spacing: -0.025rem;
white-space: nowrap;
flex-shrink: 0;
}
.draft-tag {
font-size: 0.719rem;
font-weight: 600;
color: var(--amber);
background: var(--amber-weak);
border: 1px solid var(--amber-border);
padding: 0.1875rem 0.5rem;
border-radius: 20px;
line-height: 1;
white-space: nowrap;
}
.pagehead .actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
}
.pagehead .btn {
height: 2.125rem;
}
/* 레이아웃 */
.layout {
display: grid;
grid-template-columns: 1fr 21.75rem;
gap: 1.25rem;
align-items: start;
}
.main-card {
padding: 0.5rem 1.625rem 1.625rem;
}
/* 제목 입력 */
.title-input {
width: 100%;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 1.5rem;
font-weight: 700;
letter-spacing: -0.03125rem;
color: var(--text);
padding: 1.25rem 0 0.875rem;
line-height: 1.3;
}
.title-input::placeholder {
color: #c4c8cf;
}
.divider {
height: 1px;
background: var(--border);
margin: 0 -1.625rem;
}
.field {
padding-top: 1.375rem;
}
.field-label {
display: flex;
align-items: center;
gap: 0.4375rem;
font-size: 0.781rem;
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.5625rem;
white-space: nowrap;
}
.field-label .req {
color: var(--accent);
font-weight: 700;
}
/* 에디터 */
.editor {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
overflow: hidden;
}
.editor:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.editor-toolbar {
display: flex;
align-items: center;
gap: 0.125rem;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--border);
background: #fafbfc;
}
.tb {
height: 1.75rem;
min-width: 1.75rem;
padding: 0 0.4375rem;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-2);
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
display: grid;
place-items: center;
font-family: inherit;
}
.tb:hover {
background: #eceef1;
color: var(--text);
}
.tb svg {
width: 1rem;
height: 1rem;
}
.tb-sep {
width: 1px;
height: 1.125rem;
background: var(--border-strong);
margin: 0 0.3125rem;
}
.editor-body {
padding: 0.875rem 1rem;
font-size: 0.875rem;
color: var(--text);
min-height: 9.375rem;
line-height: 1.65;
}
.editor-body p {
margin-bottom: 0.6875rem;
}
.editor-body p:last-child {
margin-bottom: 0;
}
.mention {
color: var(--accent);
font-weight: 600;
background: var(--accent-weak);
padding: 0 0.1875rem;
border-radius: 3px;
}
/* 체크리스트 */
.check-head {
display: flex;
align-items: center;
gap: 0.625rem;
margin-bottom: 0.625rem;
}
.progress-wrap {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5625rem;
}
.progress-bar {
width: 5.75rem;
height: 0.375rem;
border-radius: 4px;
background: #eceef1;
overflow: hidden;
}
.progress-bar > i {
display: block;
height: 100%;
background: var(--green);
border-radius: 4px;
}
.progress-txt {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-2);
white-space: nowrap;
}
.checklist {
display: flex;
flex-direction: column;
}
.check-item {
display: flex;
align-items: center;
gap: 0.6875rem;
padding: 0.5625rem 0;
border-radius: var(--radius-sm);
}
.check-item:hover {
background: #f7f8fa;
}
.cbox {
width: 1.125rem;
height: 1.125rem;
border-radius: 0.3125rem;
border: 1.5px solid var(--border-strong);
flex-shrink: 0;
display: grid;
place-items: center;
background: #fff;
cursor: pointer;
}
.cbox.done {
background: var(--green);
border-color: var(--green);
}
.cbox svg {
width: 0.75rem;
height: 0.75rem;
color: #fff;
opacity: 0;
}
.cbox.done svg {
opacity: 1;
}
.check-item .txt {
font-size: 0.844rem;
color: var(--text);
flex: 1;
}
.check-item.done .txt {
color: var(--text-3);
text-decoration: line-through;
}
.check-item .row-del {
opacity: 0;
color: var(--text-3);
cursor: pointer;
width: 1.5rem;
height: 1.5rem;
display: grid;
place-items: center;
border-radius: 4px;
}
.check-item:hover .row-del {
opacity: 1;
}
.check-item .row-del:hover {
background: #eceef1;
color: var(--red);
}
.add-row {
display: flex;
align-items: center;
gap: 0.6875rem;
padding: 0.625rem 0 0.25rem;
color: var(--text-3);
font-size: 0.844rem;
}
.add-row .plus {
width: 1.125rem;
height: 1.125rem;
border-radius: 0.3125rem;
border: 1.5px dashed var(--border-strong);
display: grid;
place-items: center;
font-size: 0.8125rem;
line-height: 1;
flex-shrink: 0;
}
.add-input {
flex: 1;
border: none;
outline: none;
background: transparent;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
}
.add-input::placeholder {
color: var(--text-3);
}
/* 사이드바 */
.side {
display: flex;
flex-direction: column;
gap: 1rem;
}
.side-card {
padding: 1rem 1.125rem 1.125rem;
}
.side-field + .side-field {
margin-top: 1.125rem;
padding-top: 1.125rem;
border-top: 1px solid var(--border);
}
.side-field.bare {
border: none;
padding: 0;
margin: 0;
}
.side-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-2);
margin-bottom: 0.5625rem;
display: flex;
align-items: center;
gap: 0.375rem;
white-space: nowrap;
}
.side-label .req {
color: var(--accent);
}
.side-label .muted {
color: var(--text-3);
font-weight: 500;
}
/* 담당자 콤보 */
.combo {
position: relative;
}
.combo-field {
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0.4375rem 0.5rem;
background: #fff;
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.combo-field:focus-within {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.combo-caret {
color: var(--text-3);
display: grid;
place-items: center;
margin-left: auto;
flex-shrink: 0;
}
.combo-caret svg {
width: 1rem;
height: 1rem;
}
.chip {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: #f1f2f4;
border: 1px solid var(--border);
border-radius: 20px;
padding: 0.1875rem 0.5rem 0.1875rem 0.1875rem;
font-size: 0.781rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.chip .avatar {
width: 1.375rem;
height: 1.375rem;
font-size: 0.656rem;
}
.chip .x {
color: var(--text-3);
cursor: pointer;
font-size: 0.8125rem;
line-height: 1;
margin-left: 0.0625rem;
}
.chip .x:hover {
color: var(--red);
}
.combo-input {
flex: 1;
min-width: 4.375rem;
border: none;
outline: none;
font-family: inherit;
font-size: 0.8125rem;
padding: 0.25rem 0.125rem;
background: transparent;
}
.combo-input::placeholder {
color: var(--text-3);
}
/* 마감기한 입력 */
.input {
width: 100%;
height: 2.375rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.6875rem;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
display: flex;
align-items: center;
gap: 0.5625rem;
}
.input .ico {
color: var(--text-3);
display: grid;
place-items: center;
}
.input .ico svg {
width: 1rem;
height: 1rem;
}
.date-meta {
font-size: 0.75rem;
color: var(--amber);
font-weight: 600;
margin-top: 0.4375rem;
display: flex;
align-items: center;
gap: 0.3125rem;
}
.date-meta .dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
background: var(--amber);
}
/* 첨부파일 */
.dropzone {
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius);
padding: 0.8125rem;
text-align: center;
color: var(--text-3);
font-size: 0.781rem;
cursor: pointer;
background: #fafbfc;
}
.dropzone:hover {
border-color: var(--accent-border);
background: var(--accent-weak);
color: var(--accent);
}
.dropzone b {
color: var(--accent);
font-weight: 600;
}
.files {
display: flex;
flex-direction: column;
gap: 0.4375rem;
margin-bottom: 0.625rem;
}
.file {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.5rem 0.5625rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
.file-ico {
width: 1.875rem;
height: 1.875rem;
border-radius: 0.375rem;
display: grid;
place-items: center;
flex-shrink: 0;
font-size: 0.625rem;
font-weight: 800;
color: #fff;
letter-spacing: -0.01875rem;
}
.fi-pdf {
background: #e25950;
}
.fi-zip {
background: #8a64d6;
}
.fi-doc {
background: #3d8bf2;
}
.file-info {
min-width: 0;
flex: 1;
}
.file-name {
font-size: 0.781rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
}
.file-size {
font-size: 0.719rem;
color: var(--text-3);
display: block;
}
.file .x {
color: var(--text-3);
cursor: pointer;
width: 1.375rem;
height: 1.375rem;
display: grid;
place-items: center;
border-radius: 4px;
flex-shrink: 0;
}
.file .x:hover {
background: #eceef1;
color: var(--red);
}
/* 지시자 */
.issuer {
display: flex;
align-items: center;
gap: 0.625rem;
}
.issuer .avatar {
width: 1.875rem;
height: 1.875rem;
font-size: 0.75rem;
}
.issuer .nm {
font-size: 0.8125rem;
font-weight: 600;
}
.issuer .rl {
font-size: 0.719rem;
color: var(--text-3);
white-space: nowrap;
}
.side-foot {
font-size: 0.719rem;
color: var(--text-3);
line-height: 1.6;
margin-top: 0.75rem;
}
</style>
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
// 코드 스플리팅 — 모든 페이지 컴포넌트는 lazy import 처리
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/repos',
},
{
path: '/login',
name: 'login',
component: () => import('@/pages/relay/LoginPage.vue'),
meta: { requiresAuth: false },
},
{
path: '/signup',
name: 'signup',
component: () => import('@/pages/relay/SignupPage.vue'),
meta: { requiresAuth: false },
},
{
path: '/repos',
name: 'repo-list',
component: () => import('@/pages/relay/RepoListPage.vue'),
meta: { requiresAuth: true },
},
{
// 정적 경로 '/repos/new' 는 동적 '/repos/:repoId' 보다 먼저 선언해 우선 매칭되게 한다
path: '/repos/new',
name: 'repo-create',
component: () => import('@/pages/relay/RepoCreatePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/repos/:repoId',
name: 'repo-detail',
component: () => import('@/pages/relay/RepoDetailPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/repos/:repoId/members',
name: 'repo-members',
component: () => import('@/pages/relay/RepoMembersPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/repos/:repoId/activity',
name: 'repo-activity',
component: () => import('@/pages/relay/RepoActivityPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/repos/:repoId/settings',
name: 'repo-settings',
component: () => import('@/pages/relay/RepoSettingsPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/repos/:repoId/tasks/new',
name: 'task-create',
component: () => import('@/pages/relay/TaskCreatePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/repos/:repoId/tasks/:taskId',
name: 'task-detail',
component: () => import('@/pages/relay/TaskDetailPage.vue'),
meta: { requiresAuth: true },
},
{
// 담당자(작업자) 관점의 업무 상세 — ?state=request|changes|approved
path: '/repos/:repoId/tasks/:taskId/view',
name: 'task-assignee-view',
component: () => import('@/pages/relay/TaskAssigneePage.vue'),
meta: { requiresAuth: true },
},
{
// '내 업무' — 다음 단계에서 구현 예정(현재는 임시 안내)
path: '/tasks',
name: 'my-tasks',
component: () => import('@/pages/relay/MyTasksPage.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
component: () => import('@/pages/NotFoundPage.vue'),
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
// 네비게이션 가드 — 세션 부트스트랩 + 인증 분기
router.beforeEach(async (to) => {
const authStore = useAuthStore()
// 앱 최초 진입 시 1회 세션 복원(/auth/me). 이후는 캐시된 상태 사용
if (!authStore.bootstrapped) {
await authStore.bootstrap()
}
// 보호 라우트인데 미로그인 → 로그인 페이지로 (원래 목적지를 redirect 쿼리로 보존)
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
return { name: 'login', query: { redirect: to.fullPath } }
}
// 이미 로그인 상태에서 로그인/회원가입 진입 → 저장소 목록으로
if (!to.meta.requiresAuth && authStore.isLoggedIn && (to.name === 'login' || to.name === 'signup')) {
return { path: '/repos' }
}
return true
})
export default router
+77
View File
@@ -0,0 +1,77 @@
// 공통 포맷터 — 부수효과 없는 순수 함수만 작성
/**
* 숫자를 한국 통화 표기 (예: 12345 → "12,345원")
*/
export function formatCurrency(value: number): string {
return `${value.toLocaleString('ko-KR')}`
}
/**
* Date 또는 ISO 문자열을 'YYYY-MM-DD' 형식으로 변환
*/
export function formatDate(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(date.getTime())) return ''
const yyyy = date.getFullYear()
const mm = String(date.getMonth() + 1).padStart(2, '0')
const dd = String(date.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
}
/* ============================================================
* 표시용 파생 값 — 백엔드 원시 데이터를 화면 라벨/색상으로 변환
* (서버는 타임스탬프/카운트만 내려주고 여기서 계산한다)
* ============================================================ */
/** 아바타 색상 키 (relay.css 의 .av-* 클래스와 대응) */
export type AvatarColorKey = 'av-a' | 'av-b' | 'av-c' | 'av-d' | 'av-e' | 'av-f'
const AVATAR_COLORS: AvatarColorKey[] = ['av-a', 'av-b', 'av-c', 'av-d', 'av-e', 'av-f']
/** 식별자(문자열)를 결정적으로 아바타 색상에 매핑 */
export function avatarColor(seed: string): AvatarColorKey {
let hash = 0
for (let i = 0; i < seed.length; i++) {
hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
}
return AVATAR_COLORS[hash % AVATAR_COLORS.length] ?? 'av-a'
}
/** 이름의 첫 글자(아바타 이니셜) */
export function initialOf(name: string): string {
return name.trim().charAt(0) || '?'
}
/** ISO 문자열/Date 를 한국어 상대시간으로 (예: "2시간 전", "어제", "3일 전") */
export function relativeTimeKo(input: Date | string): string {
const date = typeof input === 'string' ? new Date(input) : input
if (Number.isNaN(date.getTime())) return ''
const diffMs = Date.now() - date.getTime()
const min = Math.floor(diffMs / 60_000)
if (min < 1) return '방금'
if (min < 60) return `${min}분 전`
const hour = Math.floor(min / 60)
if (hour < 24) return `${hour}시간 전`
const day = Math.floor(hour / 24)
if (day === 1) return '어제'
if (day < 7) return `${day}일 전`
const week = Math.floor(day / 7)
if (week < 5) return `${week}주 전`
return formatDate(date)
}
/** 진행률 정보 (완료/전체 → 퍼센트·단계·라벨) */
export interface ProgressInfo {
pct: number
level: 'done' | 'mid' | 'low'
label: string
}
/** 완료/전체 수로 진행률 계산 */
export function progressOf(done: number, total: number): ProgressInfo {
const pct = total > 0 ? Math.round((done / total) * 100) : 0
const level: ProgressInfo['level'] = pct >= 90 ? 'done' : pct >= 40 ? 'mid' : 'low'
const label = pct === 0 ? '시작 전' : `${pct}% 완료`
return { pct, level, label }
}
+48
View File
@@ -0,0 +1,48 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useAuth } from '@/composables/useAuth'
import type { AuthUser, LoginPayload, SignupPayload } from '@/types/auth'
// 인증 상태 스토어 — 현재 사용자/로그인 여부 + 인증 액션
export const useAuthStore = defineStore('auth', () => {
const user = ref<AuthUser | null>(null)
// 앱 시작 시 1회 부트스트랩(/auth/me) 완료 여부 — 가드에서 중복 호출 방지
const bootstrapped = ref<boolean>(false)
const isLoggedIn = computed<boolean>(() => user.value !== null)
const { signup: signupApi, login: loginApi, logout: logoutApi, fetchMe } = useAuth()
// 로그인
async function login(payload: LoginPayload): Promise<void> {
user.value = await loginApi(payload)
}
// 회원가입 (성공 시 자동 로그인 상태)
async function signup(payload: SignupPayload): Promise<void> {
user.value = await signupApi(payload)
}
// 로그아웃
async function logout(): Promise<void> {
try {
await logoutApi()
} finally {
user.value = null
}
}
// 앱 시작 시 세션 복원 — 쿠키 기반으로 현재 사용자 조회. 실패 시 비로그인 상태
async function bootstrap(): Promise<void> {
if (bootstrapped.value) return
try {
user.value = await fetchMe()
} catch {
user.value = null
} finally {
bootstrapped.value = true
}
}
return { user, isLoggedIn, bootstrapped, login, signup, logout, bootstrap }
})
+95
View File
@@ -0,0 +1,95 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { useRepo } from '@/composables/useRepo'
import { avatarColor, initialOf, 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 ?? '',
initial: initialOf(member.name),
color: avatarColor(member.id),
}
}
// 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,
}
}
// 저장소 스토어 — 목록/현재 저장소 상태 + CRUD 액션
export const useRepoStore = defineStore('repo', () => {
const repos = ref<RepoView[]>([])
const current = ref<RepoView | null>(null)
const loading = ref(false)
const repoApi = useRepo()
// 목록 조회
async function fetchList(): Promise<void> {
loading.value = true
try {
const list = await repoApi.list()
repos.value = list.map(toRepoView)
} finally {
loading.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)
if (current.value?.id === id) current.value = null
}
return { repos, current, loading, fetchList, fetchOne, create, update, remove }
})
+22
View File
@@ -0,0 +1,22 @@
// 인증 사용자 — 백엔드 PublicUser(/auth/me, /auth/login 응답)와 1:1 대응
export interface AuthUser {
id: string
email: string
name: string
role: string | null
}
// 로그인 요청 페이로드
export interface LoginPayload {
email: string
password: string
keepLogin?: boolean
}
// 회원가입 요청 페이로드
export interface SignupPayload {
email: string
password: string
name: string
role?: string
}
+42
View File
@@ -0,0 +1,42 @@
import type { Visibility } from '@/mock/relay.mock'
// 저장소 멤버(API 원시) — PublicUser 와 동일
export interface ApiMember {
id: string
email: string
name: string
role: string | null
}
// 저장소(API 원시) — 백엔드 RepoResponse 와 1:1
export interface ApiRepo {
id: string // slugName (라우팅 식별자)
name: string
owner: string
slug: string // owner/slugName
desc: string | null
visibility: Visibility
branch: string
members: ApiMember[]
memberCount: number
doneCount: number
totalCount: number
updatedAt: string
}
// 저장소 생성 요청
export interface CreateRepoPayload {
slug: string // 영문 저장소 이름
name: string // 표시 제목(한글)
visibility: Visibility
description?: string
branch?: string
}
// 저장소 수정 요청
export interface UpdateRepoPayload {
name?: string
description?: string
visibility?: Visibility
branch?: string
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}
+27
View File
@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}
+19
View File
@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// Application code imported in tests is automatically included via module resolution.
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": [],
"types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}
+28
View File
@@ -0,0 +1,28 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueJsx(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server: {
// ngrok 등 외부 도메인 접근 허용 (DNS Rebinding 보호 비활성화)
allowedHosts: true,
// 도커/윈도우 환경 파일 핫 리로딩 강제 활성화
watch: {
usePolling: true,
}
}
})
+14
View File
@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)