From 346ec7555348df1924430713ae04c336fd354edb Mon Sep 17 00:00:00 2001 From: ttipo Date: Mon, 22 Jun 2026 21:23:10 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=A0=84=EC=82=AC=20=EA=B3=B5?= =?UTF-8?q?=EC=9C=A0=20=EC=9D=BC=EC=A0=95(=EC=A3=BC=EA=B0=84=20=EC=BA=98?= =?UTF-8?q?=EB=A6=B0=EB=8D=94)=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 상단 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) --- backend/src/app.module.ts | 6 +- .../migrations/1782300000000-AddSchedule.ts | 69 ++ .../schedule/dto/schedule-category.dto.ts | 23 + .../schedule/dto/schedule-event.dto.ts | 71 ++ .../entities/schedule-category.entity.ts | 29 + .../entities/schedule-event.entity.ts | 75 ++ .../modules/schedule/schedule.controller.ts | 140 ++++ .../src/modules/schedule/schedule.module.ts | 19 + .../src/modules/schedule/schedule.service.ts | 273 ++++++++ .../components/schedule/ScheduleAvatar.vue | 68 ++ .../schedule/ScheduleCategoryModal.vue | 317 +++++++++ .../schedule/ScheduleDetailPanel.vue | 493 +++++++++++++ .../schedule/ScheduleEventModal.vue | 662 ++++++++++++++++++ .../components/schedule/ScheduleTimeline.vue | 481 +++++++++++++ .../schedule/ScheduleTypeFilter.vue | 385 ++++++++++ frontend/src/composables/useSchedule.ts | 73 ++ frontend/src/layouts/AppShell.vue | 33 +- frontend/src/pages/relay/SchedulePage.vue | 355 ++++++++++ frontend/src/router/index.ts | 7 + frontend/src/shared/utils/scheduleDate.ts | 94 +++ frontend/src/stores/schedule.store.ts | 172 +++++ frontend/src/types/schedule.ts | 49 ++ 22 files changed, 3889 insertions(+), 5 deletions(-) create mode 100644 backend/src/migrations/1782300000000-AddSchedule.ts create mode 100644 backend/src/modules/schedule/dto/schedule-category.dto.ts create mode 100644 backend/src/modules/schedule/dto/schedule-event.dto.ts create mode 100644 backend/src/modules/schedule/entities/schedule-category.entity.ts create mode 100644 backend/src/modules/schedule/entities/schedule-event.entity.ts create mode 100644 backend/src/modules/schedule/schedule.controller.ts create mode 100644 backend/src/modules/schedule/schedule.module.ts create mode 100644 backend/src/modules/schedule/schedule.service.ts create mode 100644 frontend/src/components/schedule/ScheduleAvatar.vue create mode 100644 frontend/src/components/schedule/ScheduleCategoryModal.vue create mode 100644 frontend/src/components/schedule/ScheduleDetailPanel.vue create mode 100644 frontend/src/components/schedule/ScheduleEventModal.vue create mode 100644 frontend/src/components/schedule/ScheduleTimeline.vue create mode 100644 frontend/src/components/schedule/ScheduleTypeFilter.vue create mode 100644 frontend/src/composables/useSchedule.ts create mode 100644 frontend/src/pages/relay/SchedulePage.vue create mode 100644 frontend/src/shared/utils/scheduleDate.ts create mode 100644 frontend/src/stores/schedule.store.ts create mode 100644 frontend/src/types/schedule.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 74adf22..ccaa2c3 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/migrations/1782300000000-AddSchedule.ts b/backend/src/migrations/1782300000000-AddSchedule.ts new file mode 100644 index 0000000..24a1e83 --- /dev/null +++ b/backend/src/migrations/1782300000000-AddSchedule.ts @@ -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 { + // 카테고리(레인) + 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 { + 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"`); + } +} diff --git a/backend/src/modules/schedule/dto/schedule-category.dto.ts b/backend/src/modules/schedule/dto/schedule-category.dto.ts new file mode 100644 index 0000000..28e1183 --- /dev/null +++ b/backend/src/modules/schedule/dto/schedule-category.dto.ts @@ -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, +) {} diff --git a/backend/src/modules/schedule/dto/schedule-event.dto.ts b/backend/src/modules/schedule/dto/schedule-event.dto.ts new file mode 100644 index 0000000..ffdbe9b --- /dev/null +++ b/backend/src/modules/schedule/dto/schedule-event.dto.ts @@ -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, +) {} diff --git a/backend/src/modules/schedule/entities/schedule-category.entity.ts b/backend/src/modules/schedule/entities/schedule-category.entity.ts new file mode 100644 index 0000000..c30b949 --- /dev/null +++ b/backend/src/modules/schedule/entities/schedule-category.entity.ts @@ -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; +} diff --git a/backend/src/modules/schedule/entities/schedule-event.entity.ts b/backend/src/modules/schedule/entities/schedule-event.entity.ts new file mode 100644 index 0000000..23384cd --- /dev/null +++ b/backend/src/modules/schedule/entities/schedule-event.entity.ts @@ -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; + + @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; +} diff --git a/backend/src/modules/schedule/schedule.controller.ts b/backend/src/modules/schedule/schedule.controller.ts new file mode 100644 index 0000000..0119ff5 --- /dev/null +++ b/backend/src/modules/schedule/schedule.controller.ts @@ -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 { + return this.scheduleService.listCategories(); + } + + @Post('categories') + @ApiOperation({ summary: '일정 카테고리 생성' }) + @ApiResponse({ status: 201, description: '생성 성공' }) + createCategory( + @Body() dto: CreateScheduleCategoryDto, + ): Promise { + return this.scheduleService.createCategory(dto); + } + + @Patch('categories/:id') + @ApiOperation({ summary: '일정 카테고리 수정' }) + @ApiResponse({ status: 200, description: '수정 성공' }) + updateCategory( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateScheduleCategoryDto, + ): Promise { + 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 { + return this.scheduleService.listPeople(); + } + + // ----- 일정 ----- + + @Get('events') + @ApiOperation({ summary: '일정 목록(기간 겹침)' }) + @ApiResponse({ status: 200, description: '조회 성공' }) + listEvents( + @Query('start') start: string, + @Query('end') end: string, + ): Promise { + 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 { + 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 { + 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 }; + } +} diff --git a/backend/src/modules/schedule/schedule.module.ts b/backend/src/modules/schedule/schedule.module.ts new file mode 100644 index 0000000..3004b57 --- /dev/null +++ b/backend/src/modules/schedule/schedule.module.ts @@ -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 {} diff --git a/backend/src/modules/schedule/schedule.service.ts b/backend/src/modules/schedule/schedule.service.ts new file mode 100644 index 0000000..3f41b5e --- /dev/null +++ b/backend/src/modules/schedule/schedule.service.ts @@ -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, + @InjectRepository(ScheduleEvent) + private readonly eventRepo: Repository, + private readonly userService: UserService, + ) {} + + // 기동 시 기본 카테고리 시드(멱등) — 비어있을 때만 적재. dev/prod 공통. + async onModuleInit(): Promise { + 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 { + const rows = await this.categoryRepo.find({ + order: { sortOrder: 'ASC', createdAt: 'ASC' }, + }); + return rows.map((c) => this.toCategoryResponse(c)); + } + + async createCategory( + dto: CreateScheduleCategoryDto, + ): Promise { + // 다음 정렬값 = 현재 최대 + 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, + ): Promise { + 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 { + const res = await this.categoryRepo.delete({ id }); + if (!res.affected) + throw new NotFoundException('카테고리를 찾을 수 없습니다.'); + } + + // ----- 일정 ----- + + // 주간(또는 임의 기간) 조회 — [start, end] 와 겹치는 일정 전부. + async listEvents( + start: string, + end: string, + ): Promise { + 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 { + 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, + ): Promise { + 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 { + const res = await this.eventRepo.delete({ id }); + if (!res.affected) throw new NotFoundException('일정을 찾을 수 없습니다.'); + } + + // 참석자 선택 풀(전사 사용자) — 일정 작성 모달의 참석자 선택용. + async listPeople(): Promise { + 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 { + 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, + }; + } +} diff --git a/frontend/src/components/schedule/ScheduleAvatar.vue b/frontend/src/components/schedule/ScheduleAvatar.vue new file mode 100644 index 0000000..aa4a733 --- /dev/null +++ b/frontend/src/components/schedule/ScheduleAvatar.vue @@ -0,0 +1,68 @@ + + + + + diff --git a/frontend/src/components/schedule/ScheduleCategoryModal.vue b/frontend/src/components/schedule/ScheduleCategoryModal.vue new file mode 100644 index 0000000..fc09a26 --- /dev/null +++ b/frontend/src/components/schedule/ScheduleCategoryModal.vue @@ -0,0 +1,317 @@ + + + + + diff --git a/frontend/src/components/schedule/ScheduleDetailPanel.vue b/frontend/src/components/schedule/ScheduleDetailPanel.vue new file mode 100644 index 0000000..400b433 --- /dev/null +++ b/frontend/src/components/schedule/ScheduleDetailPanel.vue @@ -0,0 +1,493 @@ + + + + + diff --git a/frontend/src/components/schedule/ScheduleEventModal.vue b/frontend/src/components/schedule/ScheduleEventModal.vue new file mode 100644 index 0000000..ab5a672 --- /dev/null +++ b/frontend/src/components/schedule/ScheduleEventModal.vue @@ -0,0 +1,662 @@ + + +