Compare commits

...

15 Commits

Author SHA1 Message Date
ttipo 61d247de08 Merge: 프론트 공용화 리팩터 (Phase 0-4)
refactor/frontend-commonization 머지 — useClickOutside/useDropdownMenu 훅,
BaseModal/SegmentedTabs/LoadMore/EmptyState 공용 컴포넌트, .form-*/.empty-state
전역 클래스, 작업상태 SSOT(workStatus) + 업무상태 'todo' 라벨 '대기' 통일.
2026-06-23 00:10:16 +09:00
ttipo ddf94b188b Merge: 전사 공유 일정(주간 캘린더) 기능
feature/schedule-calendar 머지 — 백엔드 Schedule 모듈 + 프론트 일정 화면
(주간 타임라인·카테고리/일정 CRUD·상세 패널·모달) + UI 다듬기.
공용화 리팩터(refactor/frontend-commonization)는 별도 브랜치로 분리 유지.
2026-06-23 00:09:12 +09:00
ttipo daff61c85e refactor: 업무 상태 'todo' 라벨을 '대기' 로 통일 (Phase 4)
todo 표기가 화면마다 '할 일'(taskStatusLabel) vs '대기'(ProjectDetail 배지)로
갈리던 불일치 해소. SSOT(format.taskStatusLabel)를 '대기' 로 통일하고
ProjectDetailPage 의 자체 STATUS_LABEL 제거 → taskStatusLabel 사용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:02:42 +09:00
ttipo 3380bbdcff refactor: 작업자 진행상태(대기/진행중/완료) 라벨·색 SSOT 통합 (Phase 4)
StatusSelect 와 업무 상세에 각각 중복 정의되던 WORK_STATUS_META/ORDER 를
shared/constants/workStatus.ts 단일 소스로 추출(값 동일, 표시 변화 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 00:00:21 +09:00
ttipo 21cabbe20b refactor: ScheduleAvatar 를 UserAvatar 어댑터로 정리 (Phase 3-4)
ScheduleAvatar 가 복제하던 이미지/실루엣 렌더링을 제거하고, 공용 UserAvatar 를
size(px)로 감싸는 얇은 래퍼로 전환. 아바타 표시 로직을 UserAvatar 단일 소스로 통일
(.sa 클래스는 상세 패널 스택 CSS 훅으로 유지, 사용처 변경 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:57:22 +09:00
ttipo d3e2861440 refactor: 빈/상태 메시지 박스 EmptyState 로 통일 (Phase 3-3)
목록 로딩·빈 상태 박스(state-msg/feed-state/list-empty)를 전역 .empty-state
카드 스타일 + EmptyState 컴포넌트로 통일. 5개 페이지(프로젝트 목록/상세,
내 업무, 활동, 멤버)의 중복 박스 CSS 제거.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:56:07 +09:00
ttipo e7760e9ddc refactor: 더보기 버튼 LoadMore 컴포넌트로 통일 (Phase 3-2)
목록 페이지네이션 '더보기' 버튼 마크업/문구를 LoadMore 로 추출(전역 .load-more 사용).
- 프로젝트 목록/상세, 내 업무, 프로젝트 활동 4곳 적용
- 활동 화면의 비표준 .more-row/.more-btn 제거하고 표준 .load-more 로 통일
  ('더 보기' → '더보기' 문구도 통일)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:51:45 +09:00
ttipo d3a916dbd7 refactor: 세그먼트 토글 SegmentedTabs 컴포넌트로 통일 (Phase 3-1)
3개 페이지(활동/프로젝트 상세/멤버)에 반복되던 .segmented 버튼 마크업을
options 기반 SegmentedTabs 로 추출(전역 .segmented 스타일은 그대로 사용).
수량(.n) 표기는 option.count 로 처리.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:49:09 +09:00
ttipo 7425d57134 refactor: Teleport 드롭다운 위치/토글 로직 useDropdownMenu 로 통합 (Phase 2)
DropdownSelect·StatusSelect 에 중복되던 fixed 위치 계산·open 토글·
바깥클릭/ESC/스크롤 닫기 로직을 useDropdownMenu 훅으로 추출.

- composables/useDropdownMenu.ts 신설 (align/matchWidth/gap/esc 옵션)
- DropdownSelect, StatusSelect 가 훅을 사용하도록 정리
- 절대배치 메뉴(일정 멀티셀렉트/유형필터/참석자 스택)는 Phase 0 useClickOutside 로 이미 공유

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:44:48 +09:00
ttipo 0c4f0817e6 refactor: 멤버 초대 모달을 BaseModal 로 전환 (Phase 1b)
- 중복 모달 셸/헤더/푸터/필드 CSS(~150줄) 제거 → BaseModal + 전역 .form-*
- BaseModal 에 subtitle 슬롯 + 푸터 버튼 아이콘 정렬 추가
- 본문 폼은 display:contents 로 BaseModal 본문 간격 상속,
  고유 요소(자동완성/역할 힌트/푸터 노트)만 .invite-body 로 재스코프

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:41:41 +09:00
ttipo 5f8b2597bf refactor: 공용 폼 모달 BaseModal + 전역 .form-* 필드 클래스 (Phase 1a)
- components/common/BaseModal.vue 신설: backdrop+헤더(제목/닫기)+본문/푸터 슬롯,
  Teleport·ESC·바깥클릭 닫기 내장. 전역 .modal* 와 충돌 없게 bm- 접두 클래스 사용
- relay.css 에 .form-field/-label/-row/-input/-textarea/-select(+wrap/chev) 표준 추가
- 일정·카테고리 모달을 BaseModal + .form-* 로 전환(중복 모달/필드 CSS 제거,
  푸터 버튼은 전역 .mbtn 사용)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:35:33 +09:00
ttipo 92ed6c332f refactor: 바깥클릭/ESC 닫기 공용 훅 useClickOutside 도입 (Phase 0)
11곳에 복붙되던 document 리스너 등록/해제·바깥클릭·ESC·스크롤 닫기 로직을
useClickOutside 훅으로 통합. 재사용 드롭다운 5종에 우선 적용.

- composables/useClickOutside.ts 신설 (event/esc/closeOnScroll 옵션)
- 적용: DropdownSelect, StatusSelect, ScheduleTypeFilter,
  ScheduleDetailPanel(참석자 스택), ScheduleEventModal(참석자 멀티셀렉트)
- 모달 ESC·페이지 레벨 오버레이 조정 로직은 후속 단계(BaseModal)에서 처리

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:31:45 +09:00
ttipo d5b54c1afb style: 일정 화면 UI 다듬기 (기존 디자인 규격에 통일)
- 툴바 '오늘' 버튼 제거, 컨트롤 간격을 전역 .toolbar(0.625rem)와 통일
- 타임라인 레인 hover 편집/삭제 버튼 제거(카테고리 관리는 유형 필터에서)
- 유형 필터 버튼을 기존 DropdownSelect pill 디자인으로 통일 + 수량 뱃지 제거
- '새 일정' 버튼의 스코프 .btn 재정의 제거 → 전역 .btn primary 표준 사용
- 일정/카테고리 모달을 사이트 폼 모달 규격(rem + 멤버 초대 모달)으로 재정렬,
  라벨 좌측정렬, 취소/저장 버튼을 전역 .mbtn 으로 통일
- 참석자 아바타를 공용 UserAvatar 방식(이미지/실루엣)으로 교체,
  '전 직원' 표식 크기를 아바타와 동일(20px)하게 고정(루트 14px rem 보정)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:24:33 +09:00
ttipo 346ec75553 feat: 전사 공유 일정(주간 캘린더) 기능 추가
상단 nav 독립 메뉴 '일정' — 주간 타임라인(간트형) 캘린더.
카테고리=레인, 일정=막대(다중일 지원), 우측 상세 패널, 일정/카테고리 CRUD.

백엔드(Schedule 모듈):
- ScheduleCategory/ScheduleEvent 엔티티 + 참석자(다대다) + 작성자
- 카테고리/일정 CRUD, 기간 겹침 조회, 참석자 풀(전사 사용자)
- 기본 카테고리 12종 onModuleInit 멱등 시드
- 인증 사용자 누구나 관리(전역 일정, 글로벌 관리자 개념 없음)
- 운영 마이그레이션(AddSchedule) 추가, @nestjs/schedule 임포트는 CronModule 로 alias

프론트:
- /schedule 라우트 + 사이드바 nav 항목
- 주간 네비(이전/다음/오늘), 유형 필터·카테고리 관리 드롭다운
- 타임라인/상세 패널/일정 모달/카테고리 모달(hue 슬라이더)/참석자 멀티셀렉트
- schedule.store + useSchedule + scheduleDate 유틸

런타임 검증: dev 부팅·시드(12건)·라우트 매핑 정상, 인증 게이트(401),
일정 생성/조회/수정/삭제 + 참석자/전직원/다중일 스모크 전부 정상.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 21:23:10 +09:00
ttipo 62df92e6eb chore: 디자인 시안 HTML(단일 파일) gitignore 패턴 추가
시안 네이밍이 '(단일 파일)' 로도 들어와 기존 '단독' 패턴에 안 걸리던 것 보완.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:23:45 +09:00
40 changed files with 4057 additions and 633 deletions
+1
View File
@@ -53,3 +53,4 @@ Thumbs.db
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
frontend/*단독*.html
frontend/*단일 파일*.html
+4 -2
View File
@@ -1,7 +1,7 @@
import { Logger, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
import { ScheduleModule as CronModule } from '@nestjs/schedule';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { CacheModule } from '@nestjs/cache-manager';
import { createKeyv } from '@keyv/redis';
@@ -18,10 +18,11 @@ import { ActivityModule } from './modules/activity/activity.module';
import { NotificationModule } from './modules/notification/notification.module';
import { AiModule } from './modules/ai/ai.module';
import { ProjectModule } from './modules/project/project.module';
import { ScheduleModule } from './modules/schedule/schedule.module';
@Module({
imports: [
ScheduleModule.forRoot(),
CronModule.forRoot(),
ConfigModule.forRoot({
isGlobal: true,
envFilePath: `.env.${process.env.NODE_ENV || 'development'}`,
@@ -98,6 +99,7 @@ import { ProjectModule } from './modules/project/project.module';
NotificationModule,
AiModule,
ProjectModule,
ScheduleModule,
],
controllers: [AppController],
providers: [
@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
// 전사 공유 일정(캘린더) — 카테고리/일정/참석자 테이블 추가.
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
// (기본 카테고리 시드는 ScheduleService.onModuleInit 가 멱등 처리하므로 여기서 넣지 않는다.)
export class AddSchedule1782300000000 implements MigrationInterface {
name = 'AddSchedule1782300000000';
public async up(queryRunner: QueryRunner): Promise<void> {
// 카테고리(레인)
await queryRunner.query(
`CREATE TABLE "schedule_categories" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "label" character varying NOT NULL, "color" character varying NOT NULL, "sort_order" integer NOT NULL DEFAULT '0', "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_schedule_categories" PRIMARY KEY ("id"))`,
);
// 일정(이벤트)
await queryRunner.query(
`CREATE TABLE "schedule_events" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "title" character varying NOT NULL, "start_date" date NOT NULL, "end_date" date NOT NULL, "time" character varying NOT NULL DEFAULT '', "place" character varying, "description" text, "all_hands" boolean NOT NULL DEFAULT false, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "category_id" uuid, "created_by" uuid, CONSTRAINT "PK_schedule_events" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_events_dates" ON "schedule_events" ("start_date", "end_date")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_events_category" ON "schedule_events" ("category_id")`,
);
// 참석자(다대다 조인)
await queryRunner.query(
`CREATE TABLE "schedule_event_attendees" ("event_id" uuid NOT NULL, "user_id" uuid NOT NULL, CONSTRAINT "PK_schedule_event_attendees" PRIMARY KEY ("event_id", "user_id"))`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_event_attendees_event" ON "schedule_event_attendees" ("event_id")`,
);
await queryRunner.query(
`CREATE INDEX "IDX_schedule_event_attendees_user" ON "schedule_event_attendees" ("user_id")`,
);
// FK
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_category" FOREIGN KEY ("category_id") REFERENCES "schedule_categories"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_created_by" FOREIGN KEY ("created_by") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_event" FOREIGN KEY ("event_id") REFERENCES "schedule_events"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" ADD CONSTRAINT "FK_schedule_event_attendees_user" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_user"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_event_attendees" DROP CONSTRAINT "FK_schedule_event_attendees_event"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_created_by"`,
);
await queryRunner.query(
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_category"`,
);
await queryRunner.query(`DROP TABLE "schedule_event_attendees"`);
await queryRunner.query(`DROP TABLE "schedule_events"`);
await queryRunner.query(`DROP TABLE "schedule_categories"`);
}
}
@@ -0,0 +1,23 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
// 일정 카테고리 생성 DTO
export class CreateScheduleCategoryDto {
@ApiProperty({ description: '카테고리 이름', example: '회의' })
@IsString()
@IsNotEmpty({ message: '카테고리 이름을 입력해 주세요.' })
@MaxLength(40, { message: '카테고리 이름은 최대 40자입니다.' })
label!: string;
@ApiProperty({ description: '카테고리 색(hex)', example: '#1d4ed8' })
@IsString()
@Matches(/^#[0-9a-fA-F]{6}$/, {
message: '색상은 #RRGGBB 형식의 hex 값이어야 합니다.',
})
color!: string;
}
// 일정 카테고리 수정 DTO — 생성 DTO 의 부분 집합
export class UpdateScheduleCategoryDto extends PartialType(
CreateScheduleCategoryDto,
) {}
@@ -0,0 +1,71 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import {
ArrayUnique,
IsArray,
IsBoolean,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
// 일정 생성 DTO
export class CreateScheduleEventDto {
@ApiProperty({ description: '일정 제목', example: '주간 팀 스탠드업' })
@IsString()
@IsNotEmpty({ message: '일정 제목을 입력해 주세요.' })
@MaxLength(200, { message: '제목은 최대 200자입니다.' })
title!: string;
@ApiProperty({ description: '카테고리 id(uuid)' })
@IsUUID('4', { message: '올바른 카테고리를 선택해 주세요.' })
categoryId!: string;
@ApiProperty({ description: '시작일(YYYY-MM-DD)', example: '2026-06-22' })
@IsDateString({}, { message: '시작일 형식이 올바르지 않습니다.' })
start!: string;
@ApiProperty({ description: '종료일(YYYY-MM-DD)', example: '2026-06-22' })
@IsDateString({}, { message: '종료일 형식이 올바르지 않습니다.' })
end!: string;
@ApiPropertyOptional({
description: '표시용 시간 텍스트',
example: '09:30 10:00',
})
@IsOptional()
@IsString()
@MaxLength(100)
time?: string;
@ApiPropertyOptional({ description: '장소', example: '3층 포커스룸 A' })
@IsOptional()
@IsString()
@MaxLength(200)
place?: string;
@ApiPropertyOptional({ description: '설명/메모' })
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@ApiPropertyOptional({ description: '전 직원 대상 여부', example: false })
@IsOptional()
@IsBoolean()
allHands?: boolean;
@ApiPropertyOptional({ description: '참석자 사용자 id 목록', type: [String] })
@IsOptional()
@IsArray()
@IsUUID('4', { each: true })
@ArrayUnique()
attendeeIds?: string[];
}
// 일정 수정 DTO — 생성 DTO 의 부분 집합
export class UpdateScheduleEventDto extends PartialType(
CreateScheduleEventDto,
) {}
@@ -0,0 +1,29 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
} from 'typeorm';
// 일정 카테고리(타임라인 레인) — 전사 공유. 사용자가 추가·수정·삭제 가능.
// 음영색(weak/border)은 color 로부터 프론트가 color-mix 로 파생한다(저장하지 않음).
@Entity('schedule_categories')
export class ScheduleCategory {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 표시 이름(예: 회의, 휴가·부재)
@Column({ type: 'varchar' })
label!: string;
// 카테고리 색(hex, 예: #1d4ed8)
@Column({ type: 'varchar' })
color!: string;
// 표시 순서(레인/필터 정렬) — 작을수록 위
@Column({ name: 'sort_order', type: 'int', default: 0 })
sortOrder!: number;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
}
@@ -0,0 +1,75 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
type Relation,
} from 'typeorm';
import { User } from '../../user/entities/user.entity';
import { ScheduleCategory } from './schedule-category.entity';
// 일정(이벤트) — 전사 공유 캘린더의 단일 항목. 종일 기준, 다중일(start≠end) 지원.
@Entity('schedule_events')
// 주간 범위 조회(겹침 검색)에 사용되는 복합 인덱스
@Index(['startDate', 'endDate'])
export class ScheduleEvent {
@PrimaryGeneratedColumn('uuid')
id!: string;
// 소속 카테고리(레인) — 카테고리 삭제 시 일정도 함께 삭제
// Relation<> 래퍼: 엔티티 순환참조 회피
@Index()
@ManyToOne(() => ScheduleCategory, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'category_id' })
category!: Relation<ScheduleCategory>;
@Column({ type: 'varchar' })
title!: string;
// 시작·종료일(YYYY-MM-DD, 종일 기준). date 타입이라 문자열로 다룬다.
@Column({ name: 'start_date', type: 'date' })
startDate!: string;
@Column({ name: 'end_date', type: 'date' })
endDate!: string;
// 표시용 시간 텍스트(예: "09:30 10:00", "종일") — 프론트 모달이 조립
@Column({ type: 'varchar', default: '' })
time!: string;
@Column({ type: 'varchar', nullable: true })
place!: string | null;
@Column({ type: 'text', nullable: true })
description!: string | null;
// 전 직원 대상 여부 — true 면 개별 참석자 대신 "전 직원"으로 표시
@Column({ name: 'all_hands', type: 'boolean', default: false })
allHands!: boolean;
// 개별 참석자(전 직원이 아닐 때). 조인 테이블로 다대다 매핑.
@ManyToMany(() => User)
@JoinTable({
name: 'schedule_event_attendees',
joinColumn: { name: 'event_id', referencedColumnName: 'id' },
inverseJoinColumn: { name: 'user_id', referencedColumnName: 'id' },
})
attendees!: User[];
// 작성자 — 사용자 삭제 시에도 일정은 유지(SET NULL)
@ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true })
@JoinColumn({ name: 'created_by' })
createdBy!: User | null;
@CreateDateColumn({ name: 'created_at' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt!: Date;
}
@@ -0,0 +1,140 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { PublicUser } from '../user/user.service';
import {
ScheduleService,
type ScheduleAttendee,
type ScheduleCategoryResponse,
type ScheduleEventResponse,
} from './schedule.service';
import {
CreateScheduleCategoryDto,
UpdateScheduleCategoryDto,
} from './dto/schedule-category.dto';
import {
CreateScheduleEventDto,
UpdateScheduleEventDto,
} from './dto/schedule-event.dto';
// YYYY-MM-DD 형식 검증(쿼리 파라미터용)
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
// 일정(전사 공유 캘린더) 컨트롤러 — 인증 사용자면 누구나 조회·관리 가능.
@ApiTags('Schedule')
@Controller('schedule')
@UseGuards(JwtAuthGuard)
export class ScheduleController {
constructor(private readonly scheduleService: ScheduleService) {}
// ----- 카테고리 -----
@Get('categories')
@ApiOperation({ summary: '일정 카테고리 목록' })
@ApiResponse({ status: 200, description: '조회 성공' })
listCategories(): Promise<ScheduleCategoryResponse[]> {
return this.scheduleService.listCategories();
}
@Post('categories')
@ApiOperation({ summary: '일정 카테고리 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createCategory(
@Body() dto: CreateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
return this.scheduleService.createCategory(dto);
}
@Patch('categories/:id')
@ApiOperation({ summary: '일정 카테고리 수정' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateCategory(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
return this.scheduleService.updateCategory(id, dto);
}
@Delete('categories/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '일정 카테고리 삭제(포함 일정도 삭제)' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteCategory(
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ success: boolean }> {
await this.scheduleService.deleteCategory(id);
return { success: true };
}
// ----- 참석자 선택 풀 -----
@Get('people')
@ApiOperation({ summary: '참석자 선택용 사용자 목록' })
@ApiResponse({ status: 200, description: '조회 성공' })
listPeople(): Promise<ScheduleAttendee[]> {
return this.scheduleService.listPeople();
}
// ----- 일정 -----
@Get('events')
@ApiOperation({ summary: '일정 목록(기간 겹침)' })
@ApiResponse({ status: 200, description: '조회 성공' })
listEvents(
@Query('start') start: string,
@Query('end') end: string,
): Promise<ScheduleEventResponse[]> {
if (!start || !end || !DATE_RE.test(start) || !DATE_RE.test(end)) {
throw new BadRequestException(
'start, end 쿼리(YYYY-MM-DD)가 필요합니다.',
);
}
return this.scheduleService.listEvents(start, end);
}
@Post('events')
@ApiOperation({ summary: '일정 생성' })
@ApiResponse({ status: 201, description: '생성 성공' })
createEvent(
@CurrentUser() user: PublicUser,
@Body() dto: CreateScheduleEventDto,
): Promise<ScheduleEventResponse> {
return this.scheduleService.createEvent(dto, user.id);
}
@Patch('events/:id')
@ApiOperation({ summary: '일정 수정' })
@ApiResponse({ status: 200, description: '수정 성공' })
updateEvent(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleEventDto,
): Promise<ScheduleEventResponse> {
return this.scheduleService.updateEvent(id, dto);
}
@Delete('events/:id')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '일정 삭제' })
@ApiResponse({ status: 200, description: '삭제 성공' })
async deleteEvent(
@Param('id', ParseUUIDPipe) id: string,
): Promise<{ success: boolean }> {
await this.scheduleService.deleteEvent(id);
return { success: true };
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserModule } from '../user/user.module';
import { ScheduleCategory } from './entities/schedule-category.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
import { ScheduleService } from './schedule.service';
import { ScheduleController } from './schedule.controller';
// 일정(전사 공유 캘린더) 모듈 — 카테고리/일정 CRUD. UserModule 로 참석자 풀 조회.
@Module({
imports: [
TypeOrmModule.forFeature([ScheduleCategory, ScheduleEvent]),
UserModule,
],
controllers: [ScheduleController],
providers: [ScheduleService],
exports: [ScheduleService],
})
export class ScheduleModule {}
@@ -0,0 +1,273 @@
import {
Injectable,
Logger,
NotFoundException,
type OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { User } from '../user/entities/user.entity';
import { UserService } from '../user/user.service';
import { ScheduleCategory } from './entities/schedule-category.entity';
import { ScheduleEvent } from './entities/schedule-event.entity';
import { CreateScheduleCategoryDto } from './dto/schedule-category.dto';
import { CreateScheduleEventDto } from './dto/schedule-event.dto';
// 카테고리/일정 응답(원시) — 표시 음영색은 프론트가 color 로 파생
export interface ScheduleCategoryResponse {
id: string;
label: string;
color: string;
sortOrder: number;
}
export interface ScheduleAttendee {
id: string;
name: string;
avatarUrl: string | null;
}
export interface ScheduleEventResponse {
id: string;
categoryId: string;
title: string;
start: string; // YYYY-MM-DD
end: string; // YYYY-MM-DD
time: string;
place: string | null;
description: string | null;
allHands: boolean;
attendees: ScheduleAttendee[];
createdById: string | null;
}
// 기본 카테고리 시드 — 최초 1회만 적재(테이블 비어있을 때). label/color/순서.
const DEFAULT_CATEGORIES: { label: string; color: string }[] = [
{ label: '회의', color: '#1d4ed8' },
{ label: '외부 미팅', color: '#0284c7' },
{ label: '채용·면접', color: '#be185d' },
{ label: '사내 행사', color: '#4f46e5' },
{ label: '교육·세미나', color: '#1f9d57' },
{ label: '휴가·부재', color: '#b7791f' },
{ label: '회식·모임', color: '#9333ea' },
{ label: '마감·릴리스', color: '#dc2626' },
{ label: '리뷰·QA', color: '#0d9488' },
{ label: '재무·결산', color: '#475569' },
{ label: '홍보·PR', color: '#ea580c' },
{ label: '기타', color: '#0e9594' },
];
// 일정(전사 공유 캘린더) 비즈니스 로직 — 카테고리/일정 CRUD + 참석자 매핑.
@Injectable()
export class ScheduleService implements OnModuleInit {
private readonly logger = new Logger(ScheduleService.name);
constructor(
@InjectRepository(ScheduleCategory)
private readonly categoryRepo: Repository<ScheduleCategory>,
@InjectRepository(ScheduleEvent)
private readonly eventRepo: Repository<ScheduleEvent>,
private readonly userService: UserService,
) {}
// 기동 시 기본 카테고리 시드(멱등) — 비어있을 때만 적재. dev/prod 공통.
async onModuleInit(): Promise<void> {
const count = await this.categoryRepo.count();
if (count > 0) return;
const rows = DEFAULT_CATEGORIES.map((c, i) =>
this.categoryRepo.create({
label: c.label,
color: c.color,
sortOrder: i,
}),
);
await this.categoryRepo.save(rows);
this.logger.log(`기본 일정 카테고리 ${rows.length}건 시드`);
}
// ----- 카테고리 -----
async listCategories(): Promise<ScheduleCategoryResponse[]> {
const rows = await this.categoryRepo.find({
order: { sortOrder: 'ASC', createdAt: 'ASC' },
});
return rows.map((c) => this.toCategoryResponse(c));
}
async createCategory(
dto: CreateScheduleCategoryDto,
): Promise<ScheduleCategoryResponse> {
// 다음 정렬값 = 현재 최대 + 1
const max = await this.categoryRepo
.createQueryBuilder('c')
.select('MAX(c.sort_order)', 'max')
.getRawOne<{ max: number | null }>();
const sortOrder = (max?.max ?? -1) + 1;
const saved = await this.categoryRepo.save(
this.categoryRepo.create({
label: dto.label.trim(),
color: dto.color,
sortOrder,
}),
);
return this.toCategoryResponse(saved);
}
async updateCategory(
id: string,
dto: Partial<CreateScheduleCategoryDto>,
): Promise<ScheduleCategoryResponse> {
const cat = await this.categoryRepo.findOne({ where: { id } });
if (!cat) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
if (dto.label !== undefined) cat.label = dto.label.trim();
if (dto.color !== undefined) cat.color = dto.color;
const saved = await this.categoryRepo.save(cat);
return this.toCategoryResponse(saved);
}
// 카테고리 삭제 — 포함된 일정도 FK CASCADE 로 함께 삭제된다.
async deleteCategory(id: string): Promise<void> {
const res = await this.categoryRepo.delete({ id });
if (!res.affected)
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
}
// ----- 일정 -----
// 주간(또는 임의 기간) 조회 — [start, end] 와 겹치는 일정 전부.
async listEvents(
start: string,
end: string,
): Promise<ScheduleEventResponse[]> {
const rows = await this.eventRepo.find({
where: {
startDate: LessThanOrEqual(end),
endDate: MoreThanOrEqual(start),
},
relations: ['attendees', 'createdBy'],
order: { startDate: 'ASC', time: 'ASC' },
});
return rows.map((e) => this.toEventResponse(e));
}
async createEvent(
dto: CreateScheduleEventDto,
actorId: string,
): Promise<ScheduleEventResponse> {
const category = await this.categoryRepo.findOne({
where: { id: dto.categoryId },
});
if (!category) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
const event = this.eventRepo.create({
category: { id: category.id } as ScheduleCategory,
title: dto.title.trim(),
startDate: dto.start,
endDate: dto.end,
time: dto.time?.trim() ?? '',
place: dto.place?.trim() || null,
description: dto.description?.trim() || null,
allHands: dto.allHands ?? false,
attendees: this.attendeeRefs(dto.allHands, dto.attendeeIds),
createdBy: { id: actorId } as User,
});
const saved = await this.eventRepo.save(event);
return this.findEventOrThrow(saved.id);
}
async updateEvent(
id: string,
dto: Partial<CreateScheduleEventDto>,
): Promise<ScheduleEventResponse> {
const event = await this.eventRepo.findOne({
where: { id },
relations: ['attendees'],
});
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
if (dto.categoryId !== undefined) {
const category = await this.categoryRepo.findOne({
where: { id: dto.categoryId },
});
if (!category)
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
event.category = { id: category.id } as ScheduleCategory;
}
if (dto.title !== undefined) event.title = dto.title.trim();
if (dto.start !== undefined) event.startDate = dto.start;
if (dto.end !== undefined) event.endDate = dto.end;
if (dto.time !== undefined) event.time = dto.time.trim();
if (dto.place !== undefined) event.place = dto.place.trim() || null;
if (dto.description !== undefined)
event.description = dto.description.trim() || null;
if (dto.allHands !== undefined) event.allHands = dto.allHands;
// 참석자/전직원 변경이 있으면 재매핑
if (dto.allHands !== undefined || dto.attendeeIds !== undefined) {
const allHands = dto.allHands ?? event.allHands;
event.attendees = this.attendeeRefs(allHands, dto.attendeeIds);
}
await this.eventRepo.save(event);
return this.findEventOrThrow(id);
}
async deleteEvent(id: string): Promise<void> {
const res = await this.eventRepo.delete({ id });
if (!res.affected) throw new NotFoundException('일정을 찾을 수 없습니다.');
}
// 참석자 선택 풀(전사 사용자) — 일정 작성 모달의 참석자 선택용.
async listPeople(): Promise<ScheduleAttendee[]> {
const users = await this.userService.search('', 500);
return users.map((u) => ({
id: u.id,
name: u.name,
avatarUrl: u.avatarUrl ?? null,
}));
}
// ----- 내부 헬퍼 -----
// 전 직원 일정이면 개별 참석자는 비운다. 아니면 id 참조 배열로 매핑.
private attendeeRefs(allHands?: boolean, ids?: string[]): User[] {
if (allHands) return [];
return [...new Set(ids ?? [])].map((id) => ({ id }) as User);
}
private async findEventOrThrow(id: string): Promise<ScheduleEventResponse> {
const e = await this.eventRepo.findOne({
where: { id },
relations: ['attendees', 'createdBy'],
});
if (!e) throw new NotFoundException('일정을 찾을 수 없습니다.');
return this.toEventResponse(e);
}
private toCategoryResponse(c: ScheduleCategory): ScheduleCategoryResponse {
return {
id: c.id,
label: c.label,
color: c.color,
sortOrder: c.sortOrder,
};
}
private toEventResponse(e: ScheduleEvent): ScheduleEventResponse {
return {
id: e.id,
categoryId: e.category?.id ?? '',
title: e.title,
start: e.startDate,
end: e.endDate,
time: e.time ?? '',
place: e.place ?? null,
description: e.description ?? null,
allHands: e.allHands,
attendees: (e.attendees ?? []).map((u) => ({
id: u.id,
name: u.name,
avatarUrl: u.avatarUrl ?? null,
})),
createdById: e.createdBy?.id ?? null,
};
}
}
+98
View File
@@ -569,6 +569,90 @@ body {
color: var(--text-3);
}
/* ============================================================
* 폼 필드 (폼 모달 공통 — BaseModal 본문 등에서 사용)
* ============================================================ */
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-size: 0.781rem;
font-weight: 600;
color: var(--text-2);
}
.form-row {
display: flex;
gap: 0.75rem;
}
.form-row .form-field {
flex: 1;
min-width: 0;
}
.form-input {
width: 100%;
height: 2.5rem;
padding: 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
}
.form-input::placeholder {
color: var(--text-3);
}
.form-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.form-textarea {
height: auto;
min-height: 5.75rem;
padding: 0.625rem 0.75rem;
line-height: 1.5;
resize: vertical;
}
/* 네이티브 select 래퍼 + 셰브론 */
.form-select-wrap {
position: relative;
}
.form-select {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 2.5rem;
padding: 0 2.125rem 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
background: #fff;
cursor: pointer;
}
.form-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.form-select-chev {
position: absolute;
right: 0.625rem;
top: 50%;
transform: translateY(-50%);
display: grid;
color: var(--text-3);
pointer-events: none;
}
.form-select-chev svg {
width: 0.9375rem;
height: 0.9375rem;
}
/* ============================================================
* 더보기 버튼 (목록 페이지네이션 공통)
* ============================================================ */
@@ -593,3 +677,17 @@ body {
opacity: 0.6;
cursor: default;
}
/* ============================================================
* 빈/상태 메시지 박스 (목록 로딩·빈 상태 공통)
* ============================================================ */
.empty-state {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 2.5rem 1.25rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
}
+8 -57
View File
@@ -1,7 +1,8 @@
<script setup lang="ts" generic="T extends string">
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
interface Option {
value: T
@@ -23,75 +24,25 @@ const props = withDefaults(
)
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
// 메뉴 fixed 위치(트리거 기준 계산)
const menuStyle = ref<Record<string, string>>({})
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
align: props.align,
matchWidth: true,
})
// 현재 선택된 옵션 라벨(트리거에 표시)
const selectedLabel = computed(
() => props.options.find((o) => o.value === props.modelValue)?.label ?? '',
)
// 트리거 위치 기준으로 메뉴 좌표 계산(뷰포트 fixed)
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
const style: Record<string, string> = {
top: `${r.bottom + 6}px`,
minWidth: `${r.width}px`,
}
if (props.align === 'right') {
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
} else {
style.left = `${r.left}px`
}
menuStyle.value = style
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function select(value: T): void {
emit('update:modelValue', value)
open.value = false
close()
}
// 바깥 클릭(트리거/메뉴 외부) / Esc 로 닫기
function onDocClick(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (root.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onKeydown(e: KeyboardEvent): void {
if (open.value && e.key === 'Escape') open.value = false
}
// 스크롤/리사이즈 시 위치가 어긋나므로 닫는다(간단·안전)
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDocClick)
document.addEventListener('keydown', onKeydown)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDocClick)
document.removeEventListener('keydown', onKeydown)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
})
</script>
<template>
<div
ref="root"
class="dd"
:class="{ push: pushRight, block }"
>
+12 -44
View File
@@ -1,60 +1,28 @@
<script setup lang="ts">
// 진행 상태(대기/진행 중/완료) 선택 드롭다운 — 상태색 테마. 메뉴는 body Teleport(스크롤 클리핑 방지).
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed } from 'vue'
import { useDropdownMenu } from '@/composables/useDropdownMenu'
import {
WORK_STATUS_META as META,
WORK_STATUS_ORDER as ORDER,
} from '@/shared/constants/workStatus'
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
const props = defineProps<{ modelValue: ChecklistWorkStatus }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: ChecklistWorkStatus): void }>()
const META: Record<ChecklistWorkStatus, { label: string; color: string; weak: string }> = {
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
}
const ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
const cur = computed(() => META[props.modelValue])
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
const menuStyle = ref<Record<string, string>>({})
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅(우측 정렬, 너비는 CSS 고정)
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
align: 'right',
gap: 5,
})
function positionMenu(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
menuStyle.value = {
top: `${r.bottom + 5}px`,
right: `${Math.max(0, window.innerWidth - r.right)}px`,
}
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(positionMenu)
}
function pick(v: ChecklistWorkStatus): void {
emit('update:modelValue', v)
open.value = false
close()
}
function onDoc(e: MouseEvent): void {
if (!open.value) return
const t = e.target as Node
if (triggerRef.value?.contains(t) || menuRef.value?.contains(t)) return
open.value = false
}
function onReposition(): void {
if (open.value) open.value = false
}
onMounted(() => {
document.addEventListener('click', onDoc)
window.addEventListener('scroll', onReposition, true)
window.addEventListener('resize', onReposition)
})
onBeforeUnmount(() => {
document.removeEventListener('click', onDoc)
window.removeEventListener('scroll', onReposition, true)
window.removeEventListener('resize', onReposition)
})
</script>
<template>
@@ -0,0 +1,190 @@
<script setup lang="ts">
// 공용 폼 모달 셸 — backdrop + 헤더(제목/닫기) + 본문 슬롯 + 푸터 슬롯.
// Teleport(body) · ESC · 바깥(backdrop) 클릭 닫기 내장.
// 전역 .modal*(확인 다이얼로그 전용)과 충돌하지 않도록 bm- 접두 클래스를 쓴다.
// 폼 필드는 전역 .form-* 클래스(relay.css)를 본문 슬롯에서 사용한다.
import { onBeforeUnmount, onMounted } from 'vue'
withDefaults(
defineProps<{
title: string
// 너비: md(기본 33.75rem) | sm(27.5rem)
size?: 'md' | 'sm'
}>(),
{ size: 'md' },
)
const emit = defineEmits<{ (e: 'close'): void }>()
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<Teleport to="body">
<div
class="bm-back"
@mousedown.self="emit('close')"
>
<div
class="bm"
:class="{ 'bm-sm': size === 'sm' }"
role="dialog"
aria-modal="true"
>
<div class="bm-head">
<div class="bm-head-text">
<h3>{{ title }}</h3>
<div
v-if="$slots.subtitle"
class="bm-sub"
>
<slot name="subtitle" />
</div>
</div>
<button
type="button"
class="bm-x"
aria-label="닫기"
@click="emit('close')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<div class="bm-body">
<slot />
</div>
<div
v-if="$slots.foot"
class="bm-foot"
>
<slot name="foot" />
</div>
</div>
</div>
</Teleport>
</template>
<style scoped>
.bm-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.bm {
width: 100%;
max-width: 33.75rem;
max-height: 90vh;
overflow-y: auto;
background: #fff;
border-radius: 0.875rem;
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
display: flex;
flex-direction: column;
}
.bm.bm-sm {
max-width: 27.5rem;
}
.bm-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding: 1.375rem 1.5rem 1rem;
position: sticky;
top: 0;
background: #fff;
border-radius: 0.875rem 0.875rem 0 0;
z-index: 1;
}
.bm-head h3 {
font-size: 1.0625rem;
font-weight: 700;
letter-spacing: -0.01875rem;
}
.bm-sub {
font-size: 0.8125rem;
color: var(--text-2);
margin-top: 0.25rem;
}
.bm-sub :deep(b) {
color: var(--text-2);
font-weight: 600;
}
.bm-x {
flex-shrink: 0;
}
.bm-x {
width: 2rem;
height: 2rem;
border: none;
background: transparent;
color: var(--text-3);
border-radius: var(--radius-sm);
cursor: pointer;
display: grid;
place-items: center;
margin-right: -0.3125rem;
}
.bm-x:hover {
background: #f1f2f4;
color: var(--text);
}
.bm-x svg {
width: 1.125rem;
height: 1.125rem;
}
.bm-body {
padding: 0.5rem 1.5rem 0.75rem;
display: flex;
flex-direction: column;
gap: 1.125rem;
text-align: left;
}
.bm-foot {
display: flex;
justify-content: flex-end;
gap: 0.5625rem;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
background: #fafbfc;
position: sticky;
bottom: 0;
border-radius: 0 0 0.875rem 0.875rem;
}
/* 푸터 버튼 — 전역 .mbtn(확인 다이얼로그용 flex:1) 대신 폼 모달용 우측정렬 컴팩트 */
.bm-foot :deep(.mbtn) {
flex: 0 0 auto;
height: 2.375rem;
padding: 0 1rem;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.bm-foot :deep(.mbtn svg) {
width: 0.9375rem;
height: 0.9375rem;
}
.bm-foot :deep(.mbtn:disabled) {
opacity: 0.45;
cursor: not-allowed;
}
.bm-foot :deep(.mbtn.primary:disabled),
.bm-foot :deep(.mbtn.primary:disabled:hover) {
background: var(--accent);
border-color: var(--accent);
}
</style>
@@ -0,0 +1,10 @@
<script setup lang="ts">
// 빈/상태 메시지 박스 공용 컴포넌트 — 목록 로딩·빈 상태 등에 사용.
// 전역 .empty-state 카드 스타일을 쓰고, 메시지는 기본 슬롯으로 전달한다.
</script>
<template>
<div class="empty-state">
<slot />
</div>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
// 목록 페이지네이션 '더보기' 버튼 공용 컴포넌트 — 전역 .load-more 스타일 사용.
// 부모는 보통 v-if="hasMore" 로 노출하고, @load 에서 다음 페이지를 불러온다.
defineProps<{ loading?: boolean }>()
const emit = defineEmits<{ (e: 'load'): void }>()
</script>
<template>
<button
class="load-more"
type="button"
:disabled="loading"
@click="emit('load')"
>
{{ loading ? '불러오는 중…' : '더보기' }}
</button>
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts" generic="T extends string">
// 세그먼트 토글(필터/탭) 공용 컴포넌트 — 전역 .segmented 스타일을 그대로 사용.
// options 의 count 가 있으면 라벨 뒤에 수량(.n)을 표기한다.
interface SegOption {
value: T
label: string
count?: number
}
defineProps<{ modelValue: T; options: SegOption[] }>()
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
</script>
<template>
<div class="segmented">
<button
v-for="o in options"
:key="o.value"
type="button"
:class="{ active: o.value === modelValue }"
@click="emit('update:modelValue', o.value)"
>
{{ o.label }}<span
v-if="o.count !== undefined"
class="n"
>{{ o.count }}</span>
</button>
</div>
</template>
@@ -0,0 +1,35 @@
<script setup lang="ts">
// 일정 참석자 아바타 — 사이트 공용 UserAvatar 를 size(px)로 감싸는 얇은 어댑터.
// 렌더링(이미지/실루엣)은 UserAvatar 가 단일 책임지고, 여기선 크기/원형 래퍼만 담당.
// (.sa 클래스는 상세 패널 아바타 스택 CSS 의 훅으로 유지)
import UserAvatar from '@/components/UserAvatar.vue'
withDefaults(
defineProps<{
name?: string
avatarUrl?: string | null
size?: number
}>(),
{ name: '', avatarUrl: null, size: 21 },
)
</script>
<template>
<span
class="sa"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<UserAvatar
:url="avatarUrl"
:name="name"
/>
</span>
</template>
<style scoped>
.sa {
display: block;
flex-shrink: 0;
border-radius: 50%;
}
</style>
@@ -0,0 +1,167 @@
<script setup lang="ts">
// 카테고리 생성/수정 모달 — 이름 + 색상(hue 슬라이더)
import { computed, ref } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import type {
ApiScheduleCategory,
ScheduleCategoryPayload,
} from '@/types/schedule'
const props = defineProps<{ init: ApiScheduleCategory | null }>()
const emit = defineEmits<{
(e: 'save', payload: ScheduleCategoryPayload): void
(e: 'close'): void
}>()
const CAT_SAT = 62
const CAT_LIG = 46
function hslToHex(h: number, s: number, l: number): string {
s /= 100
l /= 100
const k = (n: number) => (n + h / 30) % 12
const a = s * Math.min(l, 1 - l)
const f = (n: number) =>
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
const to = (x: number) =>
Math.round(255 * x)
.toString(16)
.padStart(2, '0')
return `#${to(f(0))}${to(f(8))}${to(f(4))}`
}
function hexToHue(hex: string): number {
const m = hex.replace('#', '')
const r = parseInt(m.slice(0, 2), 16) / 255
const g = parseInt(m.slice(2, 4), 16) / 255
const b = parseInt(m.slice(4, 6), 16) / 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
if (d) {
if (max === r) h = ((g - b) / d) % 6
else if (max === g) h = (b - r) / d + 2
else h = (r - g) / d + 4
h *= 60
if (h < 0) h += 360
}
return Math.round(h)
}
const isEdit = computed(() => !!props.init)
const name = ref(props.init?.label ?? '')
const hue = ref(props.init?.color ? hexToHue(props.init.color) : 210)
const color = computed(() => hslToHex(hue.value, CAT_SAT, CAT_LIG))
const valid = computed(() => !!name.value.trim())
function submit(): void {
if (!valid.value) return
emit('save', { label: name.value.trim(), color: color.value })
}
</script>
<template>
<BaseModal
:title="isEdit ? '카테고리 편집' : '새 카테고리'"
size="sm"
@close="emit('close')"
>
<label class="form-field"><span class="form-label">이름</span>
<input
v-model="name"
class="form-input"
placeholder="카테고리 이름"
autofocus
@keydown.enter="submit"
>
</label>
<div class="form-field">
<span class="form-label">색상</span>
<div class="cat-color">
<span
class="cat-dot"
:style="{ background: color }"
/>
<input
v-model.number="hue"
type="range"
min="0"
max="359"
class="hue-slider"
:style="{ '--cur': color }"
>
</div>
</div>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
.cat-color {
display: flex;
align-items: center;
gap: 0.75rem;
}
.cat-dot {
width: 1.5rem;
height: 1.5rem;
border-radius: var(--radius);
flex-shrink: 0;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.hue-slider {
-webkit-appearance: none;
appearance: none;
flex: 1;
height: 1rem;
margin: 0.375rem 0;
border-radius: 0.5rem;
cursor: pointer;
outline: none;
background: linear-gradient(
to right,
#f00 0%,
#ff0 17%,
#0f0 33%,
#0ff 50%,
#00f 67%,
#f0f 83%,
#f00 100%
);
}
.hue-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--cur);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgba(20, 24, 33, 0.4);
cursor: grab;
}
.hue-slider::-moz-range-thumb {
width: 1.5rem;
height: 1.5rem;
border-radius: 50%;
background: var(--cur);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgba(20, 24, 33, 0.4);
cursor: grab;
}
</style>
@@ -0,0 +1,486 @@
<script setup lang="ts">
// 우측 슬라이드 상세 패널 — 선택 일정의 시간/장소/참석자/메모 + 수정·삭제
import { computed, ref, watch } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import ScheduleAvatar from './ScheduleAvatar.vue'
import { eventDateText, spanDays } from '@/shared/utils/scheduleDate'
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
const props = defineProps<{
event: ApiScheduleEvent | null
category: ApiScheduleCategory | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'edit', ev: ApiScheduleEvent): void
(e: 'delete', ev: ApiScheduleEvent): void
}>()
// 닫히는 애니메이션 동안 직전 내용 유지
const shown = ref<ApiScheduleEvent | null>(props.event)
watch(
() => props.event,
(v) => {
if (v) shown.value = v
},
)
const open = computed(() => !!props.event)
const ev = computed(() => props.event ?? shown.value)
const multi = computed(() => (ev.value ? ev.value.start !== ev.value.end : false))
const dateText = computed(() =>
ev.value ? eventDateText(ev.value.start, ev.value.end) : '',
)
const dur = computed(() =>
ev.value && multi.value ? spanDays(ev.value.start, ev.value.end) : 0,
)
// 참석자 스택 토글
const attOpen = ref(false)
const attRef = ref<HTMLElement | null>(null)
useClickOutside(attOpen, () => (attOpen.value = false), [attRef], { esc: false })
const MAX = 6
const attendees = computed(() => ev.value?.attendees ?? [])
const shownAtt = computed(() =>
attendees.value.length > MAX
? attendees.value.slice(0, MAX - 1)
: attendees.value,
)
const extra = computed(() =>
attendees.value.length > MAX ? attendees.value.length - (MAX - 1) : 0,
)
</script>
<template>
<aside
class="detail"
:class="{ open }"
>
<template v-if="ev">
<div class="dt-head">
<div class="dt-top">
<span
v-if="category"
class="type-chip"
:style="{
background: `color-mix(in srgb, ${category.color} 13%, #fff)`,
color: category.color,
}"
>{{ category.label }}</span>
<span v-else />
<button
class="dt-close"
@click="emit('close')"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
><path d="M18 6 6 18M6 6l12 12" /></svg>
</button>
</div>
<h2>{{ ev.title }}</h2>
<div class="dt-date">
<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>
{{ dateText }}
<span
v-if="multi"
class="dt-dur"
>{{ dur }}일간</span>
</div>
</div>
<div class="dt-body">
<div class="dt-list">
<div class="dt-item">
<span class="dt-k">시간</span>
<span class="dt-v">{{ ev.time || '—' }}</span>
</div>
<div
v-if="ev.place && ev.place !== '—'"
class="dt-item"
>
<span class="dt-k">장소</span>
<span class="dt-v">{{ ev.place }}</span>
</div>
<div class="dt-item">
<span class="dt-k">참석자</span>
<span class="dt-v">
<template v-if="ev.allHands"> 직원 대상</template>
<div
v-else-if="attendees.length > 0"
ref="attRef"
class="att-stack"
>
<button
class="att-trigger"
:class="{ open: attOpen }"
@click="attOpen = !attOpen"
>
<span class="avstack">
<ScheduleAvatar
v-for="p in shownAtt"
:key="p.id"
:name="p.name"
:avatar-url="p.avatarUrl"
:size="30"
/>
<span
v-if="extra > 0"
class="av-more"
>+{{ extra }}</span>
</span>
<span class="att-chev">
<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>
<div
v-if="attOpen"
class="att-panel"
>
<div
v-for="p in attendees"
:key="p.id"
class="att-row"
>
<ScheduleAvatar
:name="p.name"
:avatar-url="p.avatarUrl"
:size="24"
/>{{ p.name }}
</div>
</div>
</div>
<template v-else>참석자 없음</template>
</span>
</div>
<div
v-if="ev.description"
class="dt-item"
>
<span class="dt-k">메모</span>
<span class="dt-v dt-v-memo">{{ ev.description }}</span>
</div>
</div>
</div>
<div class="dt-foot">
<div class="dt-actions">
<button
class="btn"
@click="emit('edit', ev)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>일정 수정
</button>
<button
class="btn danger icon-only"
title="삭제"
@click="emit('delete', ev)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
</button>
</div>
</div>
</template>
</aside>
</template>
<style scoped>
.detail {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 360px;
z-index: 30;
border-left: 1px solid var(--border);
background: var(--panel);
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: var(--shadow-md);
transform: translateX(100%);
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1);
}
.detail.open {
transform: translateX(0);
}
.type-chip {
font-size: 11px;
font-weight: 600;
padding: 2px 9px;
border-radius: 6px;
white-space: nowrap;
line-height: 1.6;
}
.dt-head {
padding: 20px 22px 18px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.dt-top {
display: flex;
align-items: center;
justify-content: space-between;
}
.dt-close {
width: 30px;
height: 30px;
border-radius: var(--radius-sm);
border: none;
background: transparent;
color: var(--text-3);
cursor: pointer;
display: grid;
place-items: center;
margin-right: -6px;
}
.dt-close:hover {
background: #f1f2f4;
color: var(--text);
}
.dt-close svg {
width: 17px;
height: 17px;
}
.dt-head h2 {
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
line-height: 1.4;
margin: 13px 0 8px;
word-break: keep-all;
}
.dt-date {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
color: var(--text-2);
}
.dt-date svg {
width: 15px;
height: 15px;
color: var(--text-3);
}
.dt-dur {
font-size: 11px;
font-weight: 700;
color: var(--accent);
background: var(--accent-weak);
border-radius: 5px;
padding: 1px 7px;
white-space: nowrap;
}
.dt-body {
padding: 18px 22px 24px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.dt-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.dt-item {
display: flex;
gap: 14px;
align-items: flex-start;
}
.dt-k {
font-size: 12px;
font-weight: 600;
color: var(--text-3);
width: 52px;
flex-shrink: 0;
padding-top: 2px;
}
.dt-v {
flex: 1;
min-width: 0;
font-size: 13.5px;
font-weight: 500;
color: var(--text);
word-break: keep-all;
}
.dt-v-memo {
font-weight: 400;
color: var(--text-2);
line-height: 1.7;
white-space: pre-wrap;
}
.att-stack {
position: relative;
margin: -3px 0;
}
.att-trigger {
display: inline-flex;
align-items: center;
gap: 9px;
border: none;
background: none;
cursor: pointer;
padding: 5px 8px 5px 5px;
margin: -5px -8px -5px -5px;
border-radius: 9px;
}
.att-trigger:hover,
.att-trigger.open {
background: #f1f2f4;
}
.avstack {
display: inline-flex;
align-items: center;
}
.avstack :deep(.sa),
.avstack .av-more {
margin-left: -9px;
box-shadow: 0 0 0 2px var(--panel);
}
.avstack :deep(.sa):first-child {
margin-left: 0;
}
.av-more {
width: 30px;
height: 30px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 12px;
font-weight: 700;
background: #eceef1;
color: var(--text-2);
flex-shrink: 0;
}
.att-chev {
color: var(--text-3);
display: grid;
transition: transform 0.15s ease;
}
.att-chev svg {
width: 15px;
height: 15px;
}
.att-trigger.open .att-chev {
transform: rotate(180deg);
}
.att-panel {
position: absolute;
top: calc(100% + 6px);
left: 0;
min-width: 200px;
max-width: 260px;
max-height: 240px;
overflow-y: auto;
z-index: 20;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-md);
padding: 5px;
}
.att-row {
display: flex;
align-items: center;
gap: 9px;
font-size: 13px;
font-weight: 500;
color: var(--text);
padding: 6px 8px;
border-radius: 6px;
white-space: nowrap;
}
.att-row:hover {
background: #f4f5f7;
}
.dt-foot {
flex-shrink: 0;
padding: 14px 22px;
border-top: 1px solid var(--border);
background: var(--panel);
}
.dt-actions {
display: flex;
gap: 8px;
}
.dt-actions .btn {
flex: 1;
justify-content: center;
height: 36px;
}
.dt-actions .btn.icon-only {
flex: 0 0 auto;
padding: 0;
width: 36px;
}
.btn {
border-radius: var(--radius);
font-size: 13.5px;
font-weight: 600;
border: 1px solid var(--border-strong);
background: var(--panel);
color: var(--text-2);
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 6px;
line-height: 1;
white-space: nowrap;
}
.btn:hover {
background: #f7f8fa;
color: var(--text);
}
.btn svg {
width: 15px;
height: 15px;
}
.btn.danger {
background: #fff;
border-color: #f1c9c9;
color: #dc2626;
}
.btn.danger:hover {
background: #fdeaea;
border-color: #ec9d9d;
color: #b91c1c;
}
</style>
@@ -0,0 +1,444 @@
<script setup lang="ts">
// 일정 생성/수정 모달
import { computed, ref, watch } from 'vue'
import BaseModal from '@/components/common/BaseModal.vue'
import { useClickOutside } from '@/composables/useClickOutside'
import ScheduleAvatar from './ScheduleAvatar.vue'
import type {
ApiScheduleCategory,
ApiScheduleEvent,
ApiSchedulePerson,
ScheduleEventPayload,
} from '@/types/schedule'
const props = defineProps<{
categories: ApiScheduleCategory[]
people: ApiSchedulePerson[]
init: ApiScheduleEvent | null
defaultDate: string
}>()
const emit = defineEmits<{
(e: 'save', payload: ScheduleEventPayload): void
(e: 'close'): void
}>()
const isEdit = computed(() => !!props.init)
// init.time → 시작/종료 HH:MM 파싱
function parseTime(tm: string): { start: string; end: string } {
if (/\d/.test(tm) && (tm.includes('') || tm.includes('-'))) {
const parts = tm.split(/[-]/).map((s) => s.trim())
const a = parts[0] ?? ''
const b = parts[1] ?? ''
return {
start: /^\d{1,2}:\d{2}/.test(a) ? a : '09:00',
end: /^\d{1,2}:\d{2}/.test(b) ? b : '',
}
}
if (/^\d{1,2}:\d{2}/.test(tm)) return { start: tm.trim(), end: '' }
return { start: '09:00', end: '10:00' }
}
const i = props.init
const pt = parseTime(i?.time ?? '')
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
)
const title = ref(i?.title ?? '')
const categoryId = ref(i?.categoryId ?? ordered.value[0]?.id ?? '')
const s = ref(i?.start ?? props.defaultDate)
const e = ref(i?.end ?? i?.start ?? props.defaultDate)
const startT = ref(pt.start)
const endT = ref(pt.end)
const place = ref(i?.place && i.place !== '—' ? i.place : '')
const desc = ref(i?.description ?? '')
const allHands = ref(i?.allHands ?? false)
const attendeeIds = ref<string[]>(i?.attendees.map((a) => a.id) ?? [])
// 시작일 변경 시 종료일이 더 빠르면 맞춰줌
watch(s, (v) => {
if (e.value < v) e.value = v
})
const valid = computed(
() =>
!!title.value.trim() &&
!!categoryId.value &&
!!s.value &&
!!e.value &&
e.value >= s.value &&
!!startT.value,
)
// 참석자 멀티셀렉트
const msOpen = ref(false)
const msRef = ref<HTMLElement | null>(null)
const msLabel = computed(() => {
if (allHands.value) return '전 직원'
if (attendeeIds.value.length === 0) return '참석자 선택'
return props.people
.filter((p) => attendeeIds.value.includes(p.id))
.map((p) => p.name)
.join(', ')
})
function toggleAll(): void {
allHands.value = !allHands.value
if (allHands.value) attendeeIds.value = []
}
function togglePerson(id: string): void {
allHands.value = false
attendeeIds.value = attendeeIds.value.includes(id)
? attendeeIds.value.filter((x) => x !== id)
: [...attendeeIds.value, id]
}
// 참석자 멀티셀렉트 바깥클릭 닫기(ESC 는 모달 닫기에서 처리)
useClickOutside(msOpen, () => (msOpen.value = false), [msRef], { esc: false })
function submit(): void {
if (!valid.value) return
const time = endT.value ? `${startT.value} ${endT.value}` : startT.value
emit('save', {
title: title.value.trim(),
categoryId: categoryId.value,
start: s.value,
end: e.value,
time,
place: place.value.trim(),
description: desc.value.trim(),
allHands: allHands.value,
attendeeIds: allHands.value ? [] : attendeeIds.value,
})
}
</script>
<template>
<BaseModal
:title="isEdit ? '일정 수정' : '새 일정'"
@close="emit('close')"
>
<label class="form-field"><span class="form-label">제목</span>
<input
v-model="title"
class="form-input"
placeholder="일정 제목"
autofocus
>
</label>
<label class="form-field"><span class="form-label">유형</span>
<div class="form-select-wrap">
<select
v-model="categoryId"
class="form-select"
>
<option
v-for="c in ordered"
:key="c.id"
:value="c.id"
>
{{ c.label }}
</option>
</select>
<span class="form-select-chev">
<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>
</label>
<div class="form-row">
<label class="form-field"><span class="form-label">시작일</span>
<input
v-model="s"
type="date"
class="form-input"
>
</label>
<label class="form-field"><span class="form-label">종료일</span>
<input
v-model="e"
type="date"
class="form-input"
:min="s"
>
</label>
</div>
<div class="form-field">
<span class="form-label">시간</span>
<div class="time-row">
<input
v-model="startT"
type="time"
class="form-input time-input"
>
<span class="time-dash"></span>
<input
v-model="endT"
type="time"
class="form-input time-input"
>
</div>
</div>
<label class="form-field"><span class="form-label">장소</span>
<input
v-model="place"
class="form-input"
placeholder="장소 또는 화상"
>
</label>
<div class="form-field">
<span class="form-label">참석자</span>
<div
ref="msRef"
class="ms"
>
<button
type="button"
class="ms-field"
:class="{ empty: !allHands && attendeeIds.length === 0, open: msOpen }"
@click="msOpen = !msOpen"
>
<span class="ms-val">{{ msLabel }}</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="m6 9 6 6 6-6" /></svg>
</span>
</button>
<div
v-if="msOpen"
class="ms-panel"
>
<button
type="button"
class="ms-item"
:class="{ on: allHands }"
@click="toggleAll"
>
<span class="ms-all"></span>
<span class="ms-name"> 직원</span>
<span
v-if="allHands"
class="ms-check"
>
<svg
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>
</span>
</button>
<div class="ms-div" />
<button
v-for="p in people"
:key="p.id"
type="button"
class="ms-item"
:class="{ on: attendeeIds.includes(p.id) }"
@click="togglePerson(p.id)"
>
<ScheduleAvatar
:name="p.name"
:avatar-url="p.avatarUrl"
:size="20"
/>
<span class="ms-name">{{ p.name }}</span>
<span
v-if="attendeeIds.includes(p.id)"
class="ms-check"
>
<svg
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>
</span>
</button>
<div
v-if="people.length === 0"
class="ms-empty"
>
등록된 사용자가 없습니다
</div>
</div>
</div>
</div>
<label class="form-field"><span class="form-label">설명</span>
<textarea
v-model="desc"
class="form-input form-textarea"
placeholder="메모"
rows="3"
/>
</label>
<template #foot>
<button
class="mbtn"
@click="emit('close')"
>
취소
</button>
<button
class="mbtn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</template>
</BaseModal>
</template>
<style scoped>
/* 시간 입력 행 */
.time-row {
display: flex;
align-items: center;
gap: 0.625rem;
}
.time-input {
width: auto;
flex: 1;
min-width: 0;
}
.time-dash {
color: var(--text-3);
}
/* 참석자 멀티셀렉트 */
.ms {
position: relative;
}
.ms-field {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
height: 2.5rem;
padding: 0 0.625rem 0 0.75rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
font-family: inherit;
font-size: 0.844rem;
color: var(--text);
cursor: pointer;
text-align: left;
}
.ms-field.empty .ms-val {
color: var(--text-3);
}
.ms-field.open {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.ms-field .ms-val {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-field .chev {
display: grid;
color: var(--text-3);
}
.ms-field .chev svg {
width: 0.9375rem;
height: 0.9375rem;
}
.ms-panel {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
z-index: 10;
max-height: 13.75rem;
overflow-y: auto;
background: #fff;
border: 1px solid var(--border);
border-radius: 0.5625rem;
box-shadow: var(--shadow-pop);
padding: 0.375rem;
}
.ms-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
border: none;
background: none;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-2);
padding: 0.4375rem 0.5rem;
border-radius: var(--radius-sm);
cursor: pointer;
text-align: left;
}
.ms-item:hover {
background: #f5f6f8;
}
.ms-item.on {
color: var(--text);
}
.ms-item .ms-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ms-item .ms-check {
flex-shrink: 0;
color: var(--accent);
display: grid;
place-items: center;
}
.ms-item .ms-check svg {
width: 0.9375rem;
height: 0.9375rem;
}
.ms-all {
/* 참석자 아바타(ScheduleAvatar :size="20" = 20px)와 동일 크기 */
width: 20px;
height: 20px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 9px;
font-weight: 700;
color: #fff;
background: #9298a3;
flex-shrink: 0;
}
.ms-div {
height: 1px;
background: var(--border);
margin: 0.25rem 0.125rem;
}
.ms-empty {
padding: 0.75rem 0.5rem;
font-size: 0.781rem;
color: var(--text-3);
text-align: center;
}
</style>
@@ -0,0 +1,422 @@
<script setup lang="ts">
// 주간 타임라인(간트형) — 카테고리=레인, 일정=막대(다중일 span). 가로 스크롤 + 라벨 고정.
import { computed, ref } from 'vue'
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
import type { WeekDay } from '@/shared/utils/scheduleDate'
const props = defineProps<{
categories: ApiScheduleCategory[]
events: ApiScheduleEvent[]
days: WeekDay[]
activeTypeIds: Set<string>
selId: string | null
}>()
const emit = defineEmits<{
(e: 'select', id: string): void
(e: 'add-category'): void
}>()
const COLW = 156
const LABELW = 132
const ROWH = 32
const cols = computed(() => props.days.length)
const colTpl = computed(
() => `${LABELW}px repeat(${cols.value}, minmax(${COLW}px, 1fr))`,
)
const trackCols = computed(
() => `repeat(${cols.value}, minmax(${COLW}px, 1fr))`,
)
const minW = computed(() => LABELW + cols.value * COLW)
// 활성 + 정렬된 레인
const lanes = computed(() =>
[...props.categories]
.filter((c) => props.activeTypeIds.has(c.id))
.sort((a, b) => a.sortOrder - b.sortOrder),
)
interface Cov {
ev: ApiScheduleEvent
startIdx: number
endIdx: number
span: number
row: number
}
// 이벤트가 이번 주 days 안에서 차지하는 시작/끝 인덱스
function coverage(ev: ApiScheduleEvent): Omit<Cov, 'row'> | null {
let startIdx = -1
let endIdx = -1
props.days.forEach((d, i) => {
if (d.iso >= ev.start && d.iso <= ev.end) {
if (startIdx < 0) startIdx = i
endIdx = i
}
})
if (startIdx < 0) return null
return { ev, startIdx, endIdx, span: endIdx - startIdx + 1 }
}
// 겹침 회피 그리디 행 배치
function packLane(catId: string): { items: Cov[]; rows: number } {
const cov = props.events
.filter((e) => e.categoryId === catId)
.map(coverage)
.filter((c): c is Omit<Cov, 'row'> => c !== null)
.sort(
(a, b) =>
a.startIdx - b.startIdx || String(a.ev.time).localeCompare(String(b.ev.time)),
)
const rowEnd: number[] = []
const items: Cov[] = cov.map((it) => {
let r = 0
while (r < rowEnd.length && (rowEnd[r] ?? -1) >= it.startIdx) r++
rowEnd[r] = it.endIdx
return { ...it, row: r }
})
return { items, rows: Math.max(rowEnd.length, 1) }
}
const lanesPacked = computed(() =>
lanes.value.map((c) => ({ cat: c, ...packLane(c.id) })),
)
// 카테고리 색 → 막대 음영(약)
function weakOf(color: string): string {
return `color-mix(in srgb, ${color} 13%, #fff)`
}
// 헤더/본문 가로 스크롤 동기화
const headRef = ref<HTMLElement | null>(null)
const bodyRef = ref<HTMLElement | null>(null)
function onBodyScroll(): void {
if (headRef.value && bodyRef.value)
headRef.value.scrollLeft = bodyRef.value.scrollLeft
}
</script>
<template>
<div class="tl">
<!-- 요일 헤더 -->
<div
ref="headRef"
class="tl-head-scroll"
>
<div
class="tl-headrow"
:style="{ gridTemplateColumns: colTpl, minWidth: `${minW}px` }"
>
<div class="tl-corner" />
<div
v-for="d in days"
:key="d.iso"
class="tl-dayhead"
:class="{ 'is-today': d.isToday, weekend: d.weekend }"
>
<span class="sdow">{{ d.dow }}</span>
<span class="sd">{{ d.d }}</span>
</div>
</div>
</div>
<!-- 본문 -->
<div
ref="bodyRef"
class="tl-body-scroll"
@scroll="onBodyScroll"
>
<div
class="tl-body"
:style="{ minWidth: `${minW}px` }"
>
<div
v-if="lanesPacked.length === 0"
class="tl-empty"
>
표시할 카테고리가 없습니다. 유형 필터에서 켜거나 카테고리를 추가하세요.
</div>
<div
v-for="lane in lanesPacked"
:key="lane.cat.id"
class="tl-row"
:style="{ gridTemplateColumns: `${LABELW}px 1fr` }"
>
<!-- 레인 라벨 -->
<div class="tl-label">
<span
class="dot"
:style="{ background: lane.cat.color }"
/>
<span class="ll-name">{{ lane.cat.label }}</span>
</div>
<!-- 트랙 -->
<div class="tl-track">
<div
class="tl-guides"
:style="{ gridTemplateColumns: trackCols }"
>
<div
v-for="d in days"
:key="d.iso"
class="g"
:class="{ 'is-today': d.isToday, weekend: d.weekend }"
/>
</div>
<div
class="tl-bars"
:style="{
gridTemplateColumns: trackCols,
gridTemplateRows: `repeat(${lane.rows}, ${ROWH}px)`,
}"
>
<div
v-for="it in lane.items"
:key="it.ev.id"
class="tl-bar"
:class="{ sel: it.ev.id === selId }"
:style="{
'--bar-color': lane.cat.color,
'--bar-weak': weakOf(lane.cat.color),
gridColumn: `${it.startIdx + 1} / span ${it.span}`,
gridRow: it.row + 1,
}"
@click="emit('select', it.ev.id)"
>
<span class="bt">{{ it.ev.title }}</span>
</div>
</div>
</div>
</div>
<!-- 카테고리 추가 -->
<div class="tl-addrow">
<button
class="tl-addcat"
@click="emit('add-category')"
>
<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>카테고리 추가
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.tl {
background: var(--panel);
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-sm);
}
.tl-head-scroll {
flex-shrink: 0;
overflow: hidden;
}
.tl-body-scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.tl-body {
display: flex;
flex-direction: column;
}
.tl-headrow {
display: grid;
background: var(--panel);
border-bottom: 1px solid var(--border-strong);
}
.tl-corner {
position: sticky;
left: 0;
z-index: 6;
background: var(--panel);
border-right: 1px solid var(--border);
}
.tl-dayhead {
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 9px 8px;
text-align: center;
border-left: 1px solid var(--border);
}
.tl-dayhead .sdow {
font-size: 12px;
font-weight: 600;
color: var(--text-3);
letter-spacing: 0.2px;
}
.tl-dayhead .sd {
display: inline-grid;
place-items: center;
min-width: 26px;
height: 26px;
padding: 0 5px;
font-size: 15px;
font-weight: 700;
letter-spacing: -0.3px;
border-radius: 13px;
}
.tl-dayhead.is-today .sdow {
color: var(--accent);
}
.tl-dayhead.is-today .sd {
background: var(--accent);
color: #fff;
}
.tl-dayhead.weekend {
background: #fafbfc;
}
.tl-dayhead.weekend .sdow,
.tl-dayhead.weekend .sd {
color: var(--text-3);
}
.tl-row {
display: grid;
border-top: 1px solid var(--border);
}
.tl-row:first-of-type {
border-top: none;
}
.tl-label {
display: flex;
align-items: center;
gap: 9px;
padding: 0 8px 0 14px;
font-size: 13.5px;
font-weight: 600;
color: var(--text);
position: sticky;
left: 0;
z-index: 3;
background: var(--panel);
border-right: 1px solid var(--border);
white-space: nowrap;
}
.tl-label .dot {
width: 11px;
height: 11px;
border-radius: 3px;
flex-shrink: 0;
}
.tl-label .ll-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tl-addrow {
border-top: 1px solid var(--border);
}
.tl-addcat {
position: sticky;
left: 0;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 11px 14px;
background: var(--panel);
border: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--text-3);
cursor: pointer;
}
.tl-addcat:hover {
color: var(--accent);
}
.tl-addcat svg {
width: 15px;
height: 15px;
}
.tl-track {
position: relative;
min-width: 0;
}
.tl-guides {
position: absolute;
inset: 0;
display: grid;
}
.tl-guides .g {
border-left: 1px solid var(--border);
}
.tl-guides .g.is-today {
background: color-mix(in srgb, var(--accent) 5%, transparent);
}
.tl-guides .g.weekend {
background: #fafbfc;
}
.tl-bars {
position: relative;
display: grid;
row-gap: 7px;
column-gap: 0;
padding: 9px 0;
}
.tl-bar {
display: flex;
flex-direction: column;
justify-content: center;
gap: 1px;
margin: 0 6px;
padding: 0 11px;
height: 100%;
border-radius: 7px;
background: var(--bar-weak);
border-left: 3px solid var(--bar-color);
cursor: pointer;
overflow: hidden;
transition:
box-shadow 0.12s ease,
transform 0.12s ease;
}
.tl-bar:hover {
box-shadow: 0 3px 10px rgba(20, 24, 33, 0.13);
transform: translateY(-1px);
}
.tl-bar .bt {
font-size: 12.5px;
font-weight: 600;
color: var(--text);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tl-bar.sel {
box-shadow: 0 0 0 2px var(--accent);
}
.tl-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 40px;
color: var(--text-3);
font-size: 13.5px;
text-align: center;
}
</style>
@@ -0,0 +1,364 @@
<script setup lang="ts">
// 유형 필터 + 카테고리 관리 드롭다운
import { computed, ref } from 'vue'
import { useClickOutside } from '@/composables/useClickOutside'
import type { ApiScheduleCategory } from '@/types/schedule'
const props = defineProps<{
categories: ApiScheduleCategory[]
counts: Record<string, number>
activeTypeIds: Set<string>
}>()
const emit = defineEmits<{
(e: 'toggle', id: string): void
(e: 'set-all', on: boolean): void
(e: 'add'): void
(e: 'edit', id: string): void
(e: 'delete', id: string): void
}>()
const open = ref(false)
const rootRef = ref<HTMLElement | null>(null)
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
)
const allOn = computed(
() =>
ordered.value.length > 0 &&
ordered.value.every((c) => props.activeTypeIds.has(c.id)),
)
</script>
<template>
<div
ref="rootRef"
class="filter"
>
<button
type="button"
class="filter-btn"
:class="{ open }"
@click="open = !open"
>
<span class="fic">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" /></svg>
</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="m6 9 6 6 6-6" /></svg>
</span>
</button>
<div
v-if="open"
class="filter-menu"
>
<div class="fm-head">
<span>유형 · 카테고리</span>
<button
class="fm-all"
@click="emit('set-all', !allOn)"
>
{{ allOn ? '전체 해제' : '전체 선택' }}
</button>
</div>
<div class="fm-list">
<div
v-for="c in ordered"
:key="c.id"
class="fm-item"
:class="{ on: activeTypeIds.has(c.id) }"
>
<button
class="fm-tog"
@click="emit('toggle', c.id)"
>
<span class="fm-check">
<svg
v-if="activeTypeIds.has(c.id)"
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>
</span>
<span
class="dot"
:style="{ background: c.color }"
/>
<span class="fm-label">{{ c.label }}</span>
<span class="fm-ct">{{ counts[c.id] || 0 }}</span>
</button>
<span class="fm-acts">
<button
class="fm-act"
title="편집"
@click="open = false; emit('edit', c.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
</button>
<button
class="fm-act"
title="삭제"
@click="open = false; emit('delete', c.id)"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
</button>
</span>
</div>
</div>
<button
class="fm-add"
@click="open = false; emit('add')"
>
<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> 카테고리
</button>
</div>
</div>
</template>
<style scoped>
.filter {
position: relative;
}
/* 트리거 — 기존 정렬/필터 드롭다운(DropdownSelect)과 동일한 pill 외형 */
.filter-btn {
display: inline-flex;
align-items: center;
gap: 0.4375rem;
height: 2.25rem;
padding: 0 0.625rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius);
background: #fff;
font-family: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-2);
cursor: pointer;
white-space: nowrap;
line-height: 1;
}
.filter-btn:hover {
background: #f9fafb;
}
.filter-btn.open {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.filter-btn .fic {
display: inline-flex;
flex-shrink: 0;
color: var(--text-3);
}
.filter-btn .fic svg {
width: 0.9375rem;
height: 0.9375rem;
}
.filter-btn .chev {
display: inline-flex;
flex-shrink: 0;
color: var(--text-3);
transition: transform 0.15s ease;
}
.filter-btn .chev svg {
width: 0.875rem;
height: 0.875rem;
}
.filter-btn.open .chev {
transform: rotate(180deg);
}
.filter-menu {
position: absolute;
top: calc(100% + 7px);
right: 0;
z-index: 60;
width: 264px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
box-shadow: var(--shadow-pop);
padding: 6px;
}
.fm-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 7px 8px 9px;
}
.fm-head > span {
font-size: 11px;
font-weight: 700;
color: var(--text-3);
letter-spacing: 0.3px;
white-space: nowrap;
}
.fm-all {
border: none;
background: none;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--accent);
cursor: pointer;
padding: 2px 5px;
border-radius: 4px;
white-space: nowrap;
}
.fm-all:hover {
background: var(--accent-weak);
}
.fm-list {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
}
.fm-item {
display: flex;
align-items: center;
border-radius: 6px;
}
.fm-item:hover {
background: #f4f5f7;
}
.fm-tog {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 9px;
border: none;
background: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
padding: 8px;
cursor: pointer;
text-align: left;
}
.fm-tog .dot {
width: 10px;
height: 10px;
border-radius: 3px;
flex-shrink: 0;
}
.fm-tog .fm-label {
flex: 1;
min-width: 0;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fm-tog .fm-ct {
font-size: 11px;
font-weight: 700;
color: var(--text-3);
}
.fm-tog .fm-check {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--accent);
display: grid;
place-items: center;
}
.fm-tog .fm-check svg {
width: 15px;
height: 15px;
}
.fm-item:not(.on) .fm-label {
color: var(--text-3);
}
.fm-item:not(.on) .dot {
background: #d7dae0 !important;
}
.fm-acts {
display: none;
gap: 1px;
padding-right: 5px;
}
.fm-item:hover .fm-acts {
display: flex;
}
.fm-act {
width: 25px;
height: 25px;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 5px;
cursor: pointer;
display: grid;
place-items: center;
}
.fm-act:hover {
background: #e3e5e9;
color: var(--text);
}
.fm-act svg {
width: 13px;
height: 13px;
}
.fm-add {
display: flex;
align-items: center;
gap: 7px;
width: 100%;
border: none;
border-top: 1px solid var(--border);
background: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--accent);
padding: 10px 8px 6px;
margin-top: 4px;
cursor: pointer;
}
.fm-add:hover {
color: var(--accent-hover);
}
.fm-add svg {
width: 15px;
height: 15px;
}
</style>
@@ -0,0 +1,54 @@
import { onBeforeUnmount, onMounted, type Ref } from 'vue'
// 바깥 클릭 / ESC / 스크롤 시 닫기 공용 훅.
// 드롭다운·팝오버·메뉴 등에서 반복되던 document 리스너 등록/해제 로직을 한 곳으로 모은다.
//
// - isOpen: 열림 상태 ref (닫혀 있으면 아무 동작 안 함)
// - close: 닫기 콜백
// - containers: "내부"로 간주할 엘리먼트 ref 목록. 클릭이 이 안이면 무시한다
// (Teleport 로 body 에 띄운 메뉴는 트리거와 메뉴 ref 를 모두 넣는다)
// - opts.event: 바깥 클릭 감지 이벤트 (기본 'mousedown')
// - opts.esc: Escape 로 닫을지 (기본 true)
// - opts.closeOnScroll: 스크롤/리사이즈 시 닫을지 — fixed 로 띄운 메뉴 위치 어긋남 방지 (기본 false)
export function useClickOutside(
isOpen: Ref<boolean>,
close: () => void,
containers: Ref<HTMLElement | null>[],
opts: {
event?: 'mousedown' | 'click'
esc?: boolean
closeOnScroll?: boolean
} = {},
): void {
const { event = 'mousedown', esc = true, closeOnScroll = false } = opts
function onPointer(e: Event): void {
if (!isOpen.value) return
const t = e.target as Node
if (containers.some((r) => r.value?.contains(t))) return
close()
}
function onKey(e: KeyboardEvent): void {
if (isOpen.value && e.key === 'Escape') close()
}
function onScroll(): void {
if (isOpen.value) close()
}
onMounted(() => {
document.addEventListener(event, onPointer, true)
if (esc) document.addEventListener('keydown', onKey)
if (closeOnScroll) {
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
}
})
onBeforeUnmount(() => {
document.removeEventListener(event, onPointer, true)
if (esc) document.removeEventListener('keydown', onKey)
if (closeOnScroll) {
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
}
})
}
@@ -0,0 +1,58 @@
import { nextTick, ref } from 'vue'
import { useClickOutside } from './useClickOutside'
// Teleport(body) + fixed 위치로 띄우는 드롭다운 메뉴 공용 훅.
// 트리거 기준 좌표 계산 · open 토글 · 바깥클릭/ESC/스크롤 닫기를 한 곳으로 모은다.
// (DropdownSelect, StatusSelect 등 스크롤/overflow 컨테이너 안에서도 안 잘리는 메뉴에 사용)
//
// - align: 트리거 좌/우 끝 기준 정렬 (기본 'left')
// - matchWidth: 메뉴 최소 너비를 트리거 너비에 맞출지 (기본 false)
// - gap: 트리거 하단과 메뉴 사이 간격(px) (기본 6)
// - esc: ESC 로 닫을지 (기본 true)
export function useDropdownMenu(
opts: {
align?: 'left' | 'right'
matchWidth?: boolean
gap?: number
esc?: boolean
} = {},
) {
const { align = 'left', matchWidth = false, gap = 6, esc = true } = opts
const open = ref(false)
const triggerRef = ref<HTMLElement | null>(null)
const menuRef = ref<HTMLElement | null>(null)
// 메뉴 fixed 위치(트리거 기준 계산)
const menuStyle = ref<Record<string, string>>({})
function position(): void {
const el = triggerRef.value
if (!el) return
const r = el.getBoundingClientRect()
const style: Record<string, string> = { top: `${r.bottom + gap}px` }
if (matchWidth) style.minWidth = `${r.width}px`
if (align === 'right') {
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
} else {
style.left = `${r.left}px`
}
menuStyle.value = style
}
function toggle(): void {
open.value = !open.value
if (open.value) void nextTick(position)
}
function close(): void {
open.value = false
}
// 바깥클릭(트리거/메뉴 외부)·ESC·스크롤(위치 어긋남) 닫기
useClickOutside(open, close, [triggerRef, menuRef], {
event: 'click',
esc,
closeOnScroll: true,
})
return { open, triggerRef, menuRef, menuStyle, toggle, close, position }
}
+73
View File
@@ -0,0 +1,73 @@
import { useApi } from './useApi'
import type {
ApiScheduleCategory,
ApiScheduleEvent,
ApiSchedulePerson,
ScheduleCategoryPayload,
ScheduleEventPayload,
} from '@/types/schedule'
// 일정 도메인 API Composable — 인터셉터가 success/data 를 언래핑
export function useSchedule() {
const api = useApi()
// 카테고리
async function listCategories(): Promise<ApiScheduleCategory[]> {
return (await api.get('/schedule/categories')) as unknown as ApiScheduleCategory[]
}
async function createCategory(
payload: ScheduleCategoryPayload,
): Promise<ApiScheduleCategory> {
return (await api.post('/schedule/categories', payload)) as unknown as ApiScheduleCategory
}
async function updateCategory(
id: string,
payload: Partial<ScheduleCategoryPayload>,
): Promise<ApiScheduleCategory> {
return (await api.patch(`/schedule/categories/${id}`, payload)) as unknown as ApiScheduleCategory
}
async function deleteCategory(id: string): Promise<void> {
await api.delete(`/schedule/categories/${id}`)
}
// 참석자 풀
async function listPeople(): Promise<ApiSchedulePerson[]> {
return (await api.get('/schedule/people')) as unknown as ApiSchedulePerson[]
}
// 일정
async function listEvents(
start: string,
end: string,
): Promise<ApiScheduleEvent[]> {
return (await api.get('/schedule/events', {
params: { start, end },
})) as unknown as ApiScheduleEvent[]
}
async function createEvent(
payload: ScheduleEventPayload,
): Promise<ApiScheduleEvent> {
return (await api.post('/schedule/events', payload)) as unknown as ApiScheduleEvent
}
async function updateEvent(
id: string,
payload: Partial<ScheduleEventPayload>,
): Promise<ApiScheduleEvent> {
return (await api.patch(`/schedule/events/${id}`, payload)) as unknown as ApiScheduleEvent
}
async function deleteEvent(id: string): Promise<void> {
await api.delete(`/schedule/events/${id}`)
}
return {
listCategories,
createCategory,
updateCategory,
deleteCategory,
listPeople,
listEvents,
createEvent,
updateEvent,
deleteEvent,
}
}
+30 -3
View File
@@ -29,9 +29,11 @@ function toggleCollapse() {
const mobileOpen = ref(false)
// 현재 경로 기준으로 사이드바 네비 활성 항목을 판별
const activeNav = computed<'tasks' | 'projects'>(() =>
route.path.startsWith('/projects') ? 'projects' : 'tasks',
)
const activeNav = computed<'tasks' | 'projects' | 'schedule'>(() => {
if (route.path.startsWith('/projects')) return 'projects'
if (route.path.startsWith('/schedule')) return 'schedule'
return 'tasks'
})
// 현재 로그인 사용자(프로필 아바타는 UserAvatar 가 이미지/placeholder 로 표시)
const me = computed(() => authStore.user)
@@ -174,6 +176,31 @@ async function onLogout() {
</svg>
<span>프로젝트</span>
</RouterLink>
<RouterLink
to="/schedule"
class="nav-item"
title="일정"
:class="{ active: activeNav === 'schedule' }"
>
<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>
<span>일정</span>
</RouterLink>
</nav>
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
+10 -28
View File
@@ -7,6 +7,8 @@ import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useMyTaskStore } from '@/stores/my-task.store'
import { useProjectStore } from '@/stores/project.store'
import { countActive, type MyTaskRow } from '@/mock/relay.mock'
@@ -215,18 +217,12 @@ function loadMore() {
</div>
<!-- 로딩 / 상태 -->
<div
v-if="loading"
class="state-msg"
>
<EmptyState v-if="loading">
업무를 불러오는
</div>
<div
v-else-if="isEmpty"
class="state-msg"
>
</EmptyState>
<EmptyState v-else-if="isEmpty">
{{ view === 'assigned' ? '담당 중인 업무가 없습니다.' : '지시한 업무가 없습니다.' }}
</div>
</EmptyState>
<!-- 상태 그룹 -->
<div
@@ -412,15 +408,11 @@ function loadMore() {
</div>
<!-- 더보기 -->
<button
<LoadMore
v-if="hasMore"
class="load-more"
type="button"
:disabled="loadingMore"
@click="loadMore"
>
{{ loadingMore ? '불러오는 중…' : '더보기' }}
</button>
:loading="loadingMore"
@load="loadMore"
/>
</div>
</AppShell>
</template>
@@ -537,16 +529,6 @@ function loadMore() {
}
/* 로딩/빈 상태 */
.state-msg {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 2.5rem 1.25rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
}
/* 상태 그룹 */
.group {
@@ -7,11 +7,20 @@ 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 SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import { useActivityStore } from '@/stores/activity.store'
import type { Project, ActivityIcon } from '@/mock/relay.mock'
type Filter = 'all' | 'task' | 'member' | 'setting'
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'task', label: '업무' },
{ value: 'member', label: '멤버' },
{ value: 'setting', label: '설정' },
]
const route = useRoute()
const projectId = computed(() => String(route.params.projectId))
@@ -139,32 +148,10 @@ const ActIcon = defineComponent({
<!-- 툴바 -->
<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>
<SegmentedTabs
v-model="filter"
:options="FILTER_OPTIONS"
/>
<DropdownSelect
v-model="memberFilter"
:options="memberOptions"
@@ -192,18 +179,12 @@ const ActIcon = defineComponent({
</div>
<!-- 로딩 / 상태 -->
<div
v-if="loading"
class="feed-state"
>
<EmptyState v-if="loading">
활동을 불러오는 중입니다
</div>
<div
v-else-if="isEmpty"
class="feed-state"
>
</EmptyState>
<EmptyState v-else-if="isEmpty">
아직 기록된 활동이 없습니다.
</div>
</EmptyState>
<!-- 활동 피드 -->
<!-- act-text v-html store 에서 사용자 입력을 escapeHtml 이스케이프한 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
@@ -243,20 +224,12 @@ const ActIcon = defineComponent({
</div>
</div>
<!-- 보기 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
<div
<!-- 더보기 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
<LoadMore
v-if="!loading && activityStore.hasMore"
class="more-row"
>
<button
type="button"
class="more-btn"
:disabled="activityStore.loadingMore"
@click="activityStore.loadMore()"
>
{{ activityStore.loadingMore ? '불러오는 중…' : '더 보기' }}
</button>
</div>
:loading="activityStore.loadingMore"
@load="activityStore.loadMore()"
/>
</div>
</AppShell>
</template>
@@ -388,39 +361,4 @@ const ActIcon = defineComponent({
margin-left: 0.75rem;
}
/* 더 보기 */
.more-row {
display: flex;
justify-content: center;
margin-top: 0.875rem;
}
.more-btn {
border: 1px solid var(--border);
background: var(--panel);
border-radius: 0.5rem;
padding: 0.5rem 1.25rem;
font-size: 0.813rem;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
}
.more-btn:hover:not(:disabled) {
background: #f4f5f7;
}
.more-btn:disabled {
opacity: 0.6;
cursor: default;
}
/* 로딩/빈 상태 */
.feed-state {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 0.625rem;
box-shadow: var(--shadow-sm);
padding: 2.5rem 1.25rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
}
</style>
+28 -63
View File
@@ -9,10 +9,14 @@ 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 SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import { useTaskStore } from '@/stores/task.store'
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
import type { Project, TaskStatus } from '@/mock/relay.mock'
import { taskStatusLabel } from '@/shared/utils/format'
import type { Project } from '@/mock/relay.mock'
const route = useRoute()
const projectId = computed(() => String(route.params.projectId))
@@ -32,6 +36,15 @@ async function loadProject() {
const keyword = ref('')
const segment = ref<TaskSegment>('all')
// 세그먼트 탭 옵션(수량 포함) — counts 갱신에 반응
const segOptions = computed<{ value: TaskSegment; label: string; count: number }[]>(
() => [
{ value: 'all', label: '전체', count: counts.value.all },
{ value: 'prog', label: '진행 중', count: counts.value.prog },
{ value: 'todo', label: '대기', count: counts.value.todo },
{ value: 'done', label: '완료', count: counts.value.done },
],
)
const sort = ref<TaskSort>('latest')
const SORT_OPTIONS: { value: TaskSort; label: string }[] = [
{ value: 'latest', label: '최신순' },
@@ -96,14 +109,7 @@ const isFiltered = computed(() => keyword.value.trim() !== '' || segment.value !
// 지연 건수 — 현재 로드된 목록 기준(전역 정확 집계는 백엔드 미지원, 알려진 한계)
const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
// 업무 상태 → 배지 라벨
const STATUS_LABEL: Record<TaskStatus, string> = {
prog: '진행 중',
review: '승인 대기',
todo: '대기',
done: '완료',
changes: '수정 요청',
}
// 업무 상태 → 배지 라벨은 공용 SSOT(taskStatusLabel) 사용
</script>
<template>
@@ -188,32 +194,10 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
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>
<SegmentedTabs
v-model="segment"
:options="segOptions"
/>
<DropdownSelect
v-model="sort"
:options="SORT_OPTIONS"
@@ -254,18 +238,12 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</div>
<!-- 업무 목록 -->
<div
v-if="loading && tasks.length === 0"
class="state-msg"
>
<EmptyState v-if="loading && tasks.length === 0">
업무를 불러오는
</div>
<div
v-else-if="tasks.length === 0"
class="state-msg"
>
</EmptyState>
<EmptyState v-else-if="tasks.length === 0">
{{ isFiltered ? '조건에 맞는 업무가 없습니다.' : '아직 등록된 업무가 없습니다. 업무를 지시해 보세요.' }}
</div>
</EmptyState>
<div
v-else
class="list"
@@ -399,7 +377,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
<span
class="st-badge"
:class="task.status"
>{{ STATUS_LABEL[task.status] }}</span>
>{{ taskStatusLabel(task.status) }}</span>
<span class="chev">
<svg
viewBox="0 0 24 24"
@@ -417,15 +395,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
</div>
<!-- 더보기 -->
<button
<LoadMore
v-if="hasMore"
class="load-more"
type="button"
:disabled="loadingMore"
@click="loadMoreTasks()"
>
{{ loadingMore ? '불러오는 중…' : '더보기' }}
</button>
:loading="loadingMore"
@load="loadMoreTasks"
/>
</div>
<!-- 프로젝트를 찾지 못한 경우 -->
@@ -591,13 +565,4 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
text-align: center;
color: var(--text-2);
}
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
</style>
+10 -24
View File
@@ -6,6 +6,8 @@ import { RouterLink } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import DropdownSelect from '@/components/DropdownSelect.vue'
import LoadMore from '@/components/common/LoadMore.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import type { ProjectListQuery, ProjectSort } from '@/types/project'
@@ -203,30 +205,20 @@ const isFiltered = computed(() => keyword.value.trim() !== '')
</RouterLink>
<!-- 로딩 / 상태 -->
<div
v-if="loading && projects.length === 0"
class="list-empty"
>
<EmptyState v-if="loading && projects.length === 0">
불러오는
</div>
<div
v-else-if="projects.length === 0"
class="list-empty"
>
</EmptyState>
<EmptyState v-else-if="projects.length === 0">
{{ isFiltered ? '검색 결과가 없습니다.' : '아직 프로젝트가 없습니다. 프로젝트를 만들어 보세요.' }}
</div>
</EmptyState>
</div>
<!-- 더보기 -->
<button
<LoadMore
v-if="hasMore"
class="load-more"
type="button"
:disabled="loadingMore"
@click="projectStore.loadMore()"
>
{{ loadingMore ? '불러오는 중…' : '더보기' }}
</button>
:loading="loadingMore"
@load="projectStore.loadMore()"
/>
</div>
</AppShell>
</template>
@@ -371,10 +363,4 @@ const isFiltered = computed(() => keyword.value.trim() !== '')
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>
+138 -317
View File
@@ -7,6 +7,9 @@ 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 BaseModal from '@/components/common/BaseModal.vue'
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
import EmptyState from '@/components/common/EmptyState.vue'
import { useProjectStore } from '@/stores/project.store'
import { useMemberStore } from '@/stores/member.store'
import { useUser } from '@/composables/useUser'
@@ -70,6 +73,15 @@ const counts = computed(() => ({
member: members.value.filter((m) => m.roleType === 'member').length,
}))
// 역할 필터 세그먼트 옵션(수량 포함)
const filterOptions = computed<{ value: RoleFilter; label: string; count: number }[]>(
() => [
{ value: 'all', label: '전체', count: counts.value.all },
{ value: 'admin', label: '관리자', count: counts.value.admin },
{ value: 'member', label: '멤버', count: counts.value.member },
],
)
const filteredMembers = computed(() =>
members.value.filter((m) => {
const matchRole = filter.value === 'all' || m.roleType === filter.value
@@ -285,26 +297,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
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>
<SegmentedTabs
v-model="filter"
:options="filterOptions"
/>
<button
v-if="isAdmin"
class="btn primary invite"
@@ -330,18 +326,12 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</div>
<!-- 멤버 목록 -->
<div
v-if="loading"
class="state-msg"
>
<EmptyState v-if="loading">
멤버를 불러오는
</div>
<div
v-else-if="filteredMembers.length === 0"
class="state-msg"
>
</EmptyState>
<EmptyState v-else-if="filteredMembers.length === 0">
표시할 멤버가 없습니다.
</div>
</EmptyState>
<div
v-else
class="list"
@@ -466,134 +456,109 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
</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>
<form
class="modal-body"
@submit.prevent="submitInvite"
>
<div class="m-field uc-field">
<label class="m-label">초대할 사람 (이메일)</label>
<input
v-model="inviteEmail"
class="m-input"
type="email"
placeholder="가입된 사용자의 이메일 입력…"
autocomplete="off"
@focus="onEmailFocus"
@input="onEmailInput"
@blur="onEmailBlur"
>
<!-- 사용자 자동완성 드롭다운 -->
<div
v-if="showUserDropdown"
class="user-dd"
>
<div
v-if="usersLoading"
class="user-dd-msg"
>
불러오는
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
</div>
</template>
</div>
</div>
<BaseModal
v-if="showInvite"
title="멤버 초대"
@close="closeInvite"
>
<template #subtitle>
<b>{{ repo?.name }}</b> 프로젝트에 멤버를 추가합니다.
</template>
<div class="m-field">
<label class="m-label">역할</label>
<DropdownSelect
v-model="inviteRole"
:options="ROLE_OPTIONS"
block
class="role-select"
aria-label="역할"
/>
</div>
<div class="role-hint">
<b>멤버</b> 담당자로 배정되어 업무를 수행하고, <b>관리자</b> 업무 지시와 멤버 관리도 있습니다.
</div>
</form>
<div class="modal-foot">
<span class="fnote">이미 가입된 사용자만 초대할 있습니다.</span>
<button
class="mbtn"
type="button"
@click="closeInvite"
<form
class="invite-body"
@submit.prevent="submitInvite"
>
<div class="form-field uc-field">
<label class="form-label">초대할 사람 (이메일)</label>
<input
v-model="inviteEmail"
class="form-input"
type="email"
placeholder="가입된 사용자의 이메일 입력…"
autocomplete="off"
@focus="onEmailFocus"
@input="onEmailInput"
@blur="onEmailBlur"
>
<!-- 사용자 자동완성 드롭다운 -->
<div
v-if="showUserDropdown"
class="user-dd"
>
<div
v-if="usersLoading"
class="user-dd-msg"
>
취소
</button>
<button
class="mbtn primary"
type="button"
:disabled="!canSubmitInvite"
@click="submitInvite"
>
<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>
{{ inviteBusy ? '초대 중…' : '초대 보내기' }}
</button>
불러오는
</div>
<template v-else>
<button
v-for="u in userOptions"
:key="u.id"
type="button"
class="user-dd-item"
@mousedown.prevent="selectUser(u)"
>
<span class="avatar user-av"><UserAvatar
:url="u.avatarUrl"
:name="u.name"
/></span>
<span class="user-nm">{{ u.name }}</span>
<span class="user-em">{{ u.email }}</span>
</button>
<div
v-if="userOptions.length === 0"
class="user-dd-msg"
>
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
</div>
</template>
</div>
</div>
</div>
</Teleport>
<div class="form-field">
<label class="form-label">역할</label>
<DropdownSelect
v-model="inviteRole"
:options="ROLE_OPTIONS"
block
class="role-select"
aria-label="역할"
/>
<div class="role-hint">
<b>멤버</b> 담당자로 배정되어 업무를 수행하고, <b>관리자</b> 업무 지시와 멤버 관리도 있습니다.
</div>
</div>
</form>
<template #foot>
<span class="fnote">이미 가입된 사용자만 초대할 있습니다.</span>
<button
class="mbtn"
type="button"
@click="closeInvite"
>
취소
</button>
<button
class="mbtn primary"
type="button"
:disabled="!canSubmitInvite"
@click="submitInvite"
>
<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>
{{ inviteBusy ? '초대 중…' : '초대 보내기' }}
</button>
</template>
</BaseModal>
</AppShell>
</template>
@@ -800,15 +765,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}
/* 로딩/빈 상태 메시지 */
.state-msg {
padding: 2.5rem 1.125rem;
text-align: center;
font-size: 0.844rem;
color: var(--text-3);
border: 1px solid var(--border);
border-radius: var(--radius);
background: #fff;
}
/* 역할 변경 드롭다운 */
/* 전역 .list 의 overflow:hidden 이 드롭다운을 잘라내므로, 이 화면에서는 보이도록 해제.
@@ -867,73 +823,17 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
<!-- 초대 모달은 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 제거 — 자동완성 드롭다운이 모달 밖으로 넘쳐도 잘리지 않도록 */
/* 멤버 초대 모달 — 셸/헤더/푸터/필드는 BaseModal + 전역 .form-* 사용.
본문 폼은 display:contents 로 BaseModal 본문 간격을 상속받고,
고유 요소(자동완성 드롭다운/역할 힌트/푸터 노트)만 여기서 정의.
(BaseModal 이 body 로 Teleport 하므로 비-scoped 전역 스타일) */
.invite-body {
display: contents;
}
.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;
/* 전역 .modal-body(text-align:center) 상속 차단 — 폼은 좌측 정렬 */
text-align: left;
}
/* 모달 입력 필드(이메일/역할) — 세로 스택, 전체 너비 */
.invite-modal .m-field {
margin-top: 1.125rem;
}
.invite-modal .m-field:first-child {
margin-top: 0;
}
/* 사용자 자동완성 드롭다운 */
.invite-modal .uc-field {
.invite-body .uc-field {
position: relative;
}
.invite-modal .user-dd {
.invite-body .user-dd {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
@@ -947,7 +847,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
max-height: 16rem;
overflow-y: auto;
}
.invite-modal .user-dd-item {
.invite-body .user-dd-item {
display: flex;
align-items: center;
gap: 0.625rem;
@@ -960,22 +860,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
font-family: inherit;
text-align: left;
}
.invite-modal .user-dd-item:hover {
.invite-body .user-dd-item:hover {
background: #f5f6f8;
}
.invite-modal .user-av {
.invite-body .user-av {
width: 1.625rem;
height: 1.625rem;
font-size: 0.6875rem;
flex-shrink: 0;
}
.invite-modal .user-nm {
.invite-body .user-nm {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text);
white-space: nowrap;
}
.invite-modal .user-em {
.invite-body .user-em {
font-size: 0.75rem;
color: var(--text-3);
margin-left: auto;
@@ -985,111 +885,32 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
max-width: 14rem;
padding-left: 0.5rem;
}
.invite-modal .user-dd-msg {
.invite-body .user-dd-msg {
padding: 0.75rem 0.5rem;
font-size: 0.781rem;
color: var(--text-3);
text-align: center;
}
.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;
}
/* 역할 선택 드롭다운(DropdownSelect) — 모달 폼 입력 높이/서체에 맞춤 */
.invite-modal .role-select :deep(.dd-trigger) {
.invite-body .role-select .dd-trigger {
height: 2.5rem;
font-size: 0.844rem;
font-weight: 600;
color: var(--text);
}
.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 {
.invite-body .role-hint {
font-size: 0.75rem;
color: var(--text-3);
margin-top: 0.5rem;
line-height: 1.5;
}
.invite-modal .role-hint b {
.invite-body .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;
/* overflow:hidden 제거에 따른 하단 라운드 보정 */
border-radius: 0 0 0.875rem 0.875rem;
}
.invite-modal .modal-foot .fnote {
/* 푸터 좌측 안내 문구(BaseModal 푸터 슬롯) */
.fnote {
font-size: 0.75rem;
color: var(--text-3);
margin-right: auto;
}
.invite-modal .mbtn {
/* 전역 .mbtn(flex:1, 확인 다이얼로그용) 상속 차단 — 버튼은 내용 너비로 우측 정렬 */
flex: 0 0 auto;
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:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.invite-modal .mbtn svg {
width: 0.9375rem;
height: 0.9375rem;
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<script setup lang="ts">
// 일정 — 전사 공유 주간 타임라인(간트형) 캘린더
import { computed, onMounted, ref } from 'vue'
import AppShell from '@/layouts/AppShell.vue'
import ScheduleTimeline from '@/components/schedule/ScheduleTimeline.vue'
import ScheduleDetailPanel from '@/components/schedule/ScheduleDetailPanel.vue'
import ScheduleEventModal from '@/components/schedule/ScheduleEventModal.vue'
import ScheduleCategoryModal from '@/components/schedule/ScheduleCategoryModal.vue'
import ScheduleTypeFilter from '@/components/schedule/ScheduleTypeFilter.vue'
import { useScheduleStore } from '@/stores/schedule.store'
import { useDialogStore } from '@/stores/dialog.store'
import type {
ApiScheduleEvent,
ScheduleCategoryPayload,
ScheduleEventPayload,
} from '@/types/schedule'
const store = useScheduleStore()
const dialog = useDialogStore()
const selId = ref<string | null>(null)
const eventModal = ref<{ init: ApiScheduleEvent | null } | null>(null)
const catModal = ref<{ init: import('@/types/schedule').ApiScheduleCategory | null } | null>(null)
onMounted(() => {
void store.init().catch(() => {
// 인터셉터가 에러 토스트 처리
})
})
// 선택 일정 — 활성 유형 + 현재 주에 보이는 것만
const selEvent = computed<ApiScheduleEvent | null>(() => {
const ev = store.events.find((e) => e.id === selId.value)
if (!ev) return null
return store.activeTypeIds.has(ev.categoryId) ? ev : null
})
const selCategory = computed(
() => store.categories.find((c) => c.id === selEvent.value?.categoryId) ?? null,
)
// 새 일정 기본 날짜 — 보고 있는 주의 월요일
const defaultDate = computed(() => store.weekStart)
// ----- 일정 -----
function openNewEvent(): void {
eventModal.value = { init: null }
}
function openEditEvent(ev: ApiScheduleEvent): void {
eventModal.value = { init: ev }
}
async function onSaveEvent(payload: ScheduleEventPayload): Promise<void> {
const init = eventModal.value?.init
if (init) {
await store.updateEvent(init.id, payload)
selId.value = init.id
} else {
selId.value = await store.createEvent(payload)
}
eventModal.value = null
}
async function onDeleteEvent(ev: ApiScheduleEvent): Promise<void> {
const ok = await dialog.confirm(
`'${ev.title}' 일정을 삭제할까요? 이 작업은 되돌릴 수 없습니다.`,
{ title: '일정 삭제', confirmText: '삭제', variant: 'danger' },
)
if (!ok) return
await store.deleteEvent(ev.id)
if (selId.value === ev.id) selId.value = null
}
// ----- 카테고리 -----
function openNewCategory(): void {
catModal.value = { init: null }
}
function openEditCategory(id: string): void {
catModal.value = { init: store.categories.find((c) => c.id === id) ?? null }
}
async function onSaveCategory(payload: ScheduleCategoryPayload): Promise<void> {
const init = catModal.value?.init
if (init) await store.updateCategory(init.id, payload)
else await store.createCategory(payload)
catModal.value = null
}
async function onDeleteCategory(id: string): Promise<void> {
const cat = store.categories.find((c) => c.id === id)
if (!cat) return
const cnt = store.counts[id] || 0
const msg = cnt
? `'${cat.label}' 카테고리와 이번 주에 표시된 일정 ${cnt}건을 포함해 해당 카테고리의 모든 일정이 삭제됩니다. 계속할까요?`
: `'${cat.label}' 카테고리를 삭제할까요?`
const ok = await dialog.confirm(msg, {
title: '카테고리 삭제',
confirmText: '삭제',
variant: 'danger',
})
if (!ok) return
await store.deleteCategory(id)
}
</script>
<template>
<AppShell>
<div class="sched">
<!-- 툴바 -->
<div class="toolbar">
<div class="datenav">
<div class="navgroup">
<button
class="navbtn"
title="이전 주"
@click="store.goPrevWeek()"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
><path d="m15 18-6-6 6-6" /></svg>
</button>
<button
class="navbtn"
title="다음 주"
@click="store.goNextWeek()"
>
<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>
</button>
</div>
<span class="range">{{ store.rangeLabel }}</span>
</div>
<div class="spacer" />
<ScheduleTypeFilter
:categories="store.categories"
:counts="store.counts"
:active-type-ids="store.activeTypeIds"
@toggle="store.toggleType"
@set-all="store.setAllTypes"
@add="openNewCategory"
@edit="openEditCategory"
@delete="onDeleteCategory"
/>
<button
class="btn primary"
@click="openNewEvent"
>
<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> 일정
</button>
</div>
<!-- 콘텐츠 + 상세 패널 -->
<div class="content-row">
<div class="viewport">
<ScheduleTimeline
:categories="store.categories"
:events="store.events"
:days="store.days"
:active-type-ids="store.activeTypeIds"
:sel-id="selId"
@select="selId = $event"
@add-category="openNewCategory"
/>
</div>
<div
v-if="selEvent"
class="detail-backdrop"
@click="selId = null"
/>
<ScheduleDetailPanel
:event="selEvent"
:category="selCategory"
@close="selId = null"
@edit="openEditEvent"
@delete="onDeleteEvent"
/>
</div>
</div>
<ScheduleEventModal
v-if="eventModal"
:categories="store.categories"
:people="store.people"
:init="eventModal.init"
:default-date="defaultDate"
@save="onSaveEvent"
@close="eventModal = null"
/>
<ScheduleCategoryModal
v-if="catModal"
:init="catModal.init"
@save="onSaveCategory"
@close="catModal = null"
/>
</AppShell>
</template>
<style scoped>
/* 상단 topbar(3.25rem) 아래 영역을 가득 채우는 풀-하이트 레이아웃 */
.sched {
height: calc(100vh - 3.25rem);
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--bg);
}
.toolbar {
display: flex;
align-items: center;
/* 프로젝트 목록 등 전역 .toolbar 와 동일한 컨트롤 간격 */
gap: 0.625rem;
padding: 11px 24px;
flex-shrink: 0;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
.datenav {
display: flex;
align-items: center;
gap: 8px;
}
.datenav .range {
font-size: 15.5px;
font-weight: 700;
letter-spacing: -0.3px;
white-space: nowrap;
margin: 0 6px;
}
.navgroup {
display: flex;
}
.navgroup .navbtn:first-child {
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
}
.navgroup .navbtn:last-child {
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
margin-left: -1px;
}
.navbtn {
width: 32px;
height: 32px;
border: 1px solid var(--border-strong);
background: #fff;
color: var(--text-2);
display: grid;
place-items: center;
cursor: pointer;
}
.navbtn:hover {
background: #f7f8fa;
color: var(--text);
z-index: 1;
}
.navbtn svg {
width: 16px;
height: 16px;
}
.spacer {
flex: 1;
}
/* '새 일정' 버튼은 전역 .btn / .btn.primary 표준(다른 화면의 주요 버튼과 동일)을 그대로 사용 */
.content-row {
flex: 1;
min-height: 0;
display: flex;
background: var(--bg);
position: relative;
overflow: hidden;
}
.viewport {
flex: 1;
min-width: 0;
overflow: hidden;
display: flex;
padding: 18px 24px 28px;
}
.detail-backdrop {
position: absolute;
inset: 0;
z-index: 25;
background: rgba(20, 24, 33, 0.22);
opacity: 0;
animation: bd-in 0.22s ease forwards;
}
@keyframes bd-in {
to {
opacity: 1;
}
}
</style>
+5 -10
View File
@@ -31,6 +31,10 @@ import { useDialog } from '@/composables/useDialog'
import UserAvatar from '@/components/UserAvatar.vue'
import StatusSelect from '@/components/StatusSelect.vue'
import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
import {
WORK_STATUS_META,
WORK_STATUS_ORDER,
} from '@/shared/constants/workStatus'
const route = useRoute()
const router = useRouter()
@@ -302,16 +306,7 @@ const isAssignee = computed(
/* ============================================================
* 중간 점검(체크포인트) — 작업자가 점검일 당일에 보고서로 전송, 지시자는 기록 열람
* ============================================================ */
const WORK_STATUS_ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
// 진행 상태 표시 메타(라벨/색/약색)
const WORK_STATUS_META: Record<
ChecklistWorkStatus,
{ label: string; color: string; weak: string }
> = {
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
}
// 진행 상태 메타/순서는 공용 SSOT(shared/constants/workStatus) 사용
// 카테고리 색(탭/그룹 dot)
const CAT_COLOR: Record<ChecklistCategory, string> = {
필수: '#2563eb',
+7
View File
@@ -101,6 +101,13 @@ const routes: RouteRecordRaw[] = [
component: () => import('@/pages/relay/MyTasksPage.vue'),
meta: { requiresAuth: true },
},
{
// 일정 — 전사 공유 주간 캘린더(타임라인)
path: '/schedule',
name: 'schedule',
component: () => import('@/pages/relay/SchedulePage.vue'),
meta: { requiresAuth: true },
},
{
path: '/:pathMatch(.*)*',
name: 'not-found',
@@ -0,0 +1,18 @@
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
// 작업자 진행상태(대기/진행 중/완료) 라벨·색 — 단일 진실 공급원(SSOT).
// StatusSelect(드롭다운)·업무 상세(보고서)에서 공통 사용한다.
export interface WorkStatusMeta {
label: string
color: string
weak: string
}
export const WORK_STATUS_META: Record<ChecklistWorkStatus, WorkStatusMeta> = {
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
}
// 표시 순서(대기 → 진행 중 → 완료)
export const WORK_STATUS_ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
+1 -1
View File
@@ -101,7 +101,7 @@ const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토']
/** 업무 상태 → 한국어 라벨 */
const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
todo: '할 일',
todo: '대기',
prog: '진행 중',
review: '승인 대기',
done: '완료',
+94
View File
@@ -0,0 +1,94 @@
// 일정(주간 캘린더) 날짜 유틸 — 모두 'YYYY-MM-DD' 로컬 날짜 문자열 기준(시간대 이동 방지).
// 월요일 시작 요일 라벨(월~일)
export const DOW_KO = ['월', '화', '수', '목', '금', '토', '일'] as const
// Date → 로컬 'YYYY-MM-DD'
export function isoOf(date: Date): string {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
// 'YYYY-MM-DD' → 로컬 Date(자정)
export function dateOf(iso: string): Date {
const [y, m, d] = iso.split('-').map(Number)
return new Date(y ?? 1970, (m ?? 1) - 1, d ?? 1)
}
// 오늘(로컬) ISO
export function todayIso(): string {
return isoOf(new Date())
}
// iso 가 속한 주의 월요일 ISO
export function mondayOf(iso: string): string {
const dt = dateOf(iso)
// getDay(): 0=일 … 6=토 → 월요일까지 거슬러 올라갈 일수
const back = (dt.getDay() + 6) % 7
dt.setDate(dt.getDate() - back)
return isoOf(dt)
}
// iso + n일
export function addDays(iso: string, n: number): string {
const dt = dateOf(iso)
dt.setDate(dt.getDate() + n)
return isoOf(dt)
}
// 요일 인덱스(0=월 … 6=일)
function dowIndex(iso: string): number {
return (dateOf(iso).getDay() + 6) % 7
}
export interface WeekDay {
iso: string
dow: string
d: number
weekend: boolean
isToday: boolean
}
// 주 시작(월요일) ISO → 7일 배열
export function weekDays(weekStartIso: string): WeekDay[] {
const today = todayIso()
return Array.from({ length: 7 }, (_, i) => {
const iso = addDays(weekStartIso, i)
return {
iso,
dow: DOW_KO[i] ?? '',
d: dateOf(iso).getDate(),
weekend: i >= 5,
isToday: iso === today,
}
})
}
// 주 범위 라벨 — 예: '2026년 6월 22 28일' / 월이 다르면 '6월 29 7월 5일'
export function weekRangeLabel(weekStartIso: string): string {
const s = dateOf(weekStartIso)
const e = dateOf(addDays(weekStartIso, 6))
const sm = s.getMonth() + 1
const em = e.getMonth() + 1
if (sm === em) {
return `${s.getFullYear()}${sm}${s.getDate()} ${e.getDate()}`
}
return `${s.getFullYear()}${sm}${s.getDate()} ${em}${e.getDate()}`
}
// 단일/다중일 표시용 — '6월 22일 (월)' 또는 '6월 22일(월) 24일(수)'
export function eventDateText(start: string, end: string): string {
const s = dateOf(start)
if (start === end) {
return `${s.getMonth() + 1}${s.getDate()}일 (${DOW_KO[dowIndex(start)]})`
}
const e = dateOf(end)
return `${s.getMonth() + 1}${s.getDate()}일(${DOW_KO[dowIndex(start)]}) ${e.getMonth() + 1}${e.getDate()}일(${DOW_KO[dowIndex(end)]})`
}
// 다중일 일수(포함 양끝)
export function spanDays(start: string, end: string): number {
return Math.round((dateOf(end).getTime() - dateOf(start).getTime()) / 86400000) + 1
}
+172
View File
@@ -0,0 +1,172 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { useSchedule } from '@/composables/useSchedule'
import {
addDays,
mondayOf,
todayIso,
weekDays,
weekRangeLabel,
type WeekDay,
} from '@/shared/utils/scheduleDate'
import type {
ApiScheduleCategory,
ApiScheduleEvent,
ApiSchedulePerson,
ScheduleCategoryPayload,
ScheduleEventPayload,
} from '@/types/schedule'
// 일정 스토어 — 카테고리/일정/참석자 + 주간 네비 상태 + CRUD
export const useScheduleStore = defineStore('schedule', () => {
const api = useSchedule()
const categories = ref<ApiScheduleCategory[]>([])
const events = ref<ApiScheduleEvent[]>([])
const people = ref<ApiSchedulePerson[]>([])
// 활성(표시) 카테고리 id 집합 — 유형 필터
const activeTypeIds = ref<Set<string>>(new Set())
// 주 시작(월요일) ISO
const weekStart = ref<string>(mondayOf(todayIso()))
const loading = ref(false)
// 파생: 주간 7일
const days = computed<WeekDay[]>(() => weekDays(weekStart.value))
const rangeLabel = computed(() => weekRangeLabel(weekStart.value))
const weekEnd = computed(() => addDays(weekStart.value, 6))
// 카테고리별 현재 주 일정 수(필터 드롭다운 표기용)
const counts = computed<Record<string, number>>(() => {
const c: Record<string, number> = {}
for (const e of events.value) c[e.categoryId] = (c[e.categoryId] || 0) + 1
return c
})
// --- 로딩 ---
// 최초 진입 — 카테고리/참석자/주간 일정 동시 로드 + 필터 전체 활성화
async function init(): Promise<void> {
loading.value = true
try {
const [cats, ppl, evs] = await Promise.all([
api.listCategories(),
api.listPeople(),
api.listEvents(weekStart.value, weekEnd.value),
])
categories.value = cats
people.value = ppl
events.value = evs
activeTypeIds.value = new Set(cats.map((c) => c.id))
} finally {
loading.value = false
}
}
// 현재 주 일정만 재조회
async function reloadEvents(): Promise<void> {
events.value = await api.listEvents(weekStart.value, weekEnd.value)
}
// --- 주간 네비 ---
function goPrevWeek(): void {
weekStart.value = addDays(weekStart.value, -7)
void reloadEvents()
}
function goNextWeek(): void {
weekStart.value = addDays(weekStart.value, 7)
void reloadEvents()
}
function goToday(): void {
weekStart.value = mondayOf(todayIso())
void reloadEvents()
}
// --- 유형 필터 ---
function toggleType(id: string): void {
const n = new Set(activeTypeIds.value)
if (n.has(id)) n.delete(id)
else n.add(id)
activeTypeIds.value = n
}
function setAllTypes(on: boolean): void {
activeTypeIds.value = on
? new Set(categories.value.map((c) => c.id))
: new Set()
}
// --- 일정 CRUD ---
async function createEvent(payload: ScheduleEventPayload): Promise<string> {
const ev = await api.createEvent(payload)
// 활성 필터에 카테고리 포함 보장
if (!activeTypeIds.value.has(ev.categoryId)) toggleType(ev.categoryId)
await reloadEvents()
return ev.id
}
async function updateEvent(
id: string,
payload: Partial<ScheduleEventPayload>,
): Promise<void> {
await api.updateEvent(id, payload)
await reloadEvents()
}
async function deleteEvent(id: string): Promise<void> {
await api.deleteEvent(id)
events.value = events.value.filter((e) => e.id !== id)
}
// --- 카테고리 CRUD ---
async function createCategory(
payload: ScheduleCategoryPayload,
): Promise<void> {
const cat = await api.createCategory(payload)
categories.value = [...categories.value, cat]
toggleTypeOn(cat.id)
}
async function updateCategory(
id: string,
payload: Partial<ScheduleCategoryPayload>,
): Promise<void> {
const cat = await api.updateCategory(id, payload)
categories.value = categories.value.map((c) => (c.id === id ? cat : c))
}
async function deleteCategory(id: string): Promise<void> {
await api.deleteCategory(id)
categories.value = categories.value.filter((c) => c.id !== id)
const n = new Set(activeTypeIds.value)
n.delete(id)
activeTypeIds.value = n
// 포함 일정이 cascade 삭제되었으므로 주간 일정 재조회
await reloadEvents()
}
function toggleTypeOn(id: string): void {
if (!activeTypeIds.value.has(id)) {
activeTypeIds.value = new Set([...activeTypeIds.value, id])
}
}
return {
categories,
events,
people,
activeTypeIds,
weekStart,
loading,
days,
rangeLabel,
counts,
init,
reloadEvents,
goPrevWeek,
goNextWeek,
goToday,
toggleType,
setAllTypes,
createEvent,
updateEvent,
deleteEvent,
createCategory,
updateCategory,
deleteCategory,
}
})
+49
View File
@@ -0,0 +1,49 @@
// 일정(전사 공유 캘린더) 도메인 타입 — 백엔드 Schedule 응답과 1:1
// 카테고리(타임라인 레인)
export interface ApiScheduleCategory {
id: string
label: string
color: string
sortOrder: number
}
// 참석자/사용자(공통 경량 형태)
export interface ApiSchedulePerson {
id: string
name: string
avatarUrl: string | null
}
// 일정(이벤트)
export interface ApiScheduleEvent {
id: string
categoryId: string
title: string
start: string // YYYY-MM-DD
end: string // YYYY-MM-DD
time: string
place: string | null
description: string | null
allHands: boolean
attendees: ApiSchedulePerson[]
createdById: string | null
}
// 생성/수정 payload
export interface ScheduleEventPayload {
title: string
categoryId: string
start: string
end: string
time?: string
place?: string
description?: string
allHands?: boolean
attendeeIds?: string[]
}
export interface ScheduleCategoryPayload {
label: string
color: string
}