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:
@@ -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>
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
|
||||
|
||||
@@ -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>
|
||||
@@ -101,6 +101,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/relay/MyTasksPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 일정 — 전사 공유 주간 캘린더(타임라인)
|
||||
path: '/schedule',
|
||||
name: 'schedule',
|
||||
component: () => import('@/pages/relay/SchedulePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user