feat: 개인 일정 캘린더 추가 및 사내 일정 수정 권한 제한
카테고리를 사내 공용(owner=null)과 개인 전용(owner=사용자)으로 나누고, 일정의 공개 범위를 소속 카테고리에서 파생시켜 두 종류가 섞이지 않게 한다. - 조회: 사내 일정 + 본인 개인 일정만 반환, 타인의 개인 항목은 존재를 숨김 - 개인 일정은 소유자만 수정·삭제하며 참석자·알림을 사용하지 않음 - 사내 일정은 등록자만 수정·삭제하도록 제한 (등록자가 탈퇴한 기존 일정은 잠기지 않도록 예외 처리) - 일정이 남은 사내 카테고리는 삭제를 막아 타인 일정 연쇄 삭제를 방지 - 툴바를 캘린더 선택(사내/개인 체크박스)과 '내 관련' 참석자 필터로 분리 - 일정·카테고리 작성 시 사내/개인 구분을 명시적으로 선택 - 기존 데이터는 전부 사내 공유(company)로 유지되는 마이그레이션 추가 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
// 개인 일정 — 카테고리/일정에 소유자와 공개 범위 추가.
|
||||
// 카테고리 owner_id 가 null 이면 전사 공용, 값이 있으면 해당 사용자 전용이며
|
||||
// 일정의 visibility 는 소속 카테고리에서 파생된다.
|
||||
// 운영(migrationsRun)에서만 실행되며, dev 는 synchronize 로 자동 반영된다.
|
||||
export class AddSchedulePersonal1783700000000 implements MigrationInterface {
|
||||
name = 'AddSchedulePersonal1783700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// 카테고리 소유자 — 기존 카테고리는 전부 전사 공용(NULL)으로 남는다.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_categories" ADD "owner_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_categories_owner" ON "schedule_categories" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_categories" ADD CONSTRAINT "FK_schedule_categories_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
|
||||
// 일정 공개 범위/소유자 — 기존 일정은 전부 전사 공유(company/NULL)로 남는다.
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD "visibility" character varying(16) NOT NULL DEFAULT 'company'`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD "owner_id" uuid`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_events_visibility" ON "schedule_events" ("visibility")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`CREATE INDEX "IDX_schedule_events_owner" ON "schedule_events" ("owner_id")`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" ADD CONSTRAINT "FK_schedule_events_owner" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP CONSTRAINT "FK_schedule_events_owner"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_schedule_events_owner"`);
|
||||
await queryRunner.query(`DROP INDEX "IDX_schedule_events_visibility"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP COLUMN "owner_id"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_events" DROP COLUMN "visibility"`,
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_categories" DROP CONSTRAINT "FK_schedule_categories_owner"`,
|
||||
);
|
||||
await queryRunner.query(`DROP INDEX "IDX_schedule_categories_owner"`);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE "schedule_categories" DROP COLUMN "owner_id"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,12 @@
|
||||
import { ApiProperty, PartialType } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
// 일정 카테고리 생성 DTO
|
||||
export class CreateScheduleCategoryDto {
|
||||
@@ -15,9 +22,18 @@ export class CreateScheduleCategoryDto {
|
||||
message: '색상은 #RRGGBB 형식의 hex 값이어야 합니다.',
|
||||
})
|
||||
color!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '개인 전용 카테고리 여부(true 면 본인만 보는 카테고리)',
|
||||
example: false,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
personal?: boolean;
|
||||
}
|
||||
|
||||
// 일정 카테고리 수정 DTO — 생성 DTO 의 부분 집합
|
||||
// (personal 은 생성 시에만 유효하며 수정 시에는 무시된다)
|
||||
export class UpdateScheduleCategoryDto extends PartialType(
|
||||
CreateScheduleCategoryDto,
|
||||
) {}
|
||||
|
||||
@@ -2,16 +2,28 @@ import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
// 일정 카테고리(타임라인 레인) — 전사 공유. 사용자가 추가·수정·삭제 가능.
|
||||
// 일정 카테고리(타임라인 레인) — 전사 공용과 개인 전용으로 나뉜다.
|
||||
// owner 가 null 이면 전사 공용, 값이 있으면 해당 사용자에게만 보이는 개인 카테고리다.
|
||||
// 일정의 공개 범위는 소속 카테고리에서 파생되므로, 두 종류는 서로 섞이지 않는다.
|
||||
// 음영색(weak/border)은 color 로부터 프론트가 color-mix 로 파생한다(저장하지 않음).
|
||||
@Entity('schedule_categories')
|
||||
export class ScheduleCategory {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id!: string;
|
||||
|
||||
// 소유자 — null 이면 전사 공용. 사용자 삭제 시 개인 카테고리도 함께 정리(CASCADE)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User | null;
|
||||
|
||||
// 표시 이름(예: 회의, 휴가·부재)
|
||||
@Column({ type: 'varchar' })
|
||||
label!: string;
|
||||
|
||||
@@ -14,7 +14,12 @@ import {
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { ScheduleCategory } from './schedule-category.entity';
|
||||
|
||||
// 일정(이벤트) — 전사 공유 캘린더의 단일 항목. 종일 기준, 다중일(start≠end) 지원.
|
||||
// 일정 공개 범위 — company: 전사 공유, private: 소유자 본인만
|
||||
export type ScheduleVisibility = 'company' | 'private';
|
||||
|
||||
// 일정(이벤트) — 캘린더의 단일 항목. 종일 기준, 다중일(start≠end) 지원.
|
||||
// 공개 범위는 소속 카테고리에서 파생된다(공용 카테고리=company, 개인 카테고리=private).
|
||||
// 조회 때 카테고리를 조인하지 않아도 되도록 visibility/owner 를 이벤트에도 비정규화해 둔다.
|
||||
@Entity('schedule_events')
|
||||
// 주간 범위 조회(겹침 검색)에 사용되는 복합 인덱스
|
||||
@Index(['startDate', 'endDate'])
|
||||
@@ -49,7 +54,20 @@ export class ScheduleEvent {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
description!: string | null;
|
||||
|
||||
// 공개 범위 — 소속 카테고리에서 파생(서비스가 강제 동기화)
|
||||
@Index()
|
||||
@Column({ type: 'varchar', length: 16, default: 'company' })
|
||||
visibility!: ScheduleVisibility;
|
||||
|
||||
// 개인 일정 소유자 — visibility 가 private 일 때만 채워진다.
|
||||
// 감사 이력인 createdBy 와 달리 접근 제어용이라 사용자 삭제 시 함께 삭제(CASCADE)
|
||||
@Index()
|
||||
@ManyToOne(() => User, { onDelete: 'CASCADE', nullable: true })
|
||||
@JoinColumn({ name: 'owner_id' })
|
||||
owner!: User | null;
|
||||
|
||||
// 전 직원 대상 여부 — true 면 개별 참석자 대신 "전 직원"으로 표시
|
||||
// 개인 일정에는 참석자 개념이 없으므로 항상 false 로 강제된다.
|
||||
@Column({ name: 'all_hands', type: 'boolean', default: false })
|
||||
allHands!: boolean;
|
||||
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
// YYYY-MM-DD 형식 검증(쿼리 파라미터용)
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// 일정(전사 공유 캘린더) 컨트롤러 — 인증 사용자면 누구나 조회·관리 가능.
|
||||
// 일정 컨트롤러 — 전사 공유 캘린더 + 사용자별 개인 일정.
|
||||
// 조회는 전사 일정 + 본인 개인 일정만, 수정·삭제는 소유자(개인)/작성자(전사)만 가능하다.
|
||||
@ApiTags('Schedule')
|
||||
@Controller('schedule')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -45,39 +46,46 @@ export class ScheduleController {
|
||||
// ----- 카테고리 -----
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: '일정 카테고리 목록' })
|
||||
@ApiOperation({ summary: '일정 카테고리 목록(전사 공용 + 내 개인)' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listCategories(): Promise<ScheduleCategoryResponse[]> {
|
||||
return this.scheduleService.listCategories();
|
||||
listCategories(
|
||||
@CurrentUser() user: PublicUser,
|
||||
): Promise<ScheduleCategoryResponse[]> {
|
||||
return this.scheduleService.listCategories(user.id);
|
||||
}
|
||||
|
||||
@Post('categories')
|
||||
@ApiOperation({ summary: '일정 카테고리 생성' })
|
||||
@ApiResponse({ status: 201, description: '생성 성공' })
|
||||
createCategory(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Body() dto: CreateScheduleCategoryDto,
|
||||
): Promise<ScheduleCategoryResponse> {
|
||||
return this.scheduleService.createCategory(dto);
|
||||
return this.scheduleService.createCategory(dto, user.id);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
@ApiOperation({ summary: '일정 카테고리 수정' })
|
||||
@ApiResponse({ status: 200, description: '수정 성공' })
|
||||
updateCategory(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateScheduleCategoryDto,
|
||||
): Promise<ScheduleCategoryResponse> {
|
||||
return this.scheduleService.updateCategory(id, dto);
|
||||
return this.scheduleService.updateCategory(id, dto, user.id);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({ summary: '일정 카테고리 삭제(포함 일정도 삭제)' })
|
||||
@ApiOperation({
|
||||
summary: '일정 카테고리 삭제(개인 카테고리는 포함 일정도 함께 삭제)',
|
||||
})
|
||||
@ApiResponse({ status: 200, description: '삭제 성공' })
|
||||
async deleteCategory(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
await this.scheduleService.deleteCategory(id);
|
||||
await this.scheduleService.deleteCategory(id, user.id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -93,9 +101,10 @@ export class ScheduleController {
|
||||
// ----- 일정 -----
|
||||
|
||||
@Get('events')
|
||||
@ApiOperation({ summary: '일정 목록(기간 겹침)' })
|
||||
@ApiOperation({ summary: '일정 목록(기간 겹침) — 전사 일정 + 내 개인 일정' })
|
||||
@ApiResponse({ status: 200, description: '조회 성공' })
|
||||
listEvents(
|
||||
@CurrentUser() user: PublicUser,
|
||||
@Query('start') start: string,
|
||||
@Query('end') end: string,
|
||||
): Promise<ScheduleEventResponse[]> {
|
||||
@@ -104,7 +113,7 @@ export class ScheduleController {
|
||||
'start, end 쿼리(YYYY-MM-DD)가 필요합니다.',
|
||||
);
|
||||
}
|
||||
return this.scheduleService.listEvents(start, end);
|
||||
return this.scheduleService.listEvents(start, end, user.id);
|
||||
}
|
||||
|
||||
@Post('events')
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { HttpStatus, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BusinessException } from '../../common/exceptions/business.exception';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { IsNull, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { User } from '../user/entities/user.entity';
|
||||
import { UserService } from '../user/user.service';
|
||||
import { NotificationService } from '../notification/notification.service';
|
||||
import type { NotificationType } from '../notification/entities/notification.entity';
|
||||
import { ScheduleCategory } from './entities/schedule-category.entity';
|
||||
import { ScheduleEvent } from './entities/schedule-event.entity';
|
||||
import {
|
||||
ScheduleEvent,
|
||||
type ScheduleVisibility,
|
||||
} from './entities/schedule-event.entity';
|
||||
import { CreateScheduleCategoryDto } from './dto/schedule-category.dto';
|
||||
import { CreateScheduleEventDto } from './dto/schedule-event.dto';
|
||||
|
||||
@@ -22,6 +26,8 @@ export interface ScheduleCategoryResponse {
|
||||
label: string;
|
||||
color: string;
|
||||
sortOrder: number;
|
||||
// 개인 전용 카테고리 여부(true 면 요청자 본인만 보는 카테고리)
|
||||
personal: boolean;
|
||||
}
|
||||
|
||||
export interface ScheduleAttendee {
|
||||
@@ -42,9 +48,19 @@ export interface ScheduleEventResponse {
|
||||
allHands: boolean;
|
||||
attendees: ScheduleAttendee[];
|
||||
createdById: string | null;
|
||||
// 공개 범위 — company: 전사 공유, private: 소유자 본인만
|
||||
visibility: ScheduleVisibility;
|
||||
ownerId: string | null;
|
||||
// 요청자가 이 일정을 수정·삭제할 수 있는지(프론트 버튼 노출 기준)
|
||||
canManage: boolean;
|
||||
}
|
||||
|
||||
// 일정(전사 공유 캘린더) 비즈니스 로직 — 카테고리/일정 CRUD + 참석자 매핑.
|
||||
// 일정 비즈니스 로직 — 카테고리/일정 CRUD + 참석자 매핑.
|
||||
//
|
||||
// 공개 범위 모델: 카테고리가 전사 공용(owner=null)과 개인 전용(owner=사용자)으로 나뉘고,
|
||||
// 일정의 visibility 는 소속 카테고리에서 파생된다. 두 종류가 섞이는 조합은 존재할 수 없다.
|
||||
//
|
||||
// 권한: 개인 일정·개인 카테고리는 소유자만, 전사 일정은 작성자만 수정·삭제할 수 있다.
|
||||
// (기본 카테고리 시드 없음 — 카테고리는 사용자가 직접 추가한다)
|
||||
@Injectable()
|
||||
export class ScheduleService {
|
||||
@@ -59,8 +75,11 @@ export class ScheduleService {
|
||||
|
||||
// ----- 카테고리 -----
|
||||
|
||||
async listCategories(): Promise<ScheduleCategoryResponse[]> {
|
||||
// 전사 공용 + 요청자 본인의 개인 카테고리만 반환한다.
|
||||
async listCategories(viewerId: string): Promise<ScheduleCategoryResponse[]> {
|
||||
const rows = await this.categoryRepo.find({
|
||||
where: [{ owner: IsNull() }, { owner: { id: viewerId } }],
|
||||
relations: ['owner'],
|
||||
order: { sortOrder: 'ASC', createdAt: 'ASC' },
|
||||
});
|
||||
return rows.map((c) => this.toCategoryResponse(c));
|
||||
@@ -68,18 +87,23 @@ export class ScheduleService {
|
||||
|
||||
async createCategory(
|
||||
dto: CreateScheduleCategoryDto,
|
||||
actorId: string,
|
||||
): 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 personal = dto.personal ?? false;
|
||||
// 다음 정렬값 = 같은 소유 범위 내 최대 + 1 (공용/개인 정렬을 서로 침범하지 않도록 분리)
|
||||
const [last] = await this.categoryRepo.find({
|
||||
where: personal ? { owner: { id: actorId } } : { owner: IsNull() },
|
||||
order: { sortOrder: 'DESC' },
|
||||
take: 1,
|
||||
});
|
||||
const sortOrder = (last?.sortOrder ?? -1) + 1;
|
||||
|
||||
const saved = await this.categoryRepo.save(
|
||||
this.categoryRepo.create({
|
||||
label: dto.label.trim(),
|
||||
color: dto.color,
|
||||
sortOrder,
|
||||
owner: personal ? { id: actorId } : null,
|
||||
}),
|
||||
);
|
||||
return this.toCategoryResponse(saved);
|
||||
@@ -88,9 +112,11 @@ export class ScheduleService {
|
||||
async updateCategory(
|
||||
id: string,
|
||||
dto: Partial<CreateScheduleCategoryDto>,
|
||||
actorId: string,
|
||||
): Promise<ScheduleCategoryResponse> {
|
||||
const cat = await this.categoryRepo.findOne({ where: { id } });
|
||||
if (!cat) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
// personal(소유 범위)은 생성 시에만 정해진다 — 수정으로 공용↔개인 전환은 허용하지 않는다.
|
||||
// 소속 일정 수십 건의 공개 범위가 한 번에 뒤집히는 것을 막기 위함이다.
|
||||
const cat = await this.assertCategoryAccessible(id, actorId);
|
||||
if (dto.label !== undefined) cat.label = dto.label.trim();
|
||||
if (dto.color !== undefined) cat.color = dto.color;
|
||||
const saved = await this.categoryRepo.save(cat);
|
||||
@@ -98,38 +124,62 @@ export class ScheduleService {
|
||||
}
|
||||
|
||||
// 카테고리 삭제 — 포함된 일정도 FK CASCADE 로 함께 삭제된다.
|
||||
async deleteCategory(id: string): Promise<void> {
|
||||
const res = await this.categoryRepo.delete({ id });
|
||||
if (!res.affected)
|
||||
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
// 공용 카테고리는 남의 일정까지 지워지므로 비어 있을 때만 삭제할 수 있다.
|
||||
async deleteCategory(id: string, actorId: string): Promise<void> {
|
||||
const cat = await this.assertCategoryAccessible(id, actorId);
|
||||
if (!cat.owner) {
|
||||
const used = await this.eventRepo.count({
|
||||
where: { category: { id } },
|
||||
});
|
||||
if (used > 0) {
|
||||
// 상태 기반 자동 매핑(VAL_001)에 문구가 묻히지 않도록 BusinessException 으로 던진다.
|
||||
throw new BusinessException(
|
||||
'BIZ_001',
|
||||
`이 카테고리에 등록된 일정 ${used}건이 있어 삭제할 수 없습니다. 일정을 먼저 정리해 주세요.`,
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.categoryRepo.delete({ id });
|
||||
}
|
||||
|
||||
// ----- 일정 -----
|
||||
|
||||
// 주간(또는 임의 기간) 조회 — [start, end] 와 겹치는 일정 전부.
|
||||
// 주간(또는 임의 기간) 조회 — [start, end] 와 겹치는 일정 중
|
||||
// 전사 일정 + 요청자 본인의 개인 일정만 반환한다.
|
||||
async listEvents(
|
||||
start: string,
|
||||
end: string,
|
||||
viewerId: string,
|
||||
): Promise<ScheduleEventResponse[]> {
|
||||
const overlap = {
|
||||
startDate: LessThanOrEqual(end),
|
||||
endDate: MoreThanOrEqual(start),
|
||||
};
|
||||
const rows = await this.eventRepo.find({
|
||||
where: {
|
||||
startDate: LessThanOrEqual(end),
|
||||
endDate: MoreThanOrEqual(start),
|
||||
},
|
||||
relations: ['attendees', 'createdBy'],
|
||||
where: [
|
||||
{ ...overlap, visibility: 'company' as const },
|
||||
{ ...overlap, visibility: 'private' as const, owner: { id: viewerId } },
|
||||
],
|
||||
relations: ['attendees', 'createdBy', 'owner'],
|
||||
order: { startDate: 'ASC', time: 'ASC' },
|
||||
});
|
||||
return rows.map((e) => this.toEventResponse(e));
|
||||
return rows.map((e) => this.toEventResponse(e, viewerId));
|
||||
}
|
||||
|
||||
async createEvent(
|
||||
dto: CreateScheduleEventDto,
|
||||
actor: ScheduleActor,
|
||||
): Promise<ScheduleEventResponse> {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
where: { id: dto.categoryId },
|
||||
});
|
||||
if (!category) throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
const category = await this.assertCategoryAccessible(
|
||||
dto.categoryId,
|
||||
actor.id,
|
||||
);
|
||||
// 공개 범위는 카테고리에서 파생 — 개인 카테고리에 담으면 개인 일정이 된다.
|
||||
const isPrivate = !!category.owner;
|
||||
// 개인 일정에는 참석자 개념이 없으므로 전 직원/참석자를 비운다.
|
||||
const allHands = isPrivate ? false : (dto.allHands ?? false);
|
||||
const attendeeIds = isPrivate ? [] : dto.attendeeIds;
|
||||
|
||||
const event = this.eventRepo.create({
|
||||
category: { id: category.id } as ScheduleCategory,
|
||||
@@ -139,21 +189,26 @@ export class ScheduleService {
|
||||
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),
|
||||
visibility: isPrivate ? 'private' : 'company',
|
||||
owner: isPrivate ? { id: actor.id } : null,
|
||||
allHands,
|
||||
attendees: this.attendeeRefs(allHands, attendeeIds),
|
||||
createdBy: { id: actor.id } as User,
|
||||
});
|
||||
const saved = await this.eventRepo.save(event);
|
||||
|
||||
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
|
||||
const recipients = event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: this.idsOf(dto.attendeeIds);
|
||||
await this.notifyEvent('schedule.invited', recipients, actor, {
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
});
|
||||
return this.findEventOrThrow(saved.id);
|
||||
// 개인 일정은 본인만 보므로 알림을 보내지 않는다.
|
||||
if (!isPrivate) {
|
||||
// 참석자에게 초대 알림 — 전 직원 일정이면 전 사용자, 아니면 지정 참석자
|
||||
const recipients = allHands
|
||||
? await this.userService.findAllIds()
|
||||
: this.idsOf(attendeeIds);
|
||||
await this.notifyEvent('schedule.invited', recipients, actor, {
|
||||
eventTitle: event.title,
|
||||
start: event.startDate,
|
||||
});
|
||||
}
|
||||
return this.findEventOrThrow(saved.id, actor.id);
|
||||
}
|
||||
|
||||
async updateEvent(
|
||||
@@ -163,21 +218,26 @@ export class ScheduleService {
|
||||
): Promise<ScheduleEventResponse> {
|
||||
const event = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['attendees'],
|
||||
relations: ['attendees', 'createdBy', 'owner'],
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
this.assertEventVisible(event, actor.id);
|
||||
this.assertEventManageable(event, actor.id);
|
||||
|
||||
// 수정 전 참석자·전직원 여부(알림 대상 비교용) — 아래 필드 변경 전에 캡처
|
||||
// 수정 전 참석자·전직원·공개범위(알림 대상 비교용) — 아래 필드 변경 전에 캡처
|
||||
const oldIds = new Set((event.attendees ?? []).map((a) => a.id));
|
||||
const wasAllHands = event.allHands;
|
||||
const wasPrivate = event.visibility === 'private';
|
||||
|
||||
if (dto.categoryId !== undefined) {
|
||||
const category = await this.categoryRepo.findOne({
|
||||
where: { id: dto.categoryId },
|
||||
});
|
||||
if (!category)
|
||||
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
const category = await this.assertCategoryAccessible(
|
||||
dto.categoryId,
|
||||
actor.id,
|
||||
);
|
||||
event.category = { id: category.id } as ScheduleCategory;
|
||||
// 카테고리를 옮기면 공개 범위도 따라 바뀐다(공용↔개인 전환)
|
||||
event.visibility = category.owner ? 'private' : 'company';
|
||||
event.owner = category.owner ? ({ id: actor.id } as User) : null;
|
||||
}
|
||||
if (dto.title !== undefined) event.title = dto.title.trim();
|
||||
if (dto.start !== undefined) event.startDate = dto.start;
|
||||
@@ -187,41 +247,60 @@ export class ScheduleService {
|
||||
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 isPrivate = event.visibility === 'private';
|
||||
if (isPrivate) {
|
||||
event.allHands = false;
|
||||
event.attendees = [];
|
||||
} else 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);
|
||||
|
||||
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로 신규→초대, 기존→수정됨.
|
||||
// 알림: 전 직원 여부를 반영한 '실효 참석자' 기준으로
|
||||
// 신규→초대, 기존→수정됨, 이탈→삭제됨(개인 일정 전환 포함).
|
||||
// (전 직원 일정이면 전 사용자가 실효 참석자 — 수정 전/후 어느 쪽이든 반영)
|
||||
const allIds =
|
||||
wasAllHands || event.allHands ? await this.userService.findAllIds() : [];
|
||||
const oldEffective = new Set(wasAllHands ? allIds : [...oldIds]);
|
||||
const newEffective = event.allHands
|
||||
? allIds
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
const oldEffective = new Set(
|
||||
wasPrivate ? [] : wasAllHands ? allIds : [...oldIds],
|
||||
);
|
||||
const newEffective = isPrivate
|
||||
? []
|
||||
: event.allHands
|
||||
? allIds
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
const newSet = new Set(newEffective);
|
||||
const invited = newEffective.filter((i) => !oldEffective.has(i));
|
||||
const stillThere = newEffective.filter((i) => oldEffective.has(i));
|
||||
const dropped = [...oldEffective].filter((i) => !newSet.has(i));
|
||||
const payload = { eventTitle: event.title, start: event.startDate };
|
||||
await this.notifyEvent('schedule.invited', invited, actor, payload);
|
||||
await this.notifyEvent('schedule.updated', stillThere, actor, payload);
|
||||
await this.notifyEvent('schedule.removed', dropped, actor, payload);
|
||||
|
||||
return this.findEventOrThrow(id);
|
||||
return this.findEventOrThrow(id, actor.id);
|
||||
}
|
||||
|
||||
async deleteEvent(id: string, actor: ScheduleActor): Promise<void> {
|
||||
// 삭제 전 참석자(알림 대상)를 먼저 확보
|
||||
const event = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['attendees'],
|
||||
relations: ['attendees', 'createdBy', 'owner'],
|
||||
});
|
||||
if (!event) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
// 전 직원 일정이면 전 사용자에게 삭제 알림
|
||||
const recipients = event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
this.assertEventVisible(event, actor.id);
|
||||
this.assertEventManageable(event, actor.id);
|
||||
|
||||
// 전 직원 일정이면 전 사용자에게 삭제 알림 (개인 일정은 알리지 않는다)
|
||||
const recipients =
|
||||
event.visibility === 'private'
|
||||
? []
|
||||
: event.allHands
|
||||
? await this.userService.findAllIds()
|
||||
: (event.attendees ?? []).map((a) => a.id);
|
||||
|
||||
await this.eventRepo.delete({ id });
|
||||
|
||||
@@ -241,6 +320,52 @@ export class ScheduleService {
|
||||
}));
|
||||
}
|
||||
|
||||
// ----- 내부 헬퍼(권한) -----
|
||||
|
||||
// 요청자가 접근(일정 담기·수정·삭제)할 수 있는 카테고리인지 검증하고 반환한다.
|
||||
// 공용 카테고리는 인증 사용자 누구나, 개인 카테고리는 소유자만 접근할 수 있고
|
||||
// 남의 개인 카테고리는 존재 자체를 노출하지 않는다.
|
||||
private async assertCategoryAccessible(
|
||||
id: string,
|
||||
actorId: string,
|
||||
): Promise<ScheduleCategory> {
|
||||
const cat = await this.categoryRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['owner'],
|
||||
});
|
||||
if (!cat || (cat.owner && cat.owner.id !== actorId))
|
||||
throw new NotFoundException('카테고리를 찾을 수 없습니다.');
|
||||
return cat;
|
||||
}
|
||||
|
||||
// 요청자가 볼 수 있는 일정인지 — 남의 개인 일정은 존재를 숨긴다.
|
||||
private assertEventVisible(event: ScheduleEvent, viewerId: string): void {
|
||||
if (event.visibility === 'private' && event.owner?.id !== viewerId)
|
||||
throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
}
|
||||
|
||||
// 요청자가 수정·삭제할 수 있는 일정인지.
|
||||
private assertEventManageable(event: ScheduleEvent, actorId: string): void {
|
||||
if (!this.canManageEvent(event, actorId)) {
|
||||
// 상태 기반 자동 매핑(AUTH_003 고정 문구) 대신 구체적인 사유를 노출한다.
|
||||
throw new BusinessException(
|
||||
'AUTH_003',
|
||||
event.visibility === 'private'
|
||||
? '본인의 개인 일정만 수정할 수 있습니다.'
|
||||
: '일정을 등록한 사람만 수정하거나 삭제할 수 있습니다.',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 관리 권한 판정 — 개인 일정은 소유자, 전사 일정은 작성자.
|
||||
// 작성자가 탈퇴해 비어 있는(created_by NULL) 전사 일정은 아무도 손대지 못하고
|
||||
// 남는 것을 막기 위해 인증 사용자 누구나 관리할 수 있게 둔다.
|
||||
private canManageEvent(event: ScheduleEvent, actorId: string): boolean {
|
||||
if (event.visibility === 'private') return event.owner?.id === actorId;
|
||||
return !event.createdBy || event.createdBy.id === actorId;
|
||||
}
|
||||
|
||||
// ----- 내부 헬퍼 -----
|
||||
|
||||
// 전 직원 일정이면 개별 참석자는 비운다. 아니면 id 참조 배열로 매핑.
|
||||
@@ -270,13 +395,17 @@ export class ScheduleService {
|
||||
});
|
||||
}
|
||||
|
||||
private async findEventOrThrow(id: string): Promise<ScheduleEventResponse> {
|
||||
private async findEventOrThrow(
|
||||
id: string,
|
||||
viewerId: string,
|
||||
): Promise<ScheduleEventResponse> {
|
||||
const e = await this.eventRepo.findOne({
|
||||
where: { id },
|
||||
relations: ['attendees', 'createdBy'],
|
||||
relations: ['attendees', 'createdBy', 'owner'],
|
||||
});
|
||||
if (!e) throw new NotFoundException('일정을 찾을 수 없습니다.');
|
||||
return this.toEventResponse(e);
|
||||
this.assertEventVisible(e, viewerId);
|
||||
return this.toEventResponse(e, viewerId);
|
||||
}
|
||||
|
||||
private toCategoryResponse(c: ScheduleCategory): ScheduleCategoryResponse {
|
||||
@@ -285,10 +414,14 @@ export class ScheduleService {
|
||||
label: c.label,
|
||||
color: c.color,
|
||||
sortOrder: c.sortOrder,
|
||||
personal: !!c.owner,
|
||||
};
|
||||
}
|
||||
|
||||
private toEventResponse(e: ScheduleEvent): ScheduleEventResponse {
|
||||
private toEventResponse(
|
||||
e: ScheduleEvent,
|
||||
viewerId: string,
|
||||
): ScheduleEventResponse {
|
||||
return {
|
||||
id: e.id,
|
||||
categoryId: e.category?.id ?? '',
|
||||
@@ -305,6 +438,9 @@ export class ScheduleService {
|
||||
avatarUrl: u.avatarUrl ?? null,
|
||||
})),
|
||||
createdById: e.createdBy?.id ?? null,
|
||||
visibility: e.visibility,
|
||||
ownerId: e.owner?.id ?? null,
|
||||
canManage: this.canManageEvent(e, viewerId),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,18 @@ 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 personal = ref(props.init?.personal ?? false)
|
||||
const valid = computed(() => !!name.value.trim())
|
||||
|
||||
function submit(): void {
|
||||
if (!valid.value) return
|
||||
emit('save', { label: name.value.trim(), color: color.value })
|
||||
const payload: ScheduleCategoryPayload = {
|
||||
label: name.value.trim(),
|
||||
color: color.value,
|
||||
}
|
||||
if (!isEdit.value) payload.personal = personal.value
|
||||
emit('save', payload)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -75,6 +82,46 @@ function submit(): void {
|
||||
@keydown.enter="submit"
|
||||
>
|
||||
</label>
|
||||
<!-- 구분 — 생성 시에만 선택 가능. 수정 화면에서는 현재 범위를 안내만 한다.
|
||||
(이후 이 카테고리에 담기는 일정의 공개 범위가 여기서 결정되므로 색상보다 위에 둔다) -->
|
||||
<div class="form-field">
|
||||
<span class="form-label"><span class="req">*</span> 구분</span>
|
||||
<div
|
||||
v-if="isEdit"
|
||||
class="scope-note"
|
||||
>
|
||||
{{ personal ? '개인 전용 카테고리입니다. (나만 볼 수 있음)' : '사내 공용 카테고리입니다.' }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="scope-opts">
|
||||
<button
|
||||
type="button"
|
||||
class="scope-opt"
|
||||
:class="{ on: !personal }"
|
||||
:aria-pressed="!personal"
|
||||
@click="personal = false"
|
||||
>
|
||||
사내 공용
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scope-opt"
|
||||
:class="{ on: personal }"
|
||||
:aria-pressed="personal"
|
||||
@click="personal = true"
|
||||
>
|
||||
개인 전용
|
||||
</button>
|
||||
</div>
|
||||
<p class="scope-hint">
|
||||
{{
|
||||
personal
|
||||
? '이 카테고리와 여기에 등록한 일정은 나만 볼 수 있습니다.'
|
||||
: '이 카테고리의 일정은 사내 모든 구성원에게 공개됩니다.'
|
||||
}}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<span class="form-label">색상</span>
|
||||
<div class="cat-color">
|
||||
@@ -117,6 +164,41 @@ function submit(): void {
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
/* 공개 범위 선택 — 2분할 세그먼트 */
|
||||
.scope-opts {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.scope-opt {
|
||||
flex: 1;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-opt:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.scope-opt.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.scope-hint {
|
||||
margin: 0.4375rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.scope-note {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.cat-dot {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
|
||||
@@ -34,6 +34,9 @@ const dur = computed(() =>
|
||||
)
|
||||
|
||||
const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
const isPersonal = computed(() => ev.value?.visibility === 'private')
|
||||
// 수정·삭제 노출 기준 — 서버가 내려준 권한을 그대로 따른다.
|
||||
const canManage = computed(() => !!ev.value?.canManage)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -44,15 +47,37 @@ const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
<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 />
|
||||
<div class="dt-chips">
|
||||
<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-if="isPersonal"
|
||||
class="private-chip"
|
||||
title="나만 볼 수 있는 개인 일정"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
개인
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="dt-close"
|
||||
@click="emit('close')"
|
||||
@@ -124,7 +149,11 @@ const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dt-foot">
|
||||
<!-- 수정·삭제는 권한이 있을 때만 노출(개인 일정=소유자, 전사 일정=등록자) -->
|
||||
<div
|
||||
v-if="canManage"
|
||||
class="dt-foot"
|
||||
>
|
||||
<div class="dt-actions">
|
||||
<button
|
||||
class="btn"
|
||||
@@ -179,6 +208,30 @@ const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
.detail.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.dt-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
min-width: 0;
|
||||
}
|
||||
/* 개인 일정 배지 */
|
||||
.private-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
background: #eef0f4;
|
||||
color: var(--text-2);
|
||||
white-space: nowrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.private-chip svg {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
}
|
||||
.type-chip {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -15,6 +15,8 @@ const props = defineProps<{
|
||||
people: ApiSchedulePerson[]
|
||||
init: ApiScheduleEvent | null
|
||||
defaultDate: string
|
||||
// 신규 작성 시 기본 구분 — 현재 보고 있는 캘린더를 따른다
|
||||
defaultScope?: 'company' | 'personal'
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', payload: ScheduleEventPayload): void
|
||||
@@ -43,9 +45,34 @@ const pt = parseTime(i?.time ?? '')
|
||||
const ordered = computed(() =>
|
||||
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
)
|
||||
// 카테고리 선택 그룹 — 공개 범위는 카테고리에서 결정된다.
|
||||
const companyCats = computed(() => ordered.value.filter((c) => !c.personal))
|
||||
const personalCats = computed(() => ordered.value.filter((c) => c.personal))
|
||||
|
||||
// 구분 선택 — 이걸 고르면 아래 유형 목록이 해당 범위로 좁혀진다.
|
||||
// 수정 시에는 기존 일정의 범위, 신규는 defaultScope(카테고리가 없으면 있는 쪽)로 시작한다.
|
||||
function initialScope(): 'company' | 'personal' {
|
||||
if (i) return i.visibility === 'private' ? 'personal' : 'company'
|
||||
const pref = props.defaultScope ?? 'company'
|
||||
const prefHasCats =
|
||||
pref === 'personal' ? personalCats.value.length : companyCats.value.length
|
||||
if (prefHasCats) return pref
|
||||
return companyCats.value.length ? 'company' : 'personal'
|
||||
}
|
||||
const scope = ref<'company' | 'personal'>(initialScope())
|
||||
const scopedCats = computed(() =>
|
||||
scope.value === 'personal' ? personalCats.value : companyCats.value,
|
||||
)
|
||||
|
||||
const title = ref(i?.title ?? '')
|
||||
const categoryId = ref(i?.categoryId ?? ordered.value[0]?.id ?? '')
|
||||
const categoryId = ref(i?.categoryId ?? scopedCats.value[0]?.id ?? '')
|
||||
|
||||
// 범위를 바꾸면 선택된 유형이 그 범위 밖일 수 있으므로 첫 항목으로 되돌린다.
|
||||
watch(scope, () => {
|
||||
if (!scopedCats.value.some((c) => c.id === categoryId.value)) {
|
||||
categoryId.value = scopedCats.value[0]?.id ?? ''
|
||||
}
|
||||
})
|
||||
const s = ref(i?.start ?? props.defaultDate)
|
||||
const e = ref(i?.end ?? i?.start ?? props.defaultDate)
|
||||
const startT = ref(pt.start)
|
||||
@@ -60,6 +87,9 @@ watch(s, (v) => {
|
||||
if (e.value < v) e.value = v
|
||||
})
|
||||
|
||||
// 개인 범위 = 개인 일정 — 참석자 개념이 없다.
|
||||
const isPersonal = computed(() => scope.value === 'personal')
|
||||
|
||||
const valid = computed(
|
||||
() =>
|
||||
!!title.value.trim() &&
|
||||
@@ -68,8 +98,8 @@ const valid = computed(
|
||||
!!e.value &&
|
||||
e.value >= s.value &&
|
||||
!!startT.value &&
|
||||
// 참석자 필수 — 전 직원 또는 1명 이상 선택
|
||||
(allHands.value || attendeeIds.value.length > 0),
|
||||
// 참석자 필수 — 전 직원 또는 1명 이상 선택(개인 일정은 해당 없음)
|
||||
(isPersonal.value || allHands.value || attendeeIds.value.length > 0),
|
||||
)
|
||||
|
||||
// 참석자 — 검색 입력 + 후보 드롭다운 + 선택 칩 (업무 생성의 담당자 등록과 동일 방식)
|
||||
@@ -124,8 +154,9 @@ function submit(): void {
|
||||
time,
|
||||
place: place.value.trim(),
|
||||
description: desc.value.trim(),
|
||||
allHands: allHands.value,
|
||||
attendeeIds: allHands.value ? [] : attendeeIds.value,
|
||||
// 개인 일정은 서버에서도 비워지지만, 요청 단계에서 먼저 정리해 보낸다.
|
||||
allHands: isPersonal.value ? false : allHands.value,
|
||||
attendeeIds: isPersonal.value || allHands.value ? [] : attendeeIds.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -143,14 +174,38 @@ function submit(): void {
|
||||
autofocus
|
||||
>
|
||||
</label>
|
||||
<div class="form-field">
|
||||
<span class="form-label"><span class="req">*</span> 구분</span>
|
||||
<div class="scope-opts">
|
||||
<button
|
||||
type="button"
|
||||
class="scope-opt"
|
||||
:class="{ on: scope === 'company' }"
|
||||
:aria-pressed="scope === 'company'"
|
||||
@click="scope = 'company'"
|
||||
>
|
||||
사내 일정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="scope-opt"
|
||||
:class="{ on: scope === 'personal' }"
|
||||
:aria-pressed="scope === 'personal'"
|
||||
@click="scope = 'personal'"
|
||||
>
|
||||
개인 일정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-field"><span class="form-label"><span class="req">*</span> 유형</span>
|
||||
<div class="form-select-wrap">
|
||||
<select
|
||||
v-model="categoryId"
|
||||
class="form-select"
|
||||
:disabled="scopedCats.length === 0"
|
||||
>
|
||||
<option
|
||||
v-for="c in ordered"
|
||||
v-for="c in scopedCats"
|
||||
:key="c.id"
|
||||
:value="c.id"
|
||||
>
|
||||
@@ -168,6 +223,12 @@ function submit(): void {
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="scopedCats.length === 0"
|
||||
class="scope-hint"
|
||||
>
|
||||
{{ scope === 'personal' ? '개인' : '사내' }} 카테고리가 없습니다. 상단 '유형' 메뉴에서 먼저 카테고리를 추가해 주세요.
|
||||
</p>
|
||||
</label>
|
||||
<div class="form-row">
|
||||
<label class="form-field"><span class="form-label"><span class="req">*</span> 시작일</span>
|
||||
@@ -209,7 +270,31 @@ function submit(): void {
|
||||
placeholder="장소를 입력하세요"
|
||||
>
|
||||
</label>
|
||||
<div class="form-field">
|
||||
<!-- 개인 일정(개인 카테고리)은 나만 보는 일정이라 참석자를 두지 않는다 -->
|
||||
<div
|
||||
v-if="isPersonal"
|
||||
class="private-note"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><rect
|
||||
x="3"
|
||||
y="11"
|
||||
width="18"
|
||||
height="11"
|
||||
rx="2"
|
||||
/><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
|
||||
개인 일정입니다. 나만 볼 수 있으며 참석자와 알림은 사용되지 않습니다.
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="form-field"
|
||||
>
|
||||
<span class="form-label"><span class="req">*</span> 참석자</span>
|
||||
<div class="combo">
|
||||
<div class="combo-field">
|
||||
@@ -330,6 +415,58 @@ function submit(): void {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 구분(사내/개인) 선택 — 카테고리 모달의 공개 범위 선택과 동일 외형 */
|
||||
.scope-opts {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.scope-opt {
|
||||
flex: 1;
|
||||
height: 2.25rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.scope-opt:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.scope-opt.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
}
|
||||
.scope-hint {
|
||||
margin: 0.4375rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 개인 일정 안내 — 참석자 영역을 대체 */
|
||||
.private-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.private-note svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 시간 입력 행 */
|
||||
.time-row {
|
||||
display: flex;
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
// 유형 필터 + 카테고리 관리 드롭다운
|
||||
// 유형 필터 + 카테고리 관리 드롭다운 — 사내 공용/내 개인 카테고리를 그룹으로 나눠 표시.
|
||||
// 상위 캘린더 스위치가 꺼진 그룹은 빈 배열로 전달되어 목록에서 통째로 빠진다.
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import type { ApiScheduleCategory } from '@/types/schedule'
|
||||
|
||||
const props = defineProps<{
|
||||
categories: ApiScheduleCategory[]
|
||||
companyCategories: ApiScheduleCategory[]
|
||||
personalCategories: ApiScheduleCategory[]
|
||||
counts: Record<string, number>
|
||||
activeTypeIds: Set<string>
|
||||
}>()
|
||||
@@ -21,13 +23,20 @@ const open = ref(false)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
|
||||
|
||||
const ordered = computed(() =>
|
||||
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
// 그룹 렌더링용 — 비어 있는 그룹은 헤더째 숨긴다.
|
||||
const groups = computed(() =>
|
||||
[
|
||||
{ key: 'company', label: '사내', items: props.companyCategories },
|
||||
{ key: 'personal', label: '내 카테고리', items: props.personalCategories },
|
||||
].filter((g) => g.items.length > 0),
|
||||
)
|
||||
const all = computed(() => [
|
||||
...props.companyCategories,
|
||||
...props.personalCategories,
|
||||
])
|
||||
const allOn = computed(
|
||||
() =>
|
||||
ordered.value.length > 0 &&
|
||||
ordered.value.every((c) => props.activeTypeIds.has(c.id)),
|
||||
all.value.length > 0 && all.value.every((c) => props.activeTypeIds.has(c.id)),
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -77,65 +86,73 @@ const allOn = computed(
|
||||
</button>
|
||||
</div>
|
||||
<div class="fm-list">
|
||||
<div
|
||||
v-for="c in ordered"
|
||||
:key="c.id"
|
||||
class="fm-item"
|
||||
:class="{ on: activeTypeIds.has(c.id) }"
|
||||
<template
|
||||
v-for="g in groups"
|
||||
:key="g.key"
|
||||
>
|
||||
<button
|
||||
class="fm-tog"
|
||||
@click="emit('toggle', c.id)"
|
||||
<div class="fm-group">
|
||||
{{ g.label }}
|
||||
</div>
|
||||
<div
|
||||
v-for="c in g.items"
|
||||
:key="c.id"
|
||||
class="fm-item"
|
||||
:class="{ on: activeTypeIds.has(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>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
class="fm-add"
|
||||
@@ -251,6 +268,19 @@ const allOn = computed(
|
||||
max-height: 20rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* 그룹 구분 헤더(사내 / 내 카테고리) */
|
||||
.fm-group {
|
||||
padding: 0.4375rem 0.5rem 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.0187rem;
|
||||
}
|
||||
.fm-group:not(:first-child) {
|
||||
margin-top: 0.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
.fm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
// 일정 — 전사 공유 캘린더(주간 간트 / 월간 달력 그리드 전환)
|
||||
// 일정 — 전사 공유 캘린더 + 개인 일정(주간 간트 / 월간 달력 그리드 전환)
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ScheduleTimeline from '@/components/schedule/ScheduleTimeline.vue'
|
||||
@@ -9,7 +9,10 @@ import ScheduleEventModal from '@/components/schedule/ScheduleEventModal.vue'
|
||||
import ScheduleCategoryModal from '@/components/schedule/ScheduleCategoryModal.vue'
|
||||
import ScheduleTypeFilter from '@/components/schedule/ScheduleTypeFilter.vue'
|
||||
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import { useScheduleStore, type ScheduleViewMode } from '@/stores/schedule.store'
|
||||
import {
|
||||
useScheduleStore,
|
||||
type ScheduleViewMode,
|
||||
} from '@/stores/schedule.store'
|
||||
import { useDialogStore } from '@/stores/dialog.store'
|
||||
import type {
|
||||
ApiScheduleEvent,
|
||||
@@ -48,6 +51,10 @@ const selCategory = computed(
|
||||
|
||||
// 새 일정 기본 날짜 — 보고 있는 주의 월요일
|
||||
const defaultDate = computed(() => store.weekStart)
|
||||
// 새 일정 기본 구분 — 개인 캘린더만 보고 있으면 개인으로 시작
|
||||
const defaultScope = computed<'company' | 'personal'>(() =>
|
||||
!store.showCompany && store.showPersonal ? 'personal' : 'company',
|
||||
)
|
||||
|
||||
// ----- 일정 -----
|
||||
function openNewEvent(): void {
|
||||
@@ -93,16 +100,21 @@ 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 msg =
|
||||
cat.personal && cnt
|
||||
? `'${cat.label}' 카테고리와 이번 주에 표시된 일정 ${cnt}건을 포함해 해당 카테고리의 모든 일정이 삭제됩니다. 계속할까요?`
|
||||
: `'${cat.label}' 카테고리를 삭제할까요?`
|
||||
const ok = await dialog.confirm(msg, {
|
||||
title: '카테고리 삭제',
|
||||
confirmText: '삭제',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
await store.deleteCategory(id)
|
||||
await store.deleteCategory(id).catch(() => {
|
||||
// 인터셉터가 에러 토스트 처리(예: 일정이 남은 전사 카테고리)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -150,11 +162,60 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
@update:model-value="store.setViewMode"
|
||||
/>
|
||||
<div class="spacer" />
|
||||
<!-- 캘린더 선택 — 사내/개인을 각각 켜고 끈다(겹쳐보기) -->
|
||||
<div
|
||||
class="calgroup"
|
||||
role="group"
|
||||
aria-label="표시할 캘린더"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="calbtn"
|
||||
:class="{ on: store.showCompany }"
|
||||
:aria-pressed="store.showCompany"
|
||||
@click="store.toggleCalendar('company')"
|
||||
>
|
||||
<span class="cbox">
|
||||
<svg
|
||||
v-if="store.showCompany"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
사내
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="calbtn"
|
||||
:class="{ on: store.showPersonal }"
|
||||
:aria-pressed="store.showPersonal"
|
||||
@click="store.toggleCalendar('personal')"
|
||||
>
|
||||
<span class="cbox">
|
||||
<svg
|
||||
v-if="store.showPersonal"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
개인
|
||||
</button>
|
||||
</div>
|
||||
<!-- 내가 참석자로 지정된 일정만 — 위 캘린더 선택과 겹쳐서 적용되는 별도 필터 -->
|
||||
<button
|
||||
type="button"
|
||||
class="mine-btn"
|
||||
:class="{ on: store.mineOnly }"
|
||||
:aria-pressed="store.mineOnly"
|
||||
title="내가 참석자로 지정된 일정만 보기"
|
||||
@click="store.toggleMineOnly()"
|
||||
>
|
||||
<svg
|
||||
@@ -169,10 +230,11 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
cy="8"
|
||||
r="4"
|
||||
/><path d="M6 21v-1a6 6 0 0 1 12 0v1" /></svg>
|
||||
내 일정
|
||||
내 관련
|
||||
</button>
|
||||
<ScheduleTypeFilter
|
||||
:categories="store.categories"
|
||||
:company-categories="store.filterCompanyCategories"
|
||||
:personal-categories="store.filterPersonalCategories"
|
||||
:counts="store.counts"
|
||||
:active-type-ids="store.activeTypeIds"
|
||||
@toggle="store.toggleType"
|
||||
@@ -201,7 +263,7 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
<div class="viewport">
|
||||
<ScheduleTimeline
|
||||
v-if="store.viewMode === 'week'"
|
||||
:categories="store.categories"
|
||||
:categories="store.displayCategories"
|
||||
:events="store.displayEvents"
|
||||
:days="store.days"
|
||||
:active-type-ids="store.activeTypeIds"
|
||||
@@ -240,6 +302,7 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
:people="store.people"
|
||||
:init="eventModal.init"
|
||||
:default-date="defaultDate"
|
||||
:default-scope="defaultScope"
|
||||
@save="onSaveEvent"
|
||||
@close="eventModal = null"
|
||||
/>
|
||||
@@ -318,7 +381,63 @@ async function onDeleteCategory(id: string): Promise<void> {
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
/* '내 일정' 토글 — 정렬/필터 pill 과 동일 외형, 활성 시 강조색 */
|
||||
/* 캘린더 선택 — 두 체크박스를 한 덩어리(연결된 pill)로 묶는다 */
|
||||
.calgroup {
|
||||
display: flex;
|
||||
}
|
||||
.calgroup .calbtn:first-child {
|
||||
border-radius: var(--radius) 0 0 var(--radius);
|
||||
}
|
||||
.calgroup .calbtn:last-child {
|
||||
border-radius: 0 var(--radius) var(--radius) 0;
|
||||
margin-left: -1px;
|
||||
}
|
||||
.calbtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.calbtn:hover {
|
||||
background: #f9fafb;
|
||||
z-index: 1;
|
||||
}
|
||||
.calbtn.on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
color: var(--accent);
|
||||
z-index: 1;
|
||||
}
|
||||
/* 체크 박스 — 켜짐만 채워진 사각형 */
|
||||
.calbtn .cbox {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 0.1875rem;
|
||||
background: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.calbtn.on .cbox {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.calbtn .cbox svg {
|
||||
width: 0.625rem;
|
||||
height: 0.625rem;
|
||||
}
|
||||
/* '내 관련' 토글 — 캘린더 선택과 독립적으로 겹쳐 적용되는 참석자 필터 */
|
||||
.mine-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -25,6 +25,9 @@ import type {
|
||||
|
||||
export type ScheduleViewMode = 'week' | 'month'
|
||||
|
||||
// 캘린더 종류 — 사내 공유 / 내 개인. 두 캘린더는 각각 켜고 끌 수 있다(겹쳐보기).
|
||||
export type ScheduleCalendar = 'company' | 'personal'
|
||||
|
||||
// 일정 스토어 — 카테고리/일정/참석자 + 주간/월간 네비 상태 + CRUD
|
||||
export const useScheduleStore = defineStore('schedule', () => {
|
||||
const api = useSchedule()
|
||||
@@ -33,9 +36,12 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
const categories = ref<ApiScheduleCategory[]>([])
|
||||
const events = ref<ApiScheduleEvent[]>([])
|
||||
const people = ref<ApiSchedulePerson[]>([])
|
||||
// 활성(표시) 카테고리 id 집합 — 유형 필터
|
||||
// 활성(표시) 카테고리 id 집합 — 유형 필터(캘린더 안에서 카테고리 단위로 걸러낸다)
|
||||
const activeTypeIds = ref<Set<string>>(new Set())
|
||||
// '내 일정만' 보기 — 참석자/전직원/작성자 기준
|
||||
// 표시할 캘린더 — 상위 스위치. 꺼진 캘린더는 유형 필터 목록에서도 그룹째 빠진다.
|
||||
const showCompany = ref(true)
|
||||
const showPersonal = ref(true)
|
||||
// '내 관련'만 보기 — 캘린더 선택과 무관하게 겹쳐서 적용되는 참석자 기준 필터
|
||||
const mineOnly = ref(false)
|
||||
// 보기 모드(주간/월간) + 기준 날짜(이 날짜가 속한 주/월을 표시)
|
||||
const viewMode = ref<ScheduleViewMode>('week')
|
||||
@@ -68,17 +74,53 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
: monthLabel(anchor.value),
|
||||
)
|
||||
|
||||
// '내 일정' 판정 — 참석자 기준(전 직원 대상은 전원 포함이므로 내 일정에 포함)
|
||||
const myId = computed(() => authStore.user?.id ?? null)
|
||||
// 개인 일정 판정 — 서버가 내 것만 내려주므로 공개 범위만 보면 된다.
|
||||
function isPersonal(e: ApiScheduleEvent): boolean {
|
||||
return e.visibility === 'private'
|
||||
}
|
||||
// 켜져 있는 캘린더의 일정인지
|
||||
function inActiveCalendar(e: ApiScheduleEvent): boolean {
|
||||
return isPersonal(e) ? showPersonal.value : showCompany.value
|
||||
}
|
||||
// '내 관련' 판정 — 내가 참석자로 지정된 일정(전 직원 대상 포함).
|
||||
// 개인 일정은 애초에 내 것뿐이므로 항상 해당된다.
|
||||
function isMine(e: ApiScheduleEvent): boolean {
|
||||
if (isPersonal(e)) return true
|
||||
if (!myId.value) return false
|
||||
return e.allHands || e.attendees.some((a) => a.id === myId.value)
|
||||
}
|
||||
// 화면 표시 대상 일정 — '내 일정만' 토글 적용(카테고리 필터는 뷰 컴포넌트가 처리)
|
||||
// 화면 표시 대상 일정 — 캘린더 선택과 '내 관련' 필터를 겹쳐서 적용한다.
|
||||
// (카테고리 단위 필터는 뷰 컴포넌트가 activeTypeIds 로 처리)
|
||||
const displayEvents = computed(() =>
|
||||
mineOnly.value ? events.value.filter(isMine) : events.value,
|
||||
events.value.filter(
|
||||
(e) => inActiveCalendar(e) && (!mineOnly.value || isMine(e)),
|
||||
),
|
||||
)
|
||||
|
||||
// 카테고리 그룹 — 필터/선택 UI 에서 사내 공용과 내 개인을 나눠 보여준다.
|
||||
const sortedCategories = computed(() =>
|
||||
[...categories.value].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
)
|
||||
const companyCategories = computed(() =>
|
||||
sortedCategories.value.filter((c) => !c.personal),
|
||||
)
|
||||
const personalCategories = computed(() =>
|
||||
sortedCategories.value.filter((c) => c.personal),
|
||||
)
|
||||
// 유형 필터에 노출할 카테고리 — 꺼진 캘린더는 그룹째 숨겨 캘린더 스위치와 역할이 겹치지 않게 한다.
|
||||
const filterCompanyCategories = computed(() =>
|
||||
showCompany.value ? companyCategories.value : [],
|
||||
)
|
||||
const filterPersonalCategories = computed(() =>
|
||||
showPersonal.value ? personalCategories.value : [],
|
||||
)
|
||||
// 주간 뷰 레인 — 꺼진 캘린더의 레인은 빈 줄로 남지 않도록 제외한다.
|
||||
const displayCategories = computed(() => [
|
||||
...filterCompanyCategories.value,
|
||||
...filterPersonalCategories.value,
|
||||
])
|
||||
|
||||
// 카테고리별 현재 범위 일정 수(필터 드롭다운 표기용) — 표시 대상 기준
|
||||
const counts = computed<Record<string, number>>(() => {
|
||||
const c: Record<string, number> = {}
|
||||
@@ -144,10 +186,18 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
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()
|
||||
const next = new Set(activeTypeIds.value)
|
||||
for (const c of displayCategories.value) {
|
||||
if (on) next.add(c.id)
|
||||
else next.delete(c.id)
|
||||
}
|
||||
activeTypeIds.value = next
|
||||
}
|
||||
function toggleCalendar(kind: ScheduleCalendar): void {
|
||||
if (kind === 'company') showCompany.value = !showCompany.value
|
||||
else showPersonal.value = !showPersonal.value
|
||||
}
|
||||
function toggleMineOnly(): void {
|
||||
mineOnly.value = !mineOnly.value
|
||||
@@ -206,10 +256,15 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
|
||||
return {
|
||||
categories,
|
||||
filterCompanyCategories,
|
||||
filterPersonalCategories,
|
||||
displayCategories,
|
||||
events,
|
||||
displayEvents,
|
||||
people,
|
||||
activeTypeIds,
|
||||
showCompany,
|
||||
showPersonal,
|
||||
mineOnly,
|
||||
viewMode,
|
||||
weekStart,
|
||||
@@ -226,6 +281,7 @@ export const useScheduleStore = defineStore('schedule', () => {
|
||||
goToday,
|
||||
toggleType,
|
||||
setAllTypes,
|
||||
toggleCalendar,
|
||||
toggleMineOnly,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
// 일정(전사 공유 캘린더) 도메인 타입 — 백엔드 Schedule 응답과 1:1
|
||||
// 일정(전사 공유 캘린더 + 개인 일정) 도메인 타입 — 백엔드 Schedule 응답과 1:1
|
||||
|
||||
// 카테고리(타임라인 레인)
|
||||
// 일정 공개 범위 — company: 전사 공유, private: 본인만
|
||||
export type ScheduleVisibility = 'company' | 'private'
|
||||
|
||||
// 카테고리(타임라인 레인) — personal 이면 본인만 보는 개인 카테고리
|
||||
export interface ApiScheduleCategory {
|
||||
id: string
|
||||
label: string
|
||||
color: string
|
||||
sortOrder: number
|
||||
personal: boolean
|
||||
}
|
||||
|
||||
// 참석자/사용자(공통 경량 형태)
|
||||
@@ -28,6 +32,10 @@ export interface ApiScheduleEvent {
|
||||
allHands: boolean
|
||||
attendees: ApiSchedulePerson[]
|
||||
createdById: string | null
|
||||
visibility: ScheduleVisibility
|
||||
ownerId: string | null
|
||||
// 서버가 판정한 수정·삭제 권한 — 개인 일정은 소유자, 전사 일정은 작성자
|
||||
canManage: boolean
|
||||
}
|
||||
|
||||
// 생성/수정 payload
|
||||
@@ -46,4 +54,6 @@ export interface ScheduleEventPayload {
|
||||
export interface ScheduleCategoryPayload {
|
||||
label: string
|
||||
color: string
|
||||
// 개인 전용 카테고리 여부 — 생성 시에만 유효(수정으로 전환 불가)
|
||||
personal?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user