Compare commits

..

4 Commits

Author SHA1 Message Date
ttipo 433901312d feat: Relay 브랜드 로고·favicon 전면 적용
사이드바·인증 화면 'R' 텍스트 로고를 브랜드 더블 셰브론 마크(RelayMark)로 교체.
favicon을 SVG+PNG+ICO 세트와 웹 매니페스트로 정비(create-vue 기본 아이콘 대체), 가운데 셰브론을 키워 작은 크기 가독성 개선.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:06:31 +09:00
ttipo 6767316cdb feat: 정렬·필터 서버 측 동작 구현 및 커스텀 드롭다운 적용
프로젝트 목록·업무 목록·내 업무에 정렬/검색/프로젝트 필터를 백엔드 쿼리 파라미터로 추가(페이지네이션과 일관). 활동 멤버 필터는 전량 로드 피드 기준 클라이언트 처리.
모든 정렬/필터 및 멤버 초대 역할 선택을 네이티브 select → 공통 DropdownSelect 컴포넌트로 통일.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:06:17 +09:00
ttipo 71040f94c8 style: 전 화면 컨텐츠 최대 너비 통일
업무 작성 화면의 .page max-width(77.5rem) override 제거 → 전역 기본값 67.5rem 상속.
프로젝트 목록·내 업무·상세 등 다른 화면과 동일한 너비로 정렬.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:06:01 +09:00
ttipo 1e6acff4f1 feat: 업무 상세 화면을 첨부파일 보기 전용으로 변경
상세 화면에서 첨부 업로드·삭제 UI/로직을 제거하고 목록 표시만 유지.
업로드는 작성·편집 화면에서만 가능하도록 일원화(미사용 스토어 액션·CSS 정리).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 12:05:53 +09:00
49 changed files with 763 additions and 439 deletions
@@ -36,8 +36,10 @@ export class ProjectController {
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
@Query('sort') sort?: string,
@Query('q') q?: string,
): Promise<PaginatedResult<ProjectResponse>> {
return this.projectService.findAll(page, size);
return this.projectService.findAll(page, size, { sort, q });
}
@Post()
+13 -3
View File
@@ -4,7 +4,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ILike, Repository, type FindOptionsOrder } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import {
paginated,
@@ -45,15 +45,25 @@ export class ProjectService {
private readonly userService: UserService,
) {}
// 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 페이지네이션.
// 프로젝트 목록 — 단일 조직 모델상 인증 사용자면 전체 열람. 검색/정렬 + 페이지네이션.
async findAll(
page?: number,
size?: number,
opts: { sort?: string; q?: string } = {},
): Promise<PaginatedResult<ProjectResponse>> {
const pg = resolvePage(page, size);
const q = opts.q?.trim();
// 정렬: 이름순 / 최근 생성순 / 최근 업데이트순(기본)
const order: FindOptionsOrder<Project> =
opts.sort === 'name'
? { name: 'ASC' }
: opts.sort === 'created'
? { createdAt: 'DESC' }
: { updatedAt: 'DESC' };
const [projects, total] = await this.projectRepo.findAndCount({
relations: ['members', 'members.user'],
order: { updatedAt: 'DESC' },
where: q ? { name: ILike(`%${q}%`) } : {},
order,
skip: pg.skip,
take: pg.take,
});
+24 -4
View File
@@ -21,24 +21,44 @@ export class MyTaskController {
constructor(private readonly taskService: TaskService) {}
@Get('assigned')
@ApiOperation({ summary: '내가 담당인 업무 (프로젝트 횡단, 페이지네이션)' })
@ApiOperation({
summary:
'내가 담당인 업무 (프로젝트 횡단, 검색/프로젝트/정렬 + 페이지네이션)',
})
@ApiResponse({ status: 200, description: '조회 성공' })
listAssigned(
@CurrentUser() user: PublicUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
@Query('q') q?: string,
@Query('projectId') projectId?: string,
@Query('sort') sort?: string,
): Promise<PaginatedResult<MyTaskRowResponse>> {
return this.taskService.listAssigned(user.id, page, size);
return this.taskService.listAssigned(user.id, page, size, {
q,
projectId,
sort,
});
}
@Get('issued')
@ApiOperation({ summary: '내가 지시한 업무 (프로젝트 횡단, 페이지네이션)' })
@ApiOperation({
summary:
'내가 지시한 업무 (프로젝트 횡단, 검색/프로젝트/정렬 + 페이지네이션)',
})
@ApiResponse({ status: 200, description: '조회 성공' })
listIssued(
@CurrentUser() user: PublicUser,
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('size', new DefaultValuePipe(20), ParseIntPipe) size: number,
@Query('q') q?: string,
@Query('projectId') projectId?: string,
@Query('sort') sort?: string,
): Promise<PaginatedResult<MyTaskRowResponse>> {
return this.taskService.listIssued(user.id, page, size);
return this.taskService.listIssued(user.id, page, size, {
q,
projectId,
sort,
});
}
}
+2 -1
View File
@@ -72,10 +72,11 @@ export class TaskController {
@Query('segment') segment?: string,
@Query('q') q?: string,
@Query('assignee') assignee?: string,
@Query('sort') sort?: string,
): Promise<ProjectTaskListResult> {
return this.taskService.list(
projectId,
{ segment, q, assignee },
{ segment, q, assignee, sort },
page,
size,
);
+55 -15
View File
@@ -5,7 +5,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { In, IsNull, Repository, type SelectQueryBuilder } from 'typeorm';
import { UserService, type PublicUser } from '../user/user.service';
import { BusinessException } from '../../common/exceptions/business.exception';
import { Project } from '../project/entities/project.entity';
@@ -198,7 +198,7 @@ export class TaskService {
// 업무 목록 — 세그먼트(all/prog/todo/done)·검색·담당자 필터 + 오프셋 페이지네이션 + 세그먼트 카운트
async list(
projectId: string,
filters: { segment?: string; q?: string; assignee?: string },
filters: { segment?: string; q?: string; assignee?: string; sort?: string },
page?: number,
size?: number,
): Promise<ProjectTaskListResult> {
@@ -239,12 +239,24 @@ export class TaskService {
);
}
// 정렬은 메인 엔티티(task.seq) 기준만 사용한다.
// 정렬은 메인 엔티티(task.seq/task.dueDate) 컬럼만 사용한다.
// skip/take(페이지네이션)와 to-many 조인을 함께 쓰면 TypeORM 이 DISTINCT id 서브쿼리로
// 페이징하는데, 이때 조인된 to-many 컬럼(checklist.sort_order)으로 ORDER BY 하면
// 컬럼 메타데이터를 찾지 못해 터진다(createOrderByCombinedWithSelectExpression).
// 목록 응답은 체크리스트의 [완료/전체] 수만 쓰므로 항목 순서가 필요 없다.
qb.orderBy('task.seq', 'DESC');
// 따라서 마감/순번 등 메인 엔티티 컬럼으로만 정렬한다.
const sort = filters.sort ?? 'latest';
if (sort === 'due') {
// 마감 임박순(마감 없음은 뒤로) → 동률은 최신 순번
qb.orderBy('task.dueDate', 'ASC', 'NULLS LAST').addOrderBy(
'task.seq',
'DESC',
);
} else if (sort === 'oldest') {
qb.orderBy('task.seq', 'ASC');
} else {
// 최신순(기본)
qb.orderBy('task.seq', 'DESC');
}
const [tasks, total] = await qb
.skip(pg.skip)
@@ -930,14 +942,41 @@ export class TaskService {
// --- 7단계: 내 업무(프로젝트 횡단 집계) ---
// 내가 담당인 업무 — 마감 임박순(마감 없음은 뒤로), 오프셋 페이지네이션
// 내 업무 공통 필터/정렬 적용 — 검색(q)·프로젝트(projectId)·정렬(sort)
// 정렬은 메인 엔티티 컬럼만 사용(to-many 조인 + skip/take 제약).
private applyMyTaskFilters(
qb: SelectQueryBuilder<Task>,
filters: { q?: string; projectId?: string; sort?: string },
): void {
const q = filters.q?.trim();
if (q) {
qb.andWhere('task.title ILIKE :q', { q: `%${q}%` });
}
if (filters.projectId) {
qb.andWhere('project.id = :pid', { pid: filters.projectId });
}
const sort = filters.sort ?? 'due';
if (sort === 'recent') {
// 최근 등록순(프로젝트 횡단이라 seq 대신 생성시각 기준)
qb.orderBy('task.createdAt', 'DESC');
} else {
// 마감 임박순(기본, 마감 없음은 뒤로)
qb.orderBy('task.dueDate', 'ASC', 'NULLS LAST').addOrderBy(
'task.seq',
'DESC',
);
}
}
// 내가 담당인 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
async listAssigned(
userId: string,
page?: number,
size?: number,
filters: { q?: string; projectId?: string; sort?: string } = {},
): Promise<PaginatedResult<MyTaskRowResponse>> {
const pg = resolvePage(page, size);
const [tasks, total] = await this.taskRepo
const qb = this.taskRepo
.createQueryBuilder('task')
.leftJoinAndSelect('task.project', 'project')
.leftJoinAndSelect('task.assignees', 'assignee')
@@ -953,9 +992,9 @@ export class TaskService {
.where('ta.user_id = :userId')
.getQuery(),
)
.setParameter('userId', userId)
.orderBy('task.dueDate', 'ASC', 'NULLS LAST')
.addOrderBy('task.seq', 'DESC')
.setParameter('userId', userId);
this.applyMyTaskFilters(qb, filters);
const [tasks, total] = await qb
.skip(pg.skip)
.take(pg.take)
.getManyAndCount();
@@ -966,22 +1005,23 @@ export class TaskService {
);
}
// 내가 지시(생성)한 업무 — 마감 임박순, 오프셋 페이지네이션
// 내가 지시(생성)한 업무 — 검색/프로젝트/정렬 필터 + 오프셋 페이지네이션
async listIssued(
userId: string,
page?: number,
size?: number,
filters: { q?: string; projectId?: string; sort?: string } = {},
): Promise<PaginatedResult<MyTaskRowResponse>> {
const pg = resolvePage(page, size);
const [tasks, total] = await this.taskRepo
const qb = this.taskRepo
.createQueryBuilder('task')
.leftJoinAndSelect('task.project', 'project')
.leftJoinAndSelect('task.assignees', 'assignee')
.leftJoinAndSelect('task.issuer', 'issuer')
.leftJoinAndSelect('task.checklist', 'checklist')
.where('task.issuer = :userId', { userId })
.orderBy('task.dueDate', 'ASC', 'NULLS LAST')
.addOrderBy('task.seq', 'DESC')
.where('task.issuer = :userId', { userId });
this.applyMyTaskFilters(qb, filters);
const [tasks, total] = await qb
.skip(pg.skip)
.take(pg.take)
.getManyAndCount();
+6 -1
View File
@@ -2,7 +2,12 @@
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<!-- 파비콘/앱 아이콘 (Relay 브랜드) — .ico(레거시) + svg(모던) 조합 -->
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon-180.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#4f46e5" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Relay — 사내 업무 지시 플랫폼" />
<!-- Pretendard 웹폰트 (디자인 시안 기준 서체) -->
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Relay app icon">
<defs>
<linearGradient id="relayGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5b54f0"/>
<stop offset="1" stop-color="#4338ca"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="120" fill="url(#relayGrad)"/>
<g fill="none" stroke="#ffffff" stroke-width="52" stroke-linecap="round" stroke-linejoin="round">
<path d="M128 130 L256 256 L128 382"/>
<path d="M256 130 L384 256 L256 382"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 591 B

+28
View File
@@ -0,0 +1,28 @@
{
"name": "Relay — 사내 업무 지시 플랫폼",
"short_name": "Relay",
"description": "프로젝트 단위로 업무를 정리하고 담당자에게 지시하는 사내 업무 플랫폼",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4f46e5",
"icons": [
{
"src": "/favicon.svg",
"type": "image/svg+xml",
"sizes": "any"
},
{
"src": "/relay-icon-256.png",
"type": "image/png",
"sizes": "256x256",
"purpose": "any maskable"
},
{
"src": "/relay-icon-512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 750 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 46 KiB

@@ -1,13 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="Relay app icon">
<defs>
<linearGradient id="relayGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#5b54f0"></stop>
<stop offset="1" stop-color="#4338ca"></stop>
<stop offset="0" stop-color="#5b54f0"/>
<stop offset="1" stop-color="#4338ca"/>
</linearGradient>
</defs>
<rect width="512" height="512" rx="143" fill="url(#relayGrad)"></rect>
<svg x="160" y="160" width="192" height="192" viewBox="0 0 32 32">
<path d="M8 8 L16 16 L8 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
<path d="M16 8 L24 16 L16 24" fill="none" stroke="#ffffff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</svg>
<rect width="512" height="512" rx="120" fill="url(#relayGrad)"/>
<g fill="none" stroke="#ffffff" stroke-width="52" stroke-linecap="round" stroke-linejoin="round">
<path d="M128 130 L256 256 L128 382"/>
<path d="M256 130 L384 256 L256 382"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 765 B

After

Width:  |  Height:  |  Size: 591 B

+1 -21
View File
@@ -402,27 +402,7 @@ body {
color: var(--accent);
}
.sort {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.375rem;
height: 2.25rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.6875rem;
background: #fff;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
}
.sort svg {
width: 0.9375rem;
height: 0.9375rem;
color: var(--text-3);
}
/* 정렬/필터 드롭다운은 공통 컴포넌트 DropdownSelect 로 대체(자체 스타일 보유). */
/* ============================================================
* 9. 카드 / 목록 컨테이너
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 사용처: 프로젝트 목록·업무 목록 정렬, 내 업무 프로젝트/정렬, 활동 멤버 필터.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
interface Option {
value: T
label: string
}
const props = withDefaults(
defineProps<{
modelValue: T
options: Option[]
// 메뉴 정렬 기준(트리거 좌/우 끝)
align?: 'left' | 'right'
// 툴바에서 오른쪽으로 밀어 붙일지(기존 margin-left:auto 대체)
pushRight?: boolean
// 폼 필드처럼 전체 너비로 펼칠지(라벨이 늘어나고 chevron 이 오른쪽 끝에 붙음)
block?: boolean
ariaLabel?: string
}>(),
{ align: 'left', pushRight: false, block: false, ariaLabel: undefined },
)
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
const open = ref(false)
const root = ref<HTMLElement | null>(null)
// 현재 선택된 옵션 라벨(트리거에 표시)
const selectedLabel = computed(
() => props.options.find((o) => o.value === props.modelValue)?.label ?? '',
)
function toggle(): void {
open.value = !open.value
}
function select(value: T): void {
emit('update:modelValue', value)
open.value = false
}
// 바깥 클릭 / Esc 로 닫기
function onDocClick(e: MouseEvent): void {
if (open.value && root.value && !root.value.contains(e.target as Node)) {
open.value = false
}
}
function onKeydown(e: KeyboardEvent): void {
if (open.value && e.key === 'Escape') open.value = false
}
onMounted(() => {
document.addEventListener('click', onDocClick)
document.addEventListener('keydown', onKeydown)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
document.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div
ref="root"
class="dd"
:class="{ push: pushRight, block }"
>
<button
type="button"
class="dd-trigger"
:class="{ open }"
:aria-label="ariaLabel"
aria-haspopup="listbox"
:aria-expanded="open"
@click="toggle"
>
<!-- 선행 아이콘(사용처별 SVG) -->
<span
v-if="$slots.icon"
class="dd-ico"
><slot name="icon" /></span>
<span class="dd-label">{{ selectedLabel }}</span>
<svg
class="dd-chev"
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>
</button>
<ul
v-if="open"
class="dd-menu"
:class="align === 'right' ? 'right' : 'left'"
role="listbox"
>
<li
v-for="opt in options"
:key="opt.value"
class="dd-opt"
:class="{ active: opt.value === modelValue }"
role="option"
:aria-selected="opt.value === modelValue"
@click="select(opt.value)"
>
<span class="dd-opt-label">{{ opt.label }}</span>
<svg
v-if="opt.value === modelValue"
class="dd-check"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M20 6 9 17l-5-5" /></svg>
</li>
</ul>
</div>
</template>
<style scoped>
.dd {
position: relative;
display: inline-flex;
}
.dd.push {
margin-left: auto;
}
/* 폼 필드용 전체 너비 변형 */
.dd.block {
display: flex;
width: 100%;
}
.dd.block .dd-trigger {
width: 100%;
max-width: none;
}
.dd.block .dd-label {
flex: 1;
}
/* 트리거 — 기존 정렬/필터 pill 과 동일한 외형 */
.dd-trigger {
display: inline-flex;
align-items: center;
gap: 0.4375rem;
height: 2.25rem;
max-width: 14rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
padding: 0 0.625rem;
background: #fff;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
}
.dd-trigger:hover {
background: #f9fafb;
}
.dd-trigger.open {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.dd-ico {
display: inline-flex;
flex-shrink: 0;
color: var(--text-3);
}
.dd-ico :deep(svg) {
width: 0.9375rem;
height: 0.9375rem;
}
.dd-label {
overflow: hidden;
text-overflow: ellipsis;
}
.dd-chev {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
color: var(--text-3);
transition: transform 0.15s ease;
}
.dd-trigger.open .dd-chev {
transform: rotate(180deg);
}
/* 메뉴 */
.dd-menu {
position: absolute;
top: calc(100% + 0.375rem);
z-index: 60;
min-width: 100%;
margin: 0;
padding: 0.25rem;
list-style: none;
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(20, 24, 33, 0.14);
}
.dd-menu.left {
left: 0;
}
.dd-menu.right {
right: 0;
}
.dd-opt {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.4375rem 0.625rem;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
color: var(--text);
cursor: pointer;
white-space: nowrap;
}
.dd-opt:hover {
background: #f1f2f4;
}
.dd-opt.active {
color: var(--accent);
font-weight: 600;
background: var(--accent-weak);
}
.dd-opt-label {
flex: 1;
}
.dd-check {
width: 0.875rem;
height: 0.875rem;
flex-shrink: 0;
}
</style>
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
// Relay 브랜드 마크(더블 셰브론) — 색상 로고 타일 안에 들어가는 심볼.
// 색상/그라데이션 배경 타일에는 white, 흰/투명 배경에는 기본(인디고)을 사용한다.
import markWhite from '@/assets/images/logo/svg/relay-mark-white.svg'
import mark from '@/assets/images/logo/svg/relay-mark.svg'
withDefaults(defineProps<{ white?: boolean }>(), { white: false })
</script>
<template>
<!-- 부모 타일이 어떤 정렬을 쓰든 중앙에 오도록 자체 중앙 정렬 -->
<span class="relay-mark">
<img
:src="white ? markWhite : mark"
alt=""
aria-hidden="true"
draggable="false"
>
</span>
</template>
<style scoped>
.relay-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.relay-mark img {
width: 66%;
height: 66%;
object-fit: contain;
}
</style>
+14 -6
View File
@@ -1,22 +1,30 @@
import { useApi } from './useApi'
import type { ApiMyTaskRow } from '@/types/my-task'
import type { ApiMyTaskRow, MyTaskListQuery } from '@/types/my-task'
import type { Paginated } from '@/types/pagination'
// 내 업무 API Composable — 프로젝트 횡단 집계(담당/지시), 오프셋 페이지네이션
// 내 업무 API Composable — 프로젝트 횡단 집계(담당/지시), 검색/프로젝트/정렬 + 오프셋 페이지네이션
export function useMyTask() {
const api = useApi()
// 내가 담당인 업무
async function assigned(page = 1, size = 20): Promise<Paginated<ApiMyTaskRow>> {
async function assigned(
page = 1,
size = 20,
query: MyTaskListQuery = {},
): Promise<Paginated<ApiMyTaskRow>> {
return (await api.get('/tasks/assigned', {
params: { page, size },
params: { ...query, page, size },
})) as unknown as Paginated<ApiMyTaskRow>
}
// 내가 지시한 업무
async function issued(page = 1, size = 20): Promise<Paginated<ApiMyTaskRow>> {
async function issued(
page = 1,
size = 20,
query: MyTaskListQuery = {},
): Promise<Paginated<ApiMyTaskRow>> {
return (await api.get('/tasks/issued', {
params: { page, size },
params: { ...query, page, size },
})) as unknown as Paginated<ApiMyTaskRow>
}
+8 -2
View File
@@ -2,6 +2,7 @@ import { useApi } from './useApi'
import type {
ApiProject,
CreateProjectPayload,
ProjectListQuery,
UpdateProjectPayload,
} from '@/types/project'
import type { Paginated } from '@/types/pagination'
@@ -10,9 +11,14 @@ import type { Paginated } from '@/types/pagination'
export function useProject() {
const api = useApi()
async function list(page = 1, size = 20): Promise<Paginated<ApiProject>> {
// 목록(검색/정렬 + 페이지네이션)
async function list(
page = 1,
size = 20,
query: ProjectListQuery = {},
): Promise<Paginated<ApiProject>> {
return (await api.get('/projects', {
params: { page, size },
params: { ...query, page, size },
})) as unknown as Paginated<ApiProject>
}
+2 -1
View File
@@ -7,6 +7,7 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useAuthStore } from '@/stores/auth.store'
import { useNotificationStore } from '@/stores/notification.store'
import UserAvatar from '@/components/UserAvatar.vue'
import RelayMark from '@/components/RelayMark.vue'
import AgentChatPanel from '@/components/AgentChatPanel.vue'
import type { NotificationView } from '@/types/notification'
@@ -96,7 +97,7 @@ async function onLogout() {
title="Relay"
>
<div class="logo">
R
<RelayMark white />
</div>
<span class="brand-name">Relay</span>
</RouterLink>
+4
View File
@@ -231,6 +231,10 @@ export interface ProjectActivityItem {
icon: ActivityIcon
/** 행위자 프로필 이미지 URL — 없으면 placeholder */
avatarUrl?: string | null
/** 행위자 식별자(멤버 필터용) — 행위자 없는 활동이면 null */
actorId?: string | null
/** 행위자 표시 이름(멤버 필터 드롭다운 라벨용) */
actorName?: string | null
/** 본문 HTML(내부 생성, 신뢰 가능 마크업) */
html: string
time: string
@@ -3,6 +3,7 @@
// 브라우저가 스크립트의 창 닫기를 차단할 수 있으므로(사용자가 직접 연 탭),
// 안내 문구를 함께 보여 사용자가 직접 닫고 기존 화면에서 로그인하도록 한다.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import RelayMark from '@/components/RelayMark.vue'
import { useRoute, RouterLink } from 'vue-router'
const route = useRoute()
@@ -30,7 +31,7 @@ onBeforeUnmount(() => {
<div class="verified-wrap">
<div class="card">
<div class="brand">
<span class="logo">R</span> Relay
<span class="logo"><RelayMark white /></span> Relay
</div>
<!-- 인증 성공 -->
@@ -3,6 +3,7 @@
// 이메일 입력(1단계) → 전송 완료 안내(2단계). 존재 여부는 노출하지 않는다.
import { onBeforeUnmount, ref } from 'vue'
import { RouterLink } from 'vue-router'
import RelayMark from '@/components/RelayMark.vue'
import { useAuth } from '@/composables/useAuth'
const { forgotPassword } = useAuth()
@@ -66,7 +67,7 @@ onBeforeUnmount(() => {
<div class="blob b2" />
<div class="ring" />
<div class="bp-logo">
<span class="logo">R</span> Relay
<span class="logo"><RelayMark white /></span> Relay
</div>
<div class="bp-body">
<div class="bp-head">
@@ -106,7 +107,7 @@ onBeforeUnmount(() => {
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo">
<span class="logo">R</span> Relay
<span class="logo"><RelayMark white /></span> Relay
</div>
<!-- 1단계: 이메일 입력 -->
+3 -2
View File
@@ -2,6 +2,7 @@
// 로그인 — 좌측 브랜드 패널 + 우측 로그인 폼
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import RelayMark from '@/components/RelayMark.vue'
import { useAuthStore } from '@/stores/auth.store'
const route = useRoute()
@@ -69,7 +70,7 @@ const points = [
<div class="blob b2" />
<div class="ring" />
<div class="bp-logo">
<span class="logo">R</span> Relay
<span class="logo"><RelayMark white /></span> Relay
</div>
<div class="bp-body">
<div class="bp-head">
@@ -110,7 +111,7 @@ const points = [
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo">
<span class="logo">R</span> Relay
<span class="logo"><RelayMark white /></span> Relay
</div>
<h1 class="ac-title">
로그인
+93 -60
View File
@@ -1,18 +1,68 @@
<script setup lang="ts">
// 내 업무 — 담당/지시 두 뷰를 탭으로 전환, 상태 그룹별 업무 목록 (API 연동)
import { computed, onMounted, ref } from 'vue'
// 검색/프로젝트/정렬은 서버에서 처리(담당·지시 양쪽에 적용, 페이지네이션과 일관)
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import { useMyTaskStore } from '@/stores/my-task.store'
import { useProjectStore } from '@/stores/project.store'
import { countActive, type MyTaskRow } from '@/mock/relay.mock'
import type { MyTaskListQuery, MyTaskSort } from '@/types/my-task'
type View = 'assigned' | 'issued'
const view = ref<View>('assigned')
// 검색/프로젝트/정렬 필터
const keyword = ref('')
const projectFilter = ref<string>('all')
const sort = ref<MyTaskSort>('due')
const SORT_OPTIONS: { value: MyTaskSort; label: string }[] = [
{ value: 'due', label: '마감 임박순' },
{ value: 'recent', label: '최근 등록순' },
]
const myTaskStore = useMyTaskStore()
// 프로젝트 필터 드롭다운 옵션 — 프로젝트 목록을 불러와 채운다('모든 프로젝트' + 각 프로젝트)
const projectStore = useProjectStore()
const { projects } = storeToRefs(projectStore)
const projectOptions = computed(() => [
{ value: 'all', label: '모든 프로젝트' },
...projects.value.map((p) => ({ value: p.id, label: p.name })),
])
// 현재 검색/프로젝트/정렬 조건 → 내 업무 쿼리
function currentQuery(): MyTaskListQuery {
const q = keyword.value.trim()
return {
...(q ? { q } : {}),
...(projectFilter.value !== 'all' ? { projectId: projectFilter.value } : {}),
sort: sort.value,
}
}
function loadAll() {
void myTaskStore.fetchAll(currentQuery())
}
onMounted(() => {
void myTaskStore.fetchAll()
void projectStore.fetchList()
loadAll()
})
// 프로젝트/정렬 변경 시 즉시 재조회
watch([projectFilter, sort], loadAll)
// 검색어는 디바운스 후 재조회(연속 입력 시 호출 폭주 방지)
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(keyword, () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(loadAll, 250)
})
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
// 행 클릭 → 항상 업무 상세(TaskDetailPage). 역할 기반 액션은 상세에서 분기한다
@@ -95,47 +145,51 @@ function loadMore() {
/><path d="m21 21-4-4" />
</svg>
<input
v-model="keyword"
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>
<DropdownSelect
v-model="projectFilter"
:options="projectOptions"
align="left"
aria-label="프로젝트 필터"
>
<template #icon>
<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>
</template>
</DropdownSelect>
<DropdownSelect
v-model="sort"
:options="SORT_OPTIONS"
push-right
align="right"
aria-label="정렬"
>
<template #icon>
<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>
</template>
</DropdownSelect>
</div>
<!-- 승인 대기 강조 배너 (지시 ) -->
@@ -459,27 +513,6 @@ function loadMore() {
.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;
@@ -6,6 +6,7 @@ import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import { useProjectStore } from '@/stores/project.store'
import { useActivityStore } from '@/stores/activity.store'
import type { Project, ActivityIcon } from '@/mock/relay.mock'
@@ -34,13 +35,35 @@ async function load() {
watch(projectId, load, { immediate: true })
const filter = ref<Filter>('all')
// 멤버 필터(행위자 id, 'all' = 전체). 피드는 전량 로드되므로 클라이언트에서 정확히 거른다.
const memberFilter = ref<string>('all')
// 필터 적용 후 빈 날짜 그룹은 제거
// 피드에 등장한 행위자 목록(중복 제거) — 멤버 필터 드롭다운 옵션
const actorOptions = computed(() => {
const map = new Map<string, string>()
for (const d of activityStore.days) {
for (const it of d.items) {
if (it.actorId) map.set(it.actorId, it.actorName ?? '알 수 없음')
}
}
return Array.from(map, ([id, name]) => ({ id, name }))
})
// 멤버 필터 드롭다운 옵션('모든 멤버' + 행위자들)
const memberOptions = computed(() => [
{ value: 'all', label: '모든 멤버' },
...actorOptions.value.map((a) => ({ value: a.id, label: a.name })),
])
// 유형/멤버 필터 적용 후 빈 날짜 그룹은 제거
const days = computed(() =>
activityStore.days
.map((d) => ({
day: d.day,
items: d.items.filter((it) => filter.value === 'all' || it.tone === filter.value),
items: d.items.filter(
(it) =>
(filter.value === 'all' || it.tone === filter.value) &&
(memberFilter.value === 'all' || it.actorId === memberFilter.value),
),
}))
.filter((d) => d.items.length > 0),
)
@@ -142,33 +165,30 @@ const ActIcon = defineComponent({
설정
</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>
<DropdownSelect
v-model="memberFilter"
:options="memberOptions"
push-right
align="right"
aria-label="멤버 필터"
>
<template #icon>
<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>
</template>
</DropdownSelect>
</div>
<!-- 로딩 / 상태 -->
@@ -237,27 +257,6 @@ const ActIcon = defineComponent({
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 {
+33 -19
View File
@@ -8,9 +8,10 @@ import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import { useProjectStore } from '@/stores/project.store'
import { useTaskStore } from '@/stores/task.store'
import type { TaskListQuery, TaskSegment } from '@/types/task'
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
import type { Project, TaskStatus } from '@/mock/relay.mock'
const route = useRoute()
@@ -31,11 +32,17 @@ async function loadProject() {
const keyword = ref('')
const segment = ref<TaskSegment>('all')
const sort = ref<TaskSort>('latest')
const SORT_OPTIONS: { value: TaskSort; label: string }[] = [
{ value: 'latest', label: '최신순' },
{ value: 'due', label: '마감 임박순' },
{ value: 'oldest', label: '오래된순' },
]
// 현재 세그먼트/검색 조건 → 목록 쿼리
// 현재 세그먼트/검색/정렬 조건 → 목록 쿼리
function currentQuery(): TaskListQuery {
const q = keyword.value.trim()
return { segment: segment.value, ...(q ? { q } : {}) }
return { segment: segment.value, sort: sort.value, ...(q ? { q } : {}) }
}
// 업무 목록 첫 페이지 — 세그먼트/검색 변경 시 리셋 로드
@@ -66,8 +73,8 @@ watch(
{ immediate: true },
)
// 세그먼트 변경 시 목록만 리셋 로드(프로젝트 재조회 불필요)
watch(segment, loadTasks)
// 세그먼트/정렬 변경 시 목록만 리셋 로드(프로젝트 재조회 불필요)
watch([segment, sort], loadTasks)
// 검색어는 디바운스 후 리셋 로드(연속 입력 시 호출 폭주 방지)
let searchTimer: ReturnType<typeof setTimeout> | null = null
@@ -207,19 +214,26 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
완료 <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>
<DropdownSelect
v-model="sort"
:options="SORT_OPTIONS"
push-right
align="right"
aria-label="정렬"
>
<template #icon>
<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>
</template>
</DropdownSelect>
<RouterLink
class="btn primary new-task"
:to="`/projects/${repo.id}/tasks/new`"
@@ -471,7 +485,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
width: 15rem;
}
.new-task {
/* .sort 의 margin-left:auto 가 오른쪽 그룹을 밀어내므로 여기엔 auto 를 두지 않는다
/* 정렬 드롭다운(push-right)의 margin-left:auto 가 오른쪽 그룹을 밀어내므로 여기엔 auto 를 두지 않는다
(이중 auto 는 여유 공간을 분배해 필터/정렬 버튼이 떨어져 보이는 원인) */
border: none;
text-decoration: none;
+57 -26
View File
@@ -1,27 +1,51 @@
<script setup lang="ts">
// 프로젝트 목록 — 검색/필터하여 목록으로 표시
import { computed, onMounted, ref } from 'vue'
// 프로젝트 목록 — 검색/정렬은 서버에서 처리(페이지네이션과 일관)
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import { useProjectStore } from '@/stores/project.store'
import type { ProjectListQuery, ProjectSort } from '@/types/project'
const keyword = ref('')
const sort = ref<ProjectSort>('recent')
const SORT_OPTIONS: { value: ProjectSort; label: string }[] = [
{ value: 'recent', label: '최근 업데이트순' },
{ value: 'name', label: '이름순' },
{ value: 'created', label: '최근 생성순' },
]
const projectStore = useProjectStore()
const { projects, loading, total, hasMore, loadingMore } = storeToRefs(projectStore)
onMounted(() => {
void projectStore.fetchList()
// 현재 검색/정렬 조건 → 목록 쿼리(서버에서 필터·정렬)
function currentQuery(): ProjectListQuery {
const q = keyword.value.trim()
return { sort: sort.value, ...(q ? { q } : {}) }
}
function loadList() {
void projectStore.fetchList(currentQuery())
}
onMounted(loadList)
// 정렬 변경 시 즉시 재조회
watch(sort, loadList)
// 검색어는 디바운스 후 재조회(연속 입력 시 호출 폭주 방지)
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(keyword, () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(loadList, 250)
})
onBeforeUnmount(() => {
if (searchTimer) clearTimeout(searchTimer)
})
// 검색어 필터 적용
const filteredProjects = computed(() =>
projects.value.filter(
(project) => !keyword.value || project.name.includes(keyword.value),
),
)
// 검색어가 있으면 '검색 결과 없음', 없으면 '프로젝트 없음' 문구 분기
const isFiltered = computed(() => keyword.value.trim() !== '')
</script>
<template>
@@ -61,19 +85,26 @@ const filteredProjects = computed(() =>
placeholder="프로젝트 검색…"
>
</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>
<DropdownSelect
v-model="sort"
:options="SORT_OPTIONS"
push-right
align="right"
aria-label="정렬"
>
<template #icon>
<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>
</template>
</DropdownSelect>
<RouterLink
class="btn primary"
to="/projects/new"
@@ -97,7 +128,7 @@ const filteredProjects = computed(() =>
<!-- 목록 -->
<div class="list">
<RouterLink
v-for="repo in filteredProjects"
v-for="repo in projects"
:key="repo.id"
class="repo-row"
:to="`/projects/${repo.id}`"
@@ -179,10 +210,10 @@ const filteredProjects = computed(() =>
불러오는
</div>
<div
v-else-if="filteredProjects.length === 0"
v-else-if="projects.length === 0"
class="list-empty"
>
{{ projects.length === 0 ? '아직 프로젝트가 없습니다. 새 프로젝트를 만들어 보세요.' : '검색 결과가 없습니다.' }}
{{ isFiltered ? '검색 결과가 없습니다.' : '아직 프로젝트가 없습니다. 새 프로젝트를 만들어 보세요.' }}
</div>
</div>
+14 -52
View File
@@ -6,6 +6,7 @@ import AppShell from '@/layouts/AppShell.vue'
import ProjectHeader from '@/components/ProjectHeader.vue'
import ProjectTabs from '@/components/ProjectTabs.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import { useProjectStore } from '@/stores/project.store'
import { useMemberStore } from '@/stores/member.store'
import { useUser } from '@/composables/useUser'
@@ -114,6 +115,10 @@ async function removeMember(userId: string, name: string) {
const showInvite = ref(false)
const inviteEmail = ref('')
const inviteRole = ref<MemberRoleType>('member')
const ROLE_OPTIONS: { value: MemberRoleType; label: string }[] = [
{ value: 'member', label: '멤버' },
{ value: 'admin', label: '관리자' },
]
const inviteBusy = ref(false)
// --- 사용자 자동완성(타입어헤드) ---
@@ -548,26 +553,13 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<div class="m-field">
<label class="m-label">역할</label>
<div class="select-wrap">
<select v-model="inviteRole">
<option value="member">
멤버
</option>
<option value="admin">
관리자
</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>
<DropdownSelect
v-model="inviteRole"
:options="ROLE_OPTIONS"
block
class="role-select"
aria-label="역할"
/>
</div>
<div class="role-hint">
<b>멤버</b> 담당자로 배정되어 업무를 수행하고, <b>관리자</b> 업무 지시와 멤버 관리도 있습니다.
@@ -1010,42 +1002,12 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
color: var(--text-3);
font-weight: 500;
}
.invite-modal .select-wrap {
position: relative;
}
.invite-modal .select-wrap select {
appearance: none;
-webkit-appearance: none;
width: 100%;
/* 역할 선택 드롭다운(DropdownSelect) — 모달 폼 입력 높이/서체에 맞춤 */
.invite-modal .role-select :deep(.dd-trigger) {
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%;
@@ -2,6 +2,7 @@
// 비밀번호 재설정 — 메일 링크의 token(쿼리)으로 새 비밀번호를 설정
import { computed, ref } from 'vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import RelayMark from '@/components/RelayMark.vue'
import { useAuth } from '@/composables/useAuth'
const route = useRoute()
@@ -45,7 +46,7 @@ async function onSubmit() {
<div class="auth-simple">
<div class="card">
<div class="bp-logo">
<span class="logo">R</span> Relay
<span class="logo"><RelayMark white /></span> Relay
</div>
<!-- 토큰 없음 -->
+3 -2
View File
@@ -2,6 +2,7 @@
// 회원가입 — 가입 폼 → 인증 메일 발송 안내. 인증 메일의 링크를 눌러야 가입이 완료된다.
import { computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import RelayMark from '@/components/RelayMark.vue'
import { useAuthStore } from '@/stores/auth.store'
import { useDialog } from '@/composables/useDialog'
@@ -98,7 +99,7 @@ const points = [
<div class="blob b2" />
<div class="ring" />
<div class="bp-logo">
<span class="logo">R</span> Relay
<span class="logo"><RelayMark white /></span> Relay
</div>
<div class="bp-body">
<div class="bp-head">
@@ -138,7 +139,7 @@ const points = [
<main class="form-panel">
<div class="auth-card">
<div class="ac-logo">
<span class="logo">R</span> Relay
<span class="logo"><RelayMark white /></span> Relay
</div>
<!-- 상태 1: 가입 -->
+1 -3
View File
@@ -1154,9 +1154,7 @@ function goBack() {
</template>
<style scoped>
.page {
max-width: 77.5rem;
}
/* 컨텐츠 최대 너비는 전역 .page(67.5rem)를 그대로 사용해 다른 화면과 통일한다. */
.crumb {
padding: 1rem 0 0;
}
+1 -124
View File
@@ -14,7 +14,6 @@ import { useRoute, useRouter, RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import {
CHECKLIST_CATEGORIES,
type Attachment,
type ChecklistCategory,
type ChecklistItem,
type Comment,
@@ -332,39 +331,8 @@ const commentTotal = computed(
)
/* ============================================================
* 파일 첨부(5단계) — 업로드/삭제
* 파일 첨부 — 상세 화면은 목록 표시 전용(업로드/삭제는 작성·편집 화면에서만).
* ============================================================ */
const fileInput = ref<HTMLInputElement | null>(null)
const uploading = ref(false)
function pickFile() {
fileInput.value?.click()
}
async function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
if (!file || !task.value) {
input.value = ''
return
}
uploading.value = true
try {
task.value = await taskStore.uploadFile(projectId.value, task.value.id, file)
} catch {
// 인터셉터가 알림 처리
} finally {
uploading.value = false
input.value = ''
}
}
async function removeFile(att: Attachment) {
if (!task.value || !att.id) return
try {
task.value = await taskStore.removeFile(projectId.value, task.value.id, att.id)
} catch {
// 인터셉터가 알림 처리
}
}
/* ============================================================
* 키보드 / 외부 클릭 처리
@@ -1134,29 +1102,7 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
<div class="side-field">
<div class="side-label files-head">
<span>첨부파일 · {{ task.files.length }}</span>
<button
class="file-add"
type="button"
:disabled="uploading"
@click="pickFile"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 5v14M5 12h14" /></svg>
{{ uploading ? '업로드 중…' : '추가' }}
</button>
</div>
<input
ref="fileInput"
class="file-hidden"
type="file"
@change="onFileChange"
>
<div
v-if="task.files.length"
class="files"
@@ -1179,22 +1125,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
target="_blank"
rel="noopener"
><span class="file-name">{{ f.name }}</span><span class="file-size">{{ f.size }}</span></a>
<button
class="file-del"
type="button"
title="첨부 삭제"
aria-label="첨부 삭제"
@click="removeFile(f)"
>
<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>
<p
@@ -3018,34 +2948,6 @@ function badgeFor(name: string | undefined): { label: string; cls: string } {
align-items: center;
justify-content: space-between;
}
.file-add {
display: inline-flex;
align-items: center;
gap: 0.25rem;
border: 1px solid var(--border);
background: #fff;
font-family: inherit;
font-size: 0.719rem;
font-weight: 600;
color: var(--text-2);
padding: 0.1875rem 0.5rem;
border-radius: var(--radius-sm);
cursor: pointer;
}
.file-add:hover {
background: #f5f6f8;
}
.file-add:disabled {
opacity: 0.6;
cursor: default;
}
.file-add svg {
width: 0.75rem;
height: 0.75rem;
}
.file-hidden {
display: none;
}
a.file-ico,
a.file-info {
text-decoration: none;
@@ -3057,31 +2959,6 @@ a.file-ico {
a.file-info {
color: inherit;
}
.file-del {
width: 1.5rem;
height: 1.5rem;
flex-shrink: 0;
display: grid;
place-items: center;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0;
transition: opacity 0.12s;
}
.file:hover .file-del {
opacity: 1;
}
.file-del:hover {
background: var(--red-weak);
color: var(--red);
}
.file-del svg {
width: 0.875rem;
height: 0.875rem;
}
.files-empty {
font-size: 0.781rem;
color: var(--text-3);
+3 -1
View File
@@ -19,9 +19,11 @@ function pstr(payload: Record<string, unknown>, key: string): string {
// 행위자/시간 등 공통 필드
function baseOf(
api: ApiActivity,
): Pick<ProjectActivityItem, 'avatarUrl' | 'time'> {
): Pick<ProjectActivityItem, 'avatarUrl' | 'actorId' | 'actorName' | 'time'> {
return {
avatarUrl: api.actor?.avatarUrl ?? null,
actorId: api.actor?.id ?? null,
actorName: api.actor?.name ?? null,
time: relativeTimeKo(api.createdAt),
}
}
+10 -7
View File
@@ -4,7 +4,7 @@ import { useMyTask } from '@/composables/useMyTask'
import { DEFAULT_PAGE_SIZE } from '@/types/pagination'
import { taskDueInfo, taskStatusLabel } from '@/shared/utils/format'
import type { ApiMember } from '@/types/repo'
import type { ApiMyTaskRow } from '@/types/my-task'
import type { ApiMyTaskRow, MyTaskListQuery } from '@/types/my-task'
import type {
MyTaskGroup,
MyTaskRow,
@@ -122,6 +122,8 @@ export const useMyTaskStore = defineStore('myTask', () => {
const issuedPage = ref(0)
const loading = ref(false)
const loadingMore = ref(false)
// 검색/프로젝트/정렬 조건 — 더보기에서 같은 조건으로 다음 페이지를 잇기 위해 보관
const query = ref<MyTaskListQuery>({})
const myTaskApi = useMyTask()
@@ -134,13 +136,14 @@ export const useMyTaskStore = defineStore('myTask', () => {
const assignedHasMore = computed(() => assignedRaw.value.length < assignedTotal.value)
const issuedHasMore = computed(() => issuedRaw.value.length < issuedTotal.value)
// 첫 페이지(리셋) — 담당/지시 동시 조회
async function fetchAll(): Promise<void> {
// 첫 페이지(리셋) — 담당/지시 동시 조회. 검색/프로젝트/정렬 조건 적용
async function fetchAll(q: MyTaskListQuery = {}): Promise<void> {
query.value = q
loading.value = true
try {
const [a, i] = await Promise.all([
myTaskApi.assigned(1, DEFAULT_PAGE_SIZE),
myTaskApi.issued(1, DEFAULT_PAGE_SIZE),
myTaskApi.assigned(1, DEFAULT_PAGE_SIZE, query.value),
myTaskApi.issued(1, DEFAULT_PAGE_SIZE, query.value),
])
assignedRaw.value = a.items
assignedTotal.value = a.total
@@ -158,7 +161,7 @@ export const useMyTaskStore = defineStore('myTask', () => {
if (loadingMore.value || !assignedHasMore.value) return
loadingMore.value = true
try {
const a = await myTaskApi.assigned(assignedPage.value + 1, DEFAULT_PAGE_SIZE)
const a = await myTaskApi.assigned(assignedPage.value + 1, DEFAULT_PAGE_SIZE, query.value)
assignedRaw.value.push(...a.items)
assignedTotal.value = a.total
assignedPage.value += 1
@@ -172,7 +175,7 @@ export const useMyTaskStore = defineStore('myTask', () => {
if (loadingMore.value || !issuedHasMore.value) return
loadingMore.value = true
try {
const i = await myTaskApi.issued(issuedPage.value + 1, DEFAULT_PAGE_SIZE)
const i = await myTaskApi.issued(issuedPage.value + 1, DEFAULT_PAGE_SIZE, query.value)
issuedRaw.value.push(...i.items)
issuedTotal.value = i.total
issuedPage.value += 1
+8 -3
View File
@@ -7,6 +7,7 @@ import type { ApiMember } from '@/types/repo'
import type {
ApiProject,
CreateProjectPayload,
ProjectListQuery,
UpdateProjectPayload,
} from '@/types/project'
import type { Project as ProjectView, User as UserView } from '@/mock/relay.mock'
@@ -56,10 +57,14 @@ export const useProjectStore = defineStore('project', () => {
const projectApi = useProject()
async function fetchList(): Promise<void> {
// 검색/정렬 조건을 보관 — 더보기에서 같은 조건으로 다음 페이지를 잇기 위함
const query = ref<ProjectListQuery>({})
async function fetchList(q: ProjectListQuery = {}): Promise<void> {
query.value = q
loading.value = true
try {
const res = await projectApi.list(1, DEFAULT_PAGE_SIZE)
const res = await projectApi.list(1, DEFAULT_PAGE_SIZE, query.value)
projects.value = res.items.map(toProjectView)
total.value = res.total
page.value = 1
@@ -72,7 +77,7 @@ export const useProjectStore = defineStore('project', () => {
if (loadingMore.value || !hasMore.value) return
loadingMore.value = true
try {
const res = await projectApi.list(page.value + 1, DEFAULT_PAGE_SIZE)
const res = await projectApi.list(page.value + 1, DEFAULT_PAGE_SIZE, query.value)
projects.value.push(...res.items.map(toProjectView))
total.value = res.total
page.value += 1
-22
View File
@@ -328,26 +328,6 @@ export const useTaskStore = defineStore('task', () => {
return refresh(projectId, taskId)
}
/* ===================== 파일 첨부 (5단계) ===================== */
async function uploadFile(
projectId: string,
taskId: number,
file: File,
): Promise<TaskDetailView> {
await taskApi.uploadFile(projectId, taskId, file)
return refresh(projectId, taskId)
}
async function removeFile(
projectId: string,
taskId: number,
fileId: string,
): Promise<TaskDetailView> {
await taskApi.removeFile(projectId, taskId, fileId)
return refresh(projectId, taskId)
}
/* ===================== 승인 워크플로우 (5단계) ===================== */
async function requestApproval(
@@ -396,8 +376,6 @@ export const useTaskStore = defineStore('task', () => {
changeStatus,
addComment,
addReply,
uploadFile,
removeFile,
requestApproval,
approve,
requestChanges,
+10
View File
@@ -3,6 +3,16 @@
import type { TaskStatus } from '@/mock/relay.mock'
import type { ApiMember } from '@/types/repo'
// 내 업무 정렬 — 마감 임박순/최근 등록순 (백엔드 sort 쿼리와 1:1)
export type MyTaskSort = 'due' | 'recent'
// 내 업무 필터 — 검색/프로젝트/정렬 (백엔드 assigned·issued 쿼리와 1:1)
export interface MyTaskListQuery {
q?: string
projectId?: string
sort?: MyTaskSort
}
// 내 업무 행(API 원시) — 그룹핑/라벨은 프론트가 파생
export interface ApiMyTaskRow {
id: number // 프로젝트 내 순번(seq)
+9
View File
@@ -17,6 +17,15 @@ export interface ApiProject {
isOwner?: boolean // 소유자 (프로젝트 삭제)
}
// 프로젝트 목록 정렬 — 최근 업데이트순/이름순/최근 생성순 (백엔드 sort 쿼리와 1:1)
export type ProjectSort = 'recent' | 'name' | 'created'
// 프로젝트 목록 필터 — 검색/정렬 (백엔드 findAll 쿼리와 1:1)
export interface ProjectListQuery {
q?: string
sort?: ProjectSort
}
// 프로젝트 생성 요청
export interface CreateProjectPayload {
name: string
+5 -1
View File
@@ -94,11 +94,15 @@ export interface TaskSegmentCounts {
done: number
}
// 업무 목록 필터세그먼트/검색/담당자 (백엔드 list 쿼리와 1:1)
// 업무 목록 정렬최신순/마감 임박순/오래된순 (백엔드 sort 쿼리와 1:1)
export type TaskSort = 'latest' | 'due' | 'oldest'
// 업무 목록 필터 — 세그먼트/검색/담당자/정렬 (백엔드 list 쿼리와 1:1)
export interface TaskListQuery {
segment?: TaskSegment
q?: string
assignee?: string
sort?: TaskSort
}
// 업무 목록 응답 — 페이지네이션 + 세그먼트 카운트 동봉 (백엔드 ProjectTaskListResult 와 1:1)