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>
This commit is contained in:
2026-06-22 21:23:10 +09:00
parent 62df92e6eb
commit 346ec75553
22 changed files with 3889 additions and 5 deletions
+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,
};
}
}
@@ -0,0 +1,68 @@
<script setup lang="ts">
// 일정 참석자 아바타 — avatarUrl 있으면 이미지, 없으면 이름 첫 글자 + 파생색
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
name: string
avatarUrl?: string | null
size?: number
mono?: boolean
}>(),
{ avatarUrl: null, size: 21, mono: false },
)
// 이름 → 안정적 색상(시안 avColor 포팅)
const AV_COLORS = [
'#d0982a',
'#e0518d',
'#3d8bf2',
'#16a37b',
'#b3631f',
'#7c5cf0',
'#0e9594',
'#d6455d',
]
const color = computed(() => {
const sum = [...(props.name || '?')].reduce((a, c) => a + c.charCodeAt(0), 0)
return AV_COLORS[sum % AV_COLORS.length]
})
const initial = computed(() => (props.name || '?').trim()[0] ?? '?')
const bg = computed(() => (props.mono ? '#aeb4bd' : color.value))
</script>
<template>
<img
v-if="avatarUrl"
class="sa"
:src="avatarUrl"
:alt="name"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<span
v-else
class="sa sa-ph"
:style="{
width: `${size}px`,
height: `${size}px`,
fontSize: `${size * 0.47}px`,
background: bg,
}"
>{{ initial }}</span>
</template>
<style scoped>
.sa {
border-radius: 50%;
flex-shrink: 0;
object-fit: cover;
display: block;
}
.sa-ph {
display: grid;
place-items: center;
font-weight: 700;
color: #fff;
line-height: 1;
}
</style>
@@ -0,0 +1,317 @@
<script setup lang="ts">
// 카테고리 생성/수정 모달 — 이름 + 색상(hue 슬라이더)
import { computed, onBeforeUnmount, onMounted, ref } from '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 })
}
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
</script>
<template>
<div
class="modal-back"
@mousedown.self="emit('close')"
>
<div class="modal modal-sm">
<div class="modal-head">
<h3>{{ isEdit ? '카테고리 편집' : '새 카테고리' }}</h3>
<button
class="modal-x"
@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="modal-body">
<label class="f-field"><span class="f-label">이름</span>
<input
v-model="name"
class="f-input"
placeholder="카테고리 이름"
autofocus
@keydown.enter="submit"
>
</label>
<div class="f-field">
<span class="f-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>
</div>
<div class="modal-foot">
<button
class="btn"
@click="emit('close')"
>
취소
</button>
<button
class="btn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.34);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal {
width: 540px;
max-width: 100%;
background: var(--panel);
border-radius: 14px;
box-shadow: 0 24px 70px rgba(20, 24, 33, 0.34);
display: flex;
flex-direction: column;
}
.modal.modal-sm {
width: 408px;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 17px 22px;
border-bottom: 1px solid var(--border);
}
.modal-head h3 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.2px;
}
.modal-x {
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 6px;
cursor: pointer;
display: grid;
place-items: center;
margin-right: -5px;
}
.modal-x:hover {
background: #f1f2f4;
color: var(--text);
}
.modal-x svg {
width: 17px;
height: 17px;
}
.modal-body {
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 13px 22px;
border-top: 1px solid var(--border);
}
.f-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.f-label {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.f-input {
width: 100%;
height: 38px;
padding: 0 11px;
border: 1px solid var(--border-strong);
border-radius: 7px;
font-family: inherit;
font-size: 13.5px;
color: var(--text);
background: #fff;
}
.f-input::placeholder {
color: var(--text-3);
}
.f-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.cat-color {
display: flex;
align-items: center;
gap: 12px;
}
.cat-dot {
width: 24px;
height: 24px;
border-radius: 7px;
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: 16px;
margin: 6px 0;
border-radius: 8px;
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: 24px;
height: 24px;
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: 24px;
height: 24px;
border-radius: 50%;
background: var(--cur);
border: 3px solid #fff;
box-shadow: 0 1px 4px rgba(20, 24, 33, 0.4);
cursor: grab;
}
.btn {
height: 38px;
padding: 0 15px;
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;
}
.btn:hover {
background: #f7f8fa;
color: var(--text);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.btn.primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,493 @@
<script setup lang="ts">
// 우측 슬라이드 상세 패널 — 선택 일정의 시간/장소/참석자/메모 + 수정·삭제
import { computed, ref, watch, onBeforeUnmount } from 'vue'
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)
function onDoc(e: MouseEvent): void {
if (!attOpen.value) return
if (attRef.value && !attRef.value.contains(e.target as Node)) attOpen.value = false
}
watch(attOpen, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDoc))
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,662 @@
<script setup lang="ts">
// 일정 생성/수정 모달
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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]
}
function onDoc(ev: MouseEvent): void {
if (msRef.value && !msRef.value.contains(ev.target as Node)) msOpen.value = false
}
watch(msOpen, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
function onKey(ev: KeyboardEvent): void {
if (ev.key === 'Escape') emit('close')
}
onMounted(() => document.addEventListener('keydown', onKey))
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKey)
document.removeEventListener('mousedown', onDoc)
})
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>
<div
class="modal-back"
@mousedown.self="emit('close')"
>
<div class="modal">
<div class="modal-head">
<h3>{{ isEdit ? '일정 수정' : '새 일정' }}</h3>
<button
class="modal-x"
@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="modal-body">
<label class="f-field"><span class="f-label">제목</span>
<input
v-model="title"
class="f-input"
placeholder="일정 제목"
autofocus
>
</label>
<label class="f-field"><span class="f-label">유형</span>
<div class="f-select-wrap">
<select
v-model="categoryId"
class="f-select"
>
<option
v-for="c in ordered"
:key="c.id"
:value="c.id"
>
{{ c.label }}
</option>
</select>
<span class="f-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="f-row">
<label class="f-field"><span class="f-label">시작일</span>
<input
v-model="s"
type="date"
class="f-input"
>
</label>
<label class="f-field"><span class="f-label">종료일</span>
<input
v-model="e"
type="date"
class="f-input"
:min="s"
>
</label>
</div>
<div class="f-field">
<span class="f-label">시간</span>
<div class="time-row">
<input
v-model="startT"
type="time"
class="f-input time-input"
>
<span class="time-dash"></span>
<input
v-model="endT"
type="time"
class="f-input time-input"
>
</div>
</div>
<label class="f-field"><span class="f-label">장소</span>
<input
v-model="place"
class="f-input"
placeholder="장소 또는 화상"
>
</label>
<div class="f-field">
<span class="f-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="f-field"><span class="f-label">설명</span>
<textarea
v-model="desc"
class="f-input f-textarea"
placeholder="메모"
rows="3"
/>
</label>
</div>
<div class="modal-foot">
<button
class="btn"
@click="emit('close')"
>
취소
</button>
<button
class="btn primary"
:disabled="!valid"
@click="submit"
>
{{ isEdit ? '저장' : '추가' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.modal-back {
position: fixed;
inset: 0;
z-index: 100;
background: rgba(20, 24, 33, 0.34);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal {
width: 540px;
max-width: 100%;
max-height: 90vh;
overflow-y: auto;
background: var(--panel);
border-radius: 14px;
box-shadow: 0 24px 70px rgba(20, 24, 33, 0.34);
display: flex;
flex-direction: column;
}
.modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 17px 22px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--panel);
border-radius: 14px 14px 0 0;
}
.modal-head h3 {
font-size: 16px;
font-weight: 700;
letter-spacing: -0.2px;
}
.modal-x {
width: 30px;
height: 30px;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 6px;
cursor: pointer;
display: grid;
place-items: center;
margin-right: -5px;
}
.modal-x:hover {
background: #f1f2f4;
color: var(--text);
}
.modal-x svg {
width: 17px;
height: 17px;
}
.modal-body {
padding: 18px 22px;
display: flex;
flex-direction: column;
gap: 14px;
}
.modal-foot {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 13px 22px;
border-top: 1px solid var(--border);
position: sticky;
bottom: 0;
background: var(--panel);
}
.f-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.f-label {
font-size: 12px;
font-weight: 600;
color: var(--text-2);
}
.f-input {
width: 100%;
height: 38px;
padding: 0 11px;
border: 1px solid var(--border-strong);
border-radius: 7px;
font-family: inherit;
font-size: 13.5px;
color: var(--text);
background: #fff;
}
.f-input::placeholder {
color: var(--text-3);
}
.f-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.f-textarea {
height: auto;
padding: 9px 11px;
resize: vertical;
line-height: 1.55;
min-height: 72px;
}
.f-row {
display: flex;
gap: 12px;
}
.f-row .f-field {
flex: 1;
min-width: 0;
}
.f-select-wrap {
position: relative;
}
.f-select {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 38px;
padding: 0 34px 0 11px;
border: 1px solid var(--border-strong);
border-radius: 7px;
font-family: inherit;
font-size: 13.5px;
font-weight: 500;
color: var(--text);
background: #fff;
cursor: pointer;
}
.f-select:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.f-select-chev {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
display: grid;
color: var(--text-3);
pointer-events: none;
}
.f-select-chev svg {
width: 15px;
height: 15px;
}
.time-row {
display: flex;
align-items: center;
gap: 10px;
}
.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: 8px;
width: 100%;
height: 38px;
padding: 0 10px 0 11px;
border: 1px solid var(--border-strong);
border-radius: 7px;
background: #fff;
font-family: inherit;
font-size: 13.5px;
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;
font-weight: 500;
}
.ms-field .chev {
display: grid;
color: var(--text-3);
}
.ms-field .chev svg {
width: 15px;
height: 15px;
}
.ms-panel {
position: absolute;
top: calc(100% + 5px);
left: 0;
right: 0;
z-index: 10;
max-height: 220px;
overflow-y: auto;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 9px;
box-shadow: var(--shadow-pop);
padding: 5px;
}
.ms-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
border: none;
background: none;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--text-2);
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
text-align: left;
}
.ms-item:hover {
background: #f4f5f7;
}
.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: 15px;
height: 15px;
}
.ms-all {
width: 20px;
height: 20px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 9.5px;
font-weight: 700;
color: #fff;
background: #9298a3;
flex-shrink: 0;
}
.ms-div {
height: 1px;
background: var(--border);
margin: 4px 2px;
}
.ms-empty {
padding: 12px 8px;
font-size: 12.5px;
color: var(--text-3);
text-align: center;
}
.btn {
height: 38px;
padding: 0 15px;
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.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.btn.primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.btn:disabled:hover {
background: var(--accent);
border-color: var(--accent);
}
</style>
@@ -0,0 +1,481 @@
<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
(e: 'edit-category', id: string): void
(e: 'delete-category', id: string): 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>
<span class="ll-acts">
<button
class="ll-btn"
title="편집"
@click="emit('edit-category', lane.cat.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="ll-btn"
title="삭제"
@click="emit('delete-category', lane.cat.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 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-label .ll-acts {
display: none;
gap: 1px;
flex-shrink: 0;
}
.tl-label:hover .ll-acts {
display: flex;
}
.ll-btn {
width: 25px;
height: 25px;
border: none;
background: transparent;
color: var(--text-3);
border-radius: 5px;
cursor: pointer;
display: grid;
place-items: center;
}
.ll-btn:hover {
background: #eceef1;
color: var(--text);
}
.ll-btn svg {
width: 14px;
height: 14px;
}
.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,385 @@
<script setup lang="ts">
// 유형 필터 + 카테고리 관리 드롭다운
import { computed, ref, watch, onBeforeUnmount } from 'vue'
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)
function onDoc(e: MouseEvent): void {
if (rootRef.value && !rootRef.value.contains(e.target as Node)) open.value = false
}
watch(open, (v) => {
if (v) document.addEventListener('mousedown', onDoc)
else document.removeEventListener('mousedown', onDoc)
})
onBeforeUnmount(() => document.removeEventListener('mousedown', onDoc))
const ordered = computed(() =>
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
)
const activeCount = computed(
() => ordered.value.filter((c) => props.activeTypeIds.has(c.id)).length,
)
const allOn = computed(
() => activeCount.value === ordered.value.length && ordered.value.length > 0,
)
</script>
<template>
<div
ref="rootRef"
class="filter"
>
<button
class="btn 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
v-if="!allOn"
class="filter-badge"
>{{ activeCount }}</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;
}
.btn {
height: 36px;
padding: 0 15px;
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: 7px;
line-height: 1;
white-space: nowrap;
}
.btn:hover {
background: #f7f8fa;
color: var(--text);
}
.filter-btn .fic {
display: grid;
}
.filter-btn .fic svg {
width: 15px;
height: 15px;
}
.filter-btn .chev {
display: grid;
transition: transform 0.15s ease;
opacity: 0.55;
margin-left: -1px;
}
.filter-btn .chev svg {
width: 14px;
height: 14px;
}
.filter-btn.open {
background: #f1f2f4;
color: var(--text);
border-color: var(--border-strong);
}
.filter-btn.open .chev {
transform: rotate(180deg);
}
.filter-badge {
background: var(--accent);
color: #fff;
font-size: 11px;
font-weight: 700;
border-radius: 9px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: grid;
place-items: center;
line-height: 1;
}
.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>
+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>
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
+355
View File
@@ -0,0 +1,355 @@
<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>
<button
class="today-btn"
@click="store.goToday()"
>
오늘
</button>
<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"
@edit-category="openEditCategory"
@delete-category="onDeleteCategory"
/>
</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;
gap: 14px;
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;
}
.today-btn {
height: 32px;
padding: 0 13px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 600;
color: var(--text-2);
cursor: pointer;
}
.today-btn:hover {
background: #f7f8fa;
color: var(--text);
}
.spacer {
flex: 1;
}
.btn {
height: 36px;
padding: 0 15px;
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 svg {
width: 15px;
height: 15px;
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
}
.btn.primary:hover {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.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>
+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',
+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
}