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
+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