Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 61d247de08 | |||
| ddf94b188b | |||
| daff61c85e | |||
| 3380bbdcff | |||
| 21cabbe20b | |||
| d3e2861440 | |||
| e7760e9ddc | |||
| d3a916dbd7 | |||
| 7425d57134 | |||
| 0c4f0817e6 | |||
| 5f8b2597bf | |||
| 92ed6c332f | |||
| d5b54c1afb | |||
| 346ec75553 | |||
| 62df92e6eb |
@@ -53,3 +53,4 @@ Thumbs.db
|
||||
|
||||
# 디자인 시안 단독 HTML(참고용, 대용량 번들) — 커밋 제외
|
||||
frontend/*단독*.html
|
||||
frontend/*단일 파일*.html
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -569,6 +569,90 @@ body {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 폼 필드 (폼 모달 공통 — BaseModal 본문 등에서 사용)
|
||||
* ============================================================ */
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.form-label {
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.form-row .form-field {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.form-input {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.form-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.form-textarea {
|
||||
height: auto;
|
||||
min-height: 5.75rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
/* 네이티브 select 래퍼 + 셰브론 */
|
||||
.form-select-wrap {
|
||||
position: relative;
|
||||
}
|
||||
.form-select {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
padding: 0 2.125rem 0 0.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.form-select-chev {
|
||||
position: absolute;
|
||||
right: 0.625rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: grid;
|
||||
color: var(--text-3);
|
||||
pointer-events: none;
|
||||
}
|
||||
.form-select-chev svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 더보기 버튼 (목록 페이지네이션 공통)
|
||||
* ============================================================ */
|
||||
@@ -593,3 +677,17 @@ body {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* 빈/상태 메시지 박스 (목록 로딩·빈 상태 공통)
|
||||
* ============================================================ */
|
||||
.empty-state {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
// 커스텀 드롭다운(셀렉트) — 네이티브 select 대신 디자인 토큰에 맞춘 정렬/필터 공통 UI.
|
||||
// 메뉴는 body 로 Teleport + fixed 위치로 띄워, 스크롤/overflow 컨테이너 안에서도 잘리지 않는다.
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useDropdownMenu } from '@/composables/useDropdownMenu'
|
||||
|
||||
interface Option {
|
||||
value: T
|
||||
@@ -23,75 +24,25 @@ const props = withDefaults(
|
||||
)
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
// 메뉴 fixed 위치(트리거 기준 계산)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅
|
||||
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
|
||||
align: props.align,
|
||||
matchWidth: true,
|
||||
})
|
||||
|
||||
// 현재 선택된 옵션 라벨(트리거에 표시)
|
||||
const selectedLabel = computed(
|
||||
() => props.options.find((o) => o.value === props.modelValue)?.label ?? '',
|
||||
)
|
||||
|
||||
// 트리거 위치 기준으로 메뉴 좌표 계산(뷰포트 fixed)
|
||||
function positionMenu(): void {
|
||||
const el = triggerRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const style: Record<string, string> = {
|
||||
top: `${r.bottom + 6}px`,
|
||||
minWidth: `${r.width}px`,
|
||||
}
|
||||
if (props.align === 'right') {
|
||||
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
|
||||
} else {
|
||||
style.left = `${r.left}px`
|
||||
}
|
||||
menuStyle.value = style
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
open.value = !open.value
|
||||
if (open.value) void nextTick(positionMenu)
|
||||
}
|
||||
function select(value: T): void {
|
||||
emit('update:modelValue', value)
|
||||
open.value = false
|
||||
close()
|
||||
}
|
||||
|
||||
// 바깥 클릭(트리거/메뉴 외부) / Esc 로 닫기
|
||||
function onDocClick(e: MouseEvent): void {
|
||||
if (!open.value) return
|
||||
const t = e.target as Node
|
||||
if (root.value?.contains(t) || menuRef.value?.contains(t)) return
|
||||
open.value = false
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (open.value && e.key === 'Escape') open.value = false
|
||||
}
|
||||
// 스크롤/리사이즈 시 위치가 어긋나므로 닫는다(간단·안전)
|
||||
function onReposition(): void {
|
||||
if (open.value) open.value = false
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
window.addEventListener('scroll', onReposition, true)
|
||||
window.addEventListener('resize', onReposition)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick)
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
window.removeEventListener('scroll', onReposition, true)
|
||||
window.removeEventListener('resize', onReposition)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="dd"
|
||||
:class="{ push: pushRight, block }"
|
||||
>
|
||||
|
||||
@@ -1,60 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
// 진행 상태(대기/진행 중/완료) 선택 드롭다운 — 상태색 테마. 메뉴는 body Teleport(스크롤 클리핑 방지).
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import { useDropdownMenu } from '@/composables/useDropdownMenu'
|
||||
import {
|
||||
WORK_STATUS_META as META,
|
||||
WORK_STATUS_ORDER as ORDER,
|
||||
} from '@/shared/constants/workStatus'
|
||||
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
|
||||
|
||||
const props = defineProps<{ modelValue: ChecklistWorkStatus }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: ChecklistWorkStatus): void }>()
|
||||
|
||||
const META: Record<ChecklistWorkStatus, { label: string; color: string; weak: string }> = {
|
||||
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
|
||||
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
|
||||
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
|
||||
}
|
||||
const ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
|
||||
const cur = computed(() => META[props.modelValue])
|
||||
|
||||
const open = ref(false)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
// 메뉴 위치/토글/바깥클릭 닫기 — 공용 훅(우측 정렬, 너비는 CSS 고정)
|
||||
const { open, triggerRef, menuRef, menuStyle, toggle, close } = useDropdownMenu({
|
||||
align: 'right',
|
||||
gap: 5,
|
||||
})
|
||||
|
||||
function positionMenu(): void {
|
||||
const el = triggerRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
menuStyle.value = {
|
||||
top: `${r.bottom + 5}px`,
|
||||
right: `${Math.max(0, window.innerWidth - r.right)}px`,
|
||||
}
|
||||
}
|
||||
function toggle(): void {
|
||||
open.value = !open.value
|
||||
if (open.value) void nextTick(positionMenu)
|
||||
}
|
||||
function pick(v: ChecklistWorkStatus): void {
|
||||
emit('update:modelValue', v)
|
||||
open.value = false
|
||||
close()
|
||||
}
|
||||
function onDoc(e: MouseEvent): void {
|
||||
if (!open.value) return
|
||||
const t = e.target as Node
|
||||
if (triggerRef.value?.contains(t) || menuRef.value?.contains(t)) return
|
||||
open.value = false
|
||||
}
|
||||
function onReposition(): void {
|
||||
if (open.value) open.value = false
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDoc)
|
||||
window.addEventListener('scroll', onReposition, true)
|
||||
window.addEventListener('resize', onReposition)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDoc)
|
||||
window.removeEventListener('scroll', onReposition, true)
|
||||
window.removeEventListener('resize', onReposition)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
<script setup lang="ts">
|
||||
// 공용 폼 모달 셸 — backdrop + 헤더(제목/닫기) + 본문 슬롯 + 푸터 슬롯.
|
||||
// Teleport(body) · ESC · 바깥(backdrop) 클릭 닫기 내장.
|
||||
// 전역 .modal*(확인 다이얼로그 전용)과 충돌하지 않도록 bm- 접두 클래스를 쓴다.
|
||||
// 폼 필드는 전역 .form-* 클래스(relay.css)를 본문 슬롯에서 사용한다.
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
// 너비: md(기본 33.75rem) | sm(27.5rem)
|
||||
size?: 'md' | 'sm'
|
||||
}>(),
|
||||
{ size: 'md' },
|
||||
)
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (e.key === 'Escape') emit('close')
|
||||
}
|
||||
onMounted(() => document.addEventListener('keydown', onKey))
|
||||
onBeforeUnmount(() => document.removeEventListener('keydown', onKey))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="bm-back"
|
||||
@mousedown.self="emit('close')"
|
||||
>
|
||||
<div
|
||||
class="bm"
|
||||
:class="{ 'bm-sm': size === 'sm' }"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="bm-head">
|
||||
<div class="bm-head-text">
|
||||
<h3>{{ title }}</h3>
|
||||
<div
|
||||
v-if="$slots.subtitle"
|
||||
class="bm-sub"
|
||||
>
|
||||
<slot name="subtitle" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="bm-x"
|
||||
aria-label="닫기"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bm-body">
|
||||
<slot />
|
||||
</div>
|
||||
<div
|
||||
v-if="$slots.foot"
|
||||
class="bm-foot"
|
||||
>
|
||||
<slot name="foot" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.bm-back {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(20, 24, 33, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
.bm {
|
||||
width: 100%;
|
||||
max-width: 33.75rem;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.bm.bm-sm {
|
||||
max-width: 27.5rem;
|
||||
}
|
||||
.bm-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 1.375rem 1.5rem 1rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #fff;
|
||||
border-radius: 0.875rem 0.875rem 0 0;
|
||||
z-index: 1;
|
||||
}
|
||||
.bm-head h3 {
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01875rem;
|
||||
}
|
||||
.bm-sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.bm-sub :deep(b) {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.bm-x {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.bm-x {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-right: -0.3125rem;
|
||||
}
|
||||
.bm-x:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.bm-x svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.bm-body {
|
||||
padding: 0.5rem 1.5rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.125rem;
|
||||
text-align: left;
|
||||
}
|
||||
.bm-foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5625rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
border-radius: 0 0 0.875rem 0.875rem;
|
||||
}
|
||||
/* 푸터 버튼 — 전역 .mbtn(확인 다이얼로그용 flex:1) 대신 폼 모달용 우측정렬 컴팩트 */
|
||||
.bm-foot :deep(.mbtn) {
|
||||
flex: 0 0 auto;
|
||||
height: 2.375rem;
|
||||
padding: 0 1rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.bm-foot :deep(.mbtn svg) {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.bm-foot :deep(.mbtn:disabled) {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.bm-foot :deep(.mbtn.primary:disabled),
|
||||
.bm-foot :deep(.mbtn.primary:disabled:hover) {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
// 빈/상태 메시지 박스 공용 컴포넌트 — 목록 로딩·빈 상태 등에 사용.
|
||||
// 전역 .empty-state 카드 스타일을 쓰고, 메시지는 기본 슬롯으로 전달한다.
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
// 목록 페이지네이션 '더보기' 버튼 공용 컴포넌트 — 전역 .load-more 스타일 사용.
|
||||
// 부모는 보통 v-if="hasMore" 로 노출하고, @load 에서 다음 페이지를 불러온다.
|
||||
defineProps<{ loading?: boolean }>()
|
||||
const emit = defineEmits<{ (e: 'load'): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loading"
|
||||
@click="emit('load')"
|
||||
>
|
||||
{{ loading ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts" generic="T extends string">
|
||||
// 세그먼트 토글(필터/탭) 공용 컴포넌트 — 전역 .segmented 스타일을 그대로 사용.
|
||||
// options 의 count 가 있으면 라벨 뒤에 수량(.n)을 표기한다.
|
||||
interface SegOption {
|
||||
value: T
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
defineProps<{ modelValue: T; options: SegOption[] }>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', value: T): void }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="segmented">
|
||||
<button
|
||||
v-for="o in options"
|
||||
:key="o.value"
|
||||
type="button"
|
||||
:class="{ active: o.value === modelValue }"
|
||||
@click="emit('update:modelValue', o.value)"
|
||||
>
|
||||
{{ o.label }}<span
|
||||
v-if="o.count !== undefined"
|
||||
class="n"
|
||||
>{{ o.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
// 일정 참석자 아바타 — 사이트 공용 UserAvatar 를 size(px)로 감싸는 얇은 어댑터.
|
||||
// 렌더링(이미지/실루엣)은 UserAvatar 가 단일 책임지고, 여기선 크기/원형 래퍼만 담당.
|
||||
// (.sa 클래스는 상세 패널 아바타 스택 CSS 의 훅으로 유지)
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
name?: string
|
||||
avatarUrl?: string | null
|
||||
size?: number
|
||||
}>(),
|
||||
{ name: '', avatarUrl: null, size: 21 },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="sa"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
>
|
||||
<UserAvatar
|
||||
:url="avatarUrl"
|
||||
:name="name"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sa {
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,167 @@
|
||||
<script setup lang="ts">
|
||||
// 카테고리 생성/수정 모달 — 이름 + 색상(hue 슬라이더)
|
||||
import { computed, ref } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import type {
|
||||
ApiScheduleCategory,
|
||||
ScheduleCategoryPayload,
|
||||
} from '@/types/schedule'
|
||||
|
||||
const props = defineProps<{ init: ApiScheduleCategory | null }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', payload: ScheduleCategoryPayload): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const CAT_SAT = 62
|
||||
const CAT_LIG = 46
|
||||
|
||||
function hslToHex(h: number, s: number, l: number): string {
|
||||
s /= 100
|
||||
l /= 100
|
||||
const k = (n: number) => (n + h / 30) % 12
|
||||
const a = s * Math.min(l, 1 - l)
|
||||
const f = (n: number) =>
|
||||
l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)))
|
||||
const to = (x: number) =>
|
||||
Math.round(255 * x)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${to(f(0))}${to(f(8))}${to(f(4))}`
|
||||
}
|
||||
function hexToHue(hex: string): number {
|
||||
const m = hex.replace('#', '')
|
||||
const r = parseInt(m.slice(0, 2), 16) / 255
|
||||
const g = parseInt(m.slice(2, 4), 16) / 255
|
||||
const b = parseInt(m.slice(4, 6), 16) / 255
|
||||
const max = Math.max(r, g, b)
|
||||
const min = Math.min(r, g, b)
|
||||
const d = max - min
|
||||
let h = 0
|
||||
if (d) {
|
||||
if (max === r) h = ((g - b) / d) % 6
|
||||
else if (max === g) h = (b - r) / d + 2
|
||||
else h = (r - g) / d + 4
|
||||
h *= 60
|
||||
if (h < 0) h += 360
|
||||
}
|
||||
return Math.round(h)
|
||||
}
|
||||
|
||||
const isEdit = computed(() => !!props.init)
|
||||
const name = ref(props.init?.label ?? '')
|
||||
const hue = ref(props.init?.color ? hexToHue(props.init.color) : 210)
|
||||
const color = computed(() => hslToHex(hue.value, CAT_SAT, CAT_LIG))
|
||||
const valid = computed(() => !!name.value.trim())
|
||||
|
||||
function submit(): void {
|
||||
if (!valid.value) return
|
||||
emit('save', { label: name.value.trim(), color: color.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:title="isEdit ? '카테고리 편집' : '새 카테고리'"
|
||||
size="sm"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<label class="form-field"><span class="form-label">이름</span>
|
||||
<input
|
||||
v-model="name"
|
||||
class="form-input"
|
||||
placeholder="카테고리 이름"
|
||||
autofocus
|
||||
@keydown.enter="submit"
|
||||
>
|
||||
</label>
|
||||
<div class="form-field">
|
||||
<span class="form-label">색상</span>
|
||||
<div class="cat-color">
|
||||
<span
|
||||
class="cat-dot"
|
||||
:style="{ background: color }"
|
||||
/>
|
||||
<input
|
||||
v-model.number="hue"
|
||||
type="range"
|
||||
min="0"
|
||||
max="359"
|
||||
class="hue-slider"
|
||||
:style="{ '--cur': color }"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #foot>
|
||||
<button
|
||||
class="mbtn"
|
||||
@click="emit('close')"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="mbtn primary"
|
||||
:disabled="!valid"
|
||||
@click="submit"
|
||||
>
|
||||
{{ isEdit ? '저장' : '추가' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cat-color {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.cat-dot {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.hue-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
flex: 1;
|
||||
height: 1rem;
|
||||
margin: 0.375rem 0;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#f00 0%,
|
||||
#ff0 17%,
|
||||
#0f0 33%,
|
||||
#0ff 50%,
|
||||
#00f 67%,
|
||||
#f0f 83%,
|
||||
#f00 100%
|
||||
);
|
||||
}
|
||||
.hue-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--cur);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(20, 24, 33, 0.4);
|
||||
cursor: grab;
|
||||
}
|
||||
.hue-slider::-moz-range-thumb {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
background: var(--cur);
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 1px 4px rgba(20, 24, 33, 0.4);
|
||||
cursor: grab;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,486 @@
|
||||
<script setup lang="ts">
|
||||
// 우측 슬라이드 상세 패널 — 선택 일정의 시간/장소/참석자/메모 + 수정·삭제
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import ScheduleAvatar from './ScheduleAvatar.vue'
|
||||
import { eventDateText, spanDays } from '@/shared/utils/scheduleDate'
|
||||
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
|
||||
|
||||
const props = defineProps<{
|
||||
event: ApiScheduleEvent | null
|
||||
category: ApiScheduleCategory | null
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'edit', ev: ApiScheduleEvent): void
|
||||
(e: 'delete', ev: ApiScheduleEvent): void
|
||||
}>()
|
||||
|
||||
// 닫히는 애니메이션 동안 직전 내용 유지
|
||||
const shown = ref<ApiScheduleEvent | null>(props.event)
|
||||
watch(
|
||||
() => props.event,
|
||||
(v) => {
|
||||
if (v) shown.value = v
|
||||
},
|
||||
)
|
||||
const open = computed(() => !!props.event)
|
||||
const ev = computed(() => props.event ?? shown.value)
|
||||
const multi = computed(() => (ev.value ? ev.value.start !== ev.value.end : false))
|
||||
const dateText = computed(() =>
|
||||
ev.value ? eventDateText(ev.value.start, ev.value.end) : '',
|
||||
)
|
||||
const dur = computed(() =>
|
||||
ev.value && multi.value ? spanDays(ev.value.start, ev.value.end) : 0,
|
||||
)
|
||||
|
||||
// 참석자 스택 토글
|
||||
const attOpen = ref(false)
|
||||
const attRef = ref<HTMLElement | null>(null)
|
||||
useClickOutside(attOpen, () => (attOpen.value = false), [attRef], { esc: false })
|
||||
|
||||
const MAX = 6
|
||||
const attendees = computed(() => ev.value?.attendees ?? [])
|
||||
const shownAtt = computed(() =>
|
||||
attendees.value.length > MAX
|
||||
? attendees.value.slice(0, MAX - 1)
|
||||
: attendees.value,
|
||||
)
|
||||
const extra = computed(() =>
|
||||
attendees.value.length > MAX ? attendees.value.length - (MAX - 1) : 0,
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="detail"
|
||||
:class="{ open }"
|
||||
>
|
||||
<template v-if="ev">
|
||||
<div class="dt-head">
|
||||
<div class="dt-top">
|
||||
<span
|
||||
v-if="category"
|
||||
class="type-chip"
|
||||
:style="{
|
||||
background: `color-mix(in srgb, ${category.color} 13%, #fff)`,
|
||||
color: category.color,
|
||||
}"
|
||||
>{{ category.label }}</span>
|
||||
<span v-else />
|
||||
<button
|
||||
class="dt-close"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<h2>{{ ev.title }}</h2>
|
||||
<div class="dt-date">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><rect
|
||||
x="3"
|
||||
y="4"
|
||||
width="18"
|
||||
height="18"
|
||||
rx="2"
|
||||
/><path d="M16 2v4M8 2v4M3 10h18" /></svg>
|
||||
{{ dateText }}
|
||||
<span
|
||||
v-if="multi"
|
||||
class="dt-dur"
|
||||
>{{ dur }}일간</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dt-body">
|
||||
<div class="dt-list">
|
||||
<div class="dt-item">
|
||||
<span class="dt-k">시간</span>
|
||||
<span class="dt-v">{{ ev.time || '—' }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="ev.place && ev.place !== '—'"
|
||||
class="dt-item"
|
||||
>
|
||||
<span class="dt-k">장소</span>
|
||||
<span class="dt-v">{{ ev.place }}</span>
|
||||
</div>
|
||||
<div class="dt-item">
|
||||
<span class="dt-k">참석자</span>
|
||||
<span class="dt-v">
|
||||
<template v-if="ev.allHands">전 직원 대상</template>
|
||||
<div
|
||||
v-else-if="attendees.length > 0"
|
||||
ref="attRef"
|
||||
class="att-stack"
|
||||
>
|
||||
<button
|
||||
class="att-trigger"
|
||||
:class="{ open: attOpen }"
|
||||
@click="attOpen = !attOpen"
|
||||
>
|
||||
<span class="avstack">
|
||||
<ScheduleAvatar
|
||||
v-for="p in shownAtt"
|
||||
:key="p.id"
|
||||
:name="p.name"
|
||||
:avatar-url="p.avatarUrl"
|
||||
:size="30"
|
||||
/>
|
||||
<span
|
||||
v-if="extra > 0"
|
||||
class="av-more"
|
||||
>+{{ extra }}</span>
|
||||
</span>
|
||||
<span class="att-chev">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="attOpen"
|
||||
class="att-panel"
|
||||
>
|
||||
<div
|
||||
v-for="p in attendees"
|
||||
:key="p.id"
|
||||
class="att-row"
|
||||
>
|
||||
<ScheduleAvatar
|
||||
:name="p.name"
|
||||
:avatar-url="p.avatarUrl"
|
||||
:size="24"
|
||||
/>{{ p.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template v-else>참석자 없음</template>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="ev.description"
|
||||
class="dt-item"
|
||||
>
|
||||
<span class="dt-k">메모</span>
|
||||
<span class="dt-v dt-v-memo">{{ ev.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dt-foot">
|
||||
<div class="dt-actions">
|
||||
<button
|
||||
class="btn"
|
||||
@click="emit('edit', ev)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>일정 수정
|
||||
</button>
|
||||
<button
|
||||
class="btn danger icon-only"
|
||||
title="삭제"
|
||||
@click="emit('delete', ev)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 360px;
|
||||
z-index: 30;
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.26s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
}
|
||||
.detail.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.type-chip {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 9px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.dt-head {
|
||||
padding: 20px 22px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dt-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.dt-close {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-right: -6px;
|
||||
}
|
||||
.dt-close:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.dt-close svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
}
|
||||
.dt-head h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
line-height: 1.4;
|
||||
margin: 13px 0 8px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
.dt-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.dt-date svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.dt-dur {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
background: var(--accent-weak);
|
||||
border-radius: 5px;
|
||||
padding: 1px 7px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dt-body {
|
||||
padding: 18px 22px 24px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.dt-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.dt-item {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.dt-k {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
width: 52px;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
.dt-v {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
word-break: keep-all;
|
||||
}
|
||||
.dt-v-memo {
|
||||
font-weight: 400;
|
||||
color: var(--text-2);
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.att-stack {
|
||||
position: relative;
|
||||
margin: -3px 0;
|
||||
}
|
||||
.att-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
padding: 5px 8px 5px 5px;
|
||||
margin: -5px -8px -5px -5px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
.att-trigger:hover,
|
||||
.att-trigger.open {
|
||||
background: #f1f2f4;
|
||||
}
|
||||
.avstack {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
.avstack :deep(.sa),
|
||||
.avstack .av-more {
|
||||
margin-left: -9px;
|
||||
box-shadow: 0 0 0 2px var(--panel);
|
||||
}
|
||||
.avstack :deep(.sa):first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
.av-more {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: #eceef1;
|
||||
color: var(--text-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.att-chev {
|
||||
color: var(--text-3);
|
||||
display: grid;
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.att-chev svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.att-trigger.open .att-chev {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.att-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
min-width: 200px;
|
||||
max-width: 260px;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
z-index: 20;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 5px;
|
||||
}
|
||||
.att-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.att-row:hover {
|
||||
background: #f4f5f7;
|
||||
}
|
||||
.dt-foot {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 22px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.dt-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.dt-actions .btn {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
height: 36px;
|
||||
}
|
||||
.dt-actions .btn.icon-only {
|
||||
flex: 0 0 auto;
|
||||
padding: 0;
|
||||
width: 36px;
|
||||
}
|
||||
.btn {
|
||||
border-radius: var(--radius);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--panel);
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover {
|
||||
background: #f7f8fa;
|
||||
color: var(--text);
|
||||
}
|
||||
.btn svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.btn.danger {
|
||||
background: #fff;
|
||||
border-color: #f1c9c9;
|
||||
color: #dc2626;
|
||||
}
|
||||
.btn.danger:hover {
|
||||
background: #fdeaea;
|
||||
border-color: #ec9d9d;
|
||||
color: #b91c1c;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,444 @@
|
||||
<script setup lang="ts">
|
||||
// 일정 생성/수정 모달
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import ScheduleAvatar from './ScheduleAvatar.vue'
|
||||
import type {
|
||||
ApiScheduleCategory,
|
||||
ApiScheduleEvent,
|
||||
ApiSchedulePerson,
|
||||
ScheduleEventPayload,
|
||||
} from '@/types/schedule'
|
||||
|
||||
const props = defineProps<{
|
||||
categories: ApiScheduleCategory[]
|
||||
people: ApiSchedulePerson[]
|
||||
init: ApiScheduleEvent | null
|
||||
defaultDate: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'save', payload: ScheduleEventPayload): void
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const isEdit = computed(() => !!props.init)
|
||||
|
||||
// init.time → 시작/종료 HH:MM 파싱
|
||||
function parseTime(tm: string): { start: string; end: string } {
|
||||
if (/\d/.test(tm) && (tm.includes('–') || tm.includes('-'))) {
|
||||
const parts = tm.split(/[–-]/).map((s) => s.trim())
|
||||
const a = parts[0] ?? ''
|
||||
const b = parts[1] ?? ''
|
||||
return {
|
||||
start: /^\d{1,2}:\d{2}/.test(a) ? a : '09:00',
|
||||
end: /^\d{1,2}:\d{2}/.test(b) ? b : '',
|
||||
}
|
||||
}
|
||||
if (/^\d{1,2}:\d{2}/.test(tm)) return { start: tm.trim(), end: '' }
|
||||
return { start: '09:00', end: '10:00' }
|
||||
}
|
||||
|
||||
const i = props.init
|
||||
const pt = parseTime(i?.time ?? '')
|
||||
const ordered = computed(() =>
|
||||
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
)
|
||||
|
||||
const title = ref(i?.title ?? '')
|
||||
const categoryId = ref(i?.categoryId ?? ordered.value[0]?.id ?? '')
|
||||
const s = ref(i?.start ?? props.defaultDate)
|
||||
const e = ref(i?.end ?? i?.start ?? props.defaultDate)
|
||||
const startT = ref(pt.start)
|
||||
const endT = ref(pt.end)
|
||||
const place = ref(i?.place && i.place !== '—' ? i.place : '')
|
||||
const desc = ref(i?.description ?? '')
|
||||
const allHands = ref(i?.allHands ?? false)
|
||||
const attendeeIds = ref<string[]>(i?.attendees.map((a) => a.id) ?? [])
|
||||
|
||||
// 시작일 변경 시 종료일이 더 빠르면 맞춰줌
|
||||
watch(s, (v) => {
|
||||
if (e.value < v) e.value = v
|
||||
})
|
||||
|
||||
const valid = computed(
|
||||
() =>
|
||||
!!title.value.trim() &&
|
||||
!!categoryId.value &&
|
||||
!!s.value &&
|
||||
!!e.value &&
|
||||
e.value >= s.value &&
|
||||
!!startT.value,
|
||||
)
|
||||
|
||||
// 참석자 멀티셀렉트
|
||||
const msOpen = ref(false)
|
||||
const msRef = ref<HTMLElement | null>(null)
|
||||
const msLabel = computed(() => {
|
||||
if (allHands.value) return '전 직원'
|
||||
if (attendeeIds.value.length === 0) return '참석자 선택'
|
||||
return props.people
|
||||
.filter((p) => attendeeIds.value.includes(p.id))
|
||||
.map((p) => p.name)
|
||||
.join(', ')
|
||||
})
|
||||
function toggleAll(): void {
|
||||
allHands.value = !allHands.value
|
||||
if (allHands.value) attendeeIds.value = []
|
||||
}
|
||||
function togglePerson(id: string): void {
|
||||
allHands.value = false
|
||||
attendeeIds.value = attendeeIds.value.includes(id)
|
||||
? attendeeIds.value.filter((x) => x !== id)
|
||||
: [...attendeeIds.value, id]
|
||||
}
|
||||
// 참석자 멀티셀렉트 바깥클릭 닫기(ESC 는 모달 닫기에서 처리)
|
||||
useClickOutside(msOpen, () => (msOpen.value = false), [msRef], { esc: false })
|
||||
|
||||
function submit(): void {
|
||||
if (!valid.value) return
|
||||
const time = endT.value ? `${startT.value} – ${endT.value}` : startT.value
|
||||
emit('save', {
|
||||
title: title.value.trim(),
|
||||
categoryId: categoryId.value,
|
||||
start: s.value,
|
||||
end: e.value,
|
||||
time,
|
||||
place: place.value.trim(),
|
||||
description: desc.value.trim(),
|
||||
allHands: allHands.value,
|
||||
attendeeIds: allHands.value ? [] : attendeeIds.value,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal
|
||||
:title="isEdit ? '일정 수정' : '새 일정'"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<label class="form-field"><span class="form-label">제목</span>
|
||||
<input
|
||||
v-model="title"
|
||||
class="form-input"
|
||||
placeholder="일정 제목"
|
||||
autofocus
|
||||
>
|
||||
</label>
|
||||
<label class="form-field"><span class="form-label">유형</span>
|
||||
<div class="form-select-wrap">
|
||||
<select
|
||||
v-model="categoryId"
|
||||
class="form-select"
|
||||
>
|
||||
<option
|
||||
v-for="c in ordered"
|
||||
:key="c.id"
|
||||
:value="c.id"
|
||||
>
|
||||
{{ c.label }}
|
||||
</option>
|
||||
</select>
|
||||
<span class="form-select-chev">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="form-row">
|
||||
<label class="form-field"><span class="form-label">시작일</span>
|
||||
<input
|
||||
v-model="s"
|
||||
type="date"
|
||||
class="form-input"
|
||||
>
|
||||
</label>
|
||||
<label class="form-field"><span class="form-label">종료일</span>
|
||||
<input
|
||||
v-model="e"
|
||||
type="date"
|
||||
class="form-input"
|
||||
:min="s"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<span class="form-label">시간</span>
|
||||
<div class="time-row">
|
||||
<input
|
||||
v-model="startT"
|
||||
type="time"
|
||||
class="form-input time-input"
|
||||
>
|
||||
<span class="time-dash">–</span>
|
||||
<input
|
||||
v-model="endT"
|
||||
type="time"
|
||||
class="form-input time-input"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-field"><span class="form-label">장소</span>
|
||||
<input
|
||||
v-model="place"
|
||||
class="form-input"
|
||||
placeholder="장소 또는 화상"
|
||||
>
|
||||
</label>
|
||||
<div class="form-field">
|
||||
<span class="form-label">참석자</span>
|
||||
<div
|
||||
ref="msRef"
|
||||
class="ms"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ms-field"
|
||||
:class="{ empty: !allHands && attendeeIds.length === 0, open: msOpen }"
|
||||
@click="msOpen = !msOpen"
|
||||
>
|
||||
<span class="ms-val">{{ msLabel }}</span>
|
||||
<span class="chev">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="msOpen"
|
||||
class="ms-panel"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="ms-item"
|
||||
:class="{ on: allHands }"
|
||||
@click="toggleAll"
|
||||
>
|
||||
<span class="ms-all">전</span>
|
||||
<span class="ms-name">전 직원</span>
|
||||
<span
|
||||
v-if="allHands"
|
||||
class="ms-check"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div class="ms-div" />
|
||||
<button
|
||||
v-for="p in people"
|
||||
:key="p.id"
|
||||
type="button"
|
||||
class="ms-item"
|
||||
:class="{ on: attendeeIds.includes(p.id) }"
|
||||
@click="togglePerson(p.id)"
|
||||
>
|
||||
<ScheduleAvatar
|
||||
:name="p.name"
|
||||
:avatar-url="p.avatarUrl"
|
||||
:size="20"
|
||||
/>
|
||||
<span class="ms-name">{{ p.name }}</span>
|
||||
<span
|
||||
v-if="attendeeIds.includes(p.id)"
|
||||
class="ms-check"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="people.length === 0"
|
||||
class="ms-empty"
|
||||
>
|
||||
등록된 사용자가 없습니다
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="form-field"><span class="form-label">설명</span>
|
||||
<textarea
|
||||
v-model="desc"
|
||||
class="form-input form-textarea"
|
||||
placeholder="메모"
|
||||
rows="3"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<template #foot>
|
||||
<button
|
||||
class="mbtn"
|
||||
@click="emit('close')"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="mbtn primary"
|
||||
:disabled="!valid"
|
||||
@click="submit"
|
||||
>
|
||||
{{ isEdit ? '저장' : '추가' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 시간 입력 행 */
|
||||
.time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
}
|
||||
.time-input {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.time-dash {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 참석자 멀티셀렉트 */
|
||||
.ms {
|
||||
position: relative;
|
||||
}
|
||||
.ms-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.625rem 0 0.75rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.ms-field.empty .ms-val {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ms-field.open {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.ms-field .ms-val {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ms-field .chev {
|
||||
display: grid;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.ms-field .chev svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.ms-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
max-height: 13.75rem;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.5625rem;
|
||||
box-shadow: var(--shadow-pop);
|
||||
padding: 0.375rem;
|
||||
}
|
||||
.ms-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
padding: 0.4375rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.ms-item:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.ms-item.on {
|
||||
color: var(--text);
|
||||
}
|
||||
.ms-item .ms-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ms-item .ms-check {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.ms-item .ms-check svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.ms-all {
|
||||
/* 참석자 아바타(ScheduleAvatar :size="20" = 20px)와 동일 크기 */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: #9298a3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.ms-div {
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
margin: 0.25rem 0.125rem;
|
||||
}
|
||||
.ms-empty {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,422 @@
|
||||
<script setup lang="ts">
|
||||
// 주간 타임라인(간트형) — 카테고리=레인, 일정=막대(다중일 span). 가로 스크롤 + 라벨 고정.
|
||||
import { computed, ref } from 'vue'
|
||||
import type { ApiScheduleCategory, ApiScheduleEvent } from '@/types/schedule'
|
||||
import type { WeekDay } from '@/shared/utils/scheduleDate'
|
||||
|
||||
const props = defineProps<{
|
||||
categories: ApiScheduleCategory[]
|
||||
events: ApiScheduleEvent[]
|
||||
days: WeekDay[]
|
||||
activeTypeIds: Set<string>
|
||||
selId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', id: string): void
|
||||
(e: 'add-category'): void
|
||||
}>()
|
||||
|
||||
const COLW = 156
|
||||
const LABELW = 132
|
||||
const ROWH = 32
|
||||
|
||||
const cols = computed(() => props.days.length)
|
||||
const colTpl = computed(
|
||||
() => `${LABELW}px repeat(${cols.value}, minmax(${COLW}px, 1fr))`,
|
||||
)
|
||||
const trackCols = computed(
|
||||
() => `repeat(${cols.value}, minmax(${COLW}px, 1fr))`,
|
||||
)
|
||||
const minW = computed(() => LABELW + cols.value * COLW)
|
||||
|
||||
// 활성 + 정렬된 레인
|
||||
const lanes = computed(() =>
|
||||
[...props.categories]
|
||||
.filter((c) => props.activeTypeIds.has(c.id))
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
)
|
||||
|
||||
interface Cov {
|
||||
ev: ApiScheduleEvent
|
||||
startIdx: number
|
||||
endIdx: number
|
||||
span: number
|
||||
row: number
|
||||
}
|
||||
|
||||
// 이벤트가 이번 주 days 안에서 차지하는 시작/끝 인덱스
|
||||
function coverage(ev: ApiScheduleEvent): Omit<Cov, 'row'> | null {
|
||||
let startIdx = -1
|
||||
let endIdx = -1
|
||||
props.days.forEach((d, i) => {
|
||||
if (d.iso >= ev.start && d.iso <= ev.end) {
|
||||
if (startIdx < 0) startIdx = i
|
||||
endIdx = i
|
||||
}
|
||||
})
|
||||
if (startIdx < 0) return null
|
||||
return { ev, startIdx, endIdx, span: endIdx - startIdx + 1 }
|
||||
}
|
||||
|
||||
// 겹침 회피 그리디 행 배치
|
||||
function packLane(catId: string): { items: Cov[]; rows: number } {
|
||||
const cov = props.events
|
||||
.filter((e) => e.categoryId === catId)
|
||||
.map(coverage)
|
||||
.filter((c): c is Omit<Cov, 'row'> => c !== null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.startIdx - b.startIdx || String(a.ev.time).localeCompare(String(b.ev.time)),
|
||||
)
|
||||
const rowEnd: number[] = []
|
||||
const items: Cov[] = cov.map((it) => {
|
||||
let r = 0
|
||||
while (r < rowEnd.length && (rowEnd[r] ?? -1) >= it.startIdx) r++
|
||||
rowEnd[r] = it.endIdx
|
||||
return { ...it, row: r }
|
||||
})
|
||||
return { items, rows: Math.max(rowEnd.length, 1) }
|
||||
}
|
||||
|
||||
const lanesPacked = computed(() =>
|
||||
lanes.value.map((c) => ({ cat: c, ...packLane(c.id) })),
|
||||
)
|
||||
|
||||
// 카테고리 색 → 막대 음영(약)
|
||||
function weakOf(color: string): string {
|
||||
return `color-mix(in srgb, ${color} 13%, #fff)`
|
||||
}
|
||||
|
||||
// 헤더/본문 가로 스크롤 동기화
|
||||
const headRef = ref<HTMLElement | null>(null)
|
||||
const bodyRef = ref<HTMLElement | null>(null)
|
||||
function onBodyScroll(): void {
|
||||
if (headRef.value && bodyRef.value)
|
||||
headRef.value.scrollLeft = bodyRef.value.scrollLeft
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tl">
|
||||
<!-- 요일 헤더 -->
|
||||
<div
|
||||
ref="headRef"
|
||||
class="tl-head-scroll"
|
||||
>
|
||||
<div
|
||||
class="tl-headrow"
|
||||
:style="{ gridTemplateColumns: colTpl, minWidth: `${minW}px` }"
|
||||
>
|
||||
<div class="tl-corner" />
|
||||
<div
|
||||
v-for="d in days"
|
||||
:key="d.iso"
|
||||
class="tl-dayhead"
|
||||
:class="{ 'is-today': d.isToday, weekend: d.weekend }"
|
||||
>
|
||||
<span class="sdow">{{ d.dow }}</span>
|
||||
<span class="sd">{{ d.d }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 본문 -->
|
||||
<div
|
||||
ref="bodyRef"
|
||||
class="tl-body-scroll"
|
||||
@scroll="onBodyScroll"
|
||||
>
|
||||
<div
|
||||
class="tl-body"
|
||||
:style="{ minWidth: `${minW}px` }"
|
||||
>
|
||||
<div
|
||||
v-if="lanesPacked.length === 0"
|
||||
class="tl-empty"
|
||||
>
|
||||
표시할 카테고리가 없습니다. 유형 필터에서 켜거나 카테고리를 추가하세요.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="lane in lanesPacked"
|
||||
:key="lane.cat.id"
|
||||
class="tl-row"
|
||||
:style="{ gridTemplateColumns: `${LABELW}px 1fr` }"
|
||||
>
|
||||
<!-- 레인 라벨 -->
|
||||
<div class="tl-label">
|
||||
<span
|
||||
class="dot"
|
||||
:style="{ background: lane.cat.color }"
|
||||
/>
|
||||
<span class="ll-name">{{ lane.cat.label }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 트랙 -->
|
||||
<div class="tl-track">
|
||||
<div
|
||||
class="tl-guides"
|
||||
:style="{ gridTemplateColumns: trackCols }"
|
||||
>
|
||||
<div
|
||||
v-for="d in days"
|
||||
:key="d.iso"
|
||||
class="g"
|
||||
:class="{ 'is-today': d.isToday, weekend: d.weekend }"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="tl-bars"
|
||||
:style="{
|
||||
gridTemplateColumns: trackCols,
|
||||
gridTemplateRows: `repeat(${lane.rows}, ${ROWH}px)`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="it in lane.items"
|
||||
:key="it.ev.id"
|
||||
class="tl-bar"
|
||||
:class="{ sel: it.ev.id === selId }"
|
||||
:style="{
|
||||
'--bar-color': lane.cat.color,
|
||||
'--bar-weak': weakOf(lane.cat.color),
|
||||
gridColumn: `${it.startIdx + 1} / span ${it.span}`,
|
||||
gridRow: it.row + 1,
|
||||
}"
|
||||
@click="emit('select', it.ev.id)"
|
||||
>
|
||||
<span class="bt">{{ it.ev.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리 추가 -->
|
||||
<div class="tl-addrow">
|
||||
<button
|
||||
class="tl-addcat"
|
||||
@click="emit('add-category')"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M5 12h14M12 5v14" /></svg>카테고리 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tl {
|
||||
background: var(--panel);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.tl-head-scroll {
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tl-body-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.tl-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tl-headrow {
|
||||
display: grid;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--border-strong);
|
||||
}
|
||||
.tl-corner {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 6;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
.tl-dayhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
padding: 9px 8px;
|
||||
text-align: center;
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.tl-dayhead .sdow {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
.tl-dayhead .sd {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 5px;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
.tl-dayhead.is-today .sdow {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tl-dayhead.is-today .sd {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
.tl-dayhead.weekend {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.tl-dayhead.weekend .sdow,
|
||||
.tl-dayhead.weekend .sd {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.tl-row {
|
||||
display: grid;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.tl-row:first-of-type {
|
||||
border-top: none;
|
||||
}
|
||||
.tl-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 0 8px 0 14px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 3;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tl-label .dot {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tl-label .ll-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tl-addrow {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.tl-addcat {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 11px 14px;
|
||||
background: var(--panel);
|
||||
border: none;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-3);
|
||||
cursor: pointer;
|
||||
}
|
||||
.tl-addcat:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
.tl-addcat svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.tl-track {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.tl-guides {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
}
|
||||
.tl-guides .g {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
.tl-guides .g.is-today {
|
||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
}
|
||||
.tl-guides .g.weekend {
|
||||
background: #fafbfc;
|
||||
}
|
||||
.tl-bars {
|
||||
position: relative;
|
||||
display: grid;
|
||||
row-gap: 7px;
|
||||
column-gap: 0;
|
||||
padding: 9px 0;
|
||||
}
|
||||
.tl-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
margin: 0 6px;
|
||||
padding: 0 11px;
|
||||
height: 100%;
|
||||
border-radius: 7px;
|
||||
background: var(--bar-weak);
|
||||
border-left: 3px solid var(--bar-color);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
box-shadow 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
.tl-bar:hover {
|
||||
box-shadow: 0 3px 10px rgba(20, 24, 33, 0.13);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.tl-bar .bt {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tl-bar.sel {
|
||||
box-shadow: 0 0 0 2px var(--accent);
|
||||
}
|
||||
.tl-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 40px;
|
||||
color: var(--text-3);
|
||||
font-size: 13.5px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,364 @@
|
||||
<script setup lang="ts">
|
||||
// 유형 필터 + 카테고리 관리 드롭다운
|
||||
import { computed, ref } from 'vue'
|
||||
import { useClickOutside } from '@/composables/useClickOutside'
|
||||
import type { ApiScheduleCategory } from '@/types/schedule'
|
||||
|
||||
const props = defineProps<{
|
||||
categories: ApiScheduleCategory[]
|
||||
counts: Record<string, number>
|
||||
activeTypeIds: Set<string>
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle', id: string): void
|
||||
(e: 'set-all', on: boolean): void
|
||||
(e: 'add'): void
|
||||
(e: 'edit', id: string): void
|
||||
(e: 'delete', id: string): void
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
useClickOutside(open, () => (open.value = false), [rootRef], { esc: false })
|
||||
|
||||
const ordered = computed(() =>
|
||||
[...props.categories].sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
)
|
||||
const allOn = computed(
|
||||
() =>
|
||||
ordered.value.length > 0 &&
|
||||
ordered.value.every((c) => props.activeTypeIds.has(c.id)),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="filter"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="filter-btn"
|
||||
:class="{ open }"
|
||||
@click="open = !open"
|
||||
>
|
||||
<span class="fic">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z" /></svg>
|
||||
</span>유형
|
||||
<span class="chev">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m6 9 6 6 6-6" /></svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="open"
|
||||
class="filter-menu"
|
||||
>
|
||||
<div class="fm-head">
|
||||
<span>유형 · 카테고리</span>
|
||||
<button
|
||||
class="fm-all"
|
||||
@click="emit('set-all', !allOn)"
|
||||
>
|
||||
{{ allOn ? '전체 해제' : '전체 선택' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="fm-list">
|
||||
<div
|
||||
v-for="c in ordered"
|
||||
:key="c.id"
|
||||
class="fm-item"
|
||||
:class="{ on: activeTypeIds.has(c.id) }"
|
||||
>
|
||||
<button
|
||||
class="fm-tog"
|
||||
@click="emit('toggle', c.id)"
|
||||
>
|
||||
<span class="fm-check">
|
||||
<svg
|
||||
v-if="activeTypeIds.has(c.id)"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.4"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span
|
||||
class="dot"
|
||||
:style="{ background: c.color }"
|
||||
/>
|
||||
<span class="fm-label">{{ c.label }}</span>
|
||||
<span class="fm-ct">{{ counts[c.id] || 0 }}</span>
|
||||
</button>
|
||||
<span class="fm-acts">
|
||||
<button
|
||||
class="fm-act"
|
||||
title="편집"
|
||||
@click="open = false; emit('edit', c.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="fm-act"
|
||||
title="삭제"
|
||||
@click="open = false; emit('delete', c.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" /><path d="M10 11v6M14 11v6" /></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="fm-add"
|
||||
@click="open = false; emit('add')"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M5 12h14M12 5v14" /></svg>새 카테고리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter {
|
||||
position: relative;
|
||||
}
|
||||
/* 트리거 — 기존 정렬/필터 드롭다운(DropdownSelect)과 동일한 pill 외형 */
|
||||
.filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4375rem;
|
||||
height: 2.25rem;
|
||||
padding: 0 0.625rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
}
|
||||
.filter-btn:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
.filter-btn.open {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.filter-btn .fic {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.filter-btn .fic svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
.filter-btn .chev {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-3);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
.filter-btn .chev svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
.filter-btn.open .chev {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.filter-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 7px);
|
||||
right: 0;
|
||||
z-index: 60;
|
||||
width: 264px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-pop);
|
||||
padding: 6px;
|
||||
}
|
||||
.fm-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 8px 9px;
|
||||
}
|
||||
.fm-head > span {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
letter-spacing: 0.3px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-all {
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-all:hover {
|
||||
background: var(--accent-weak);
|
||||
}
|
||||
.fm-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.fm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.fm-item:hover {
|
||||
background: #f4f5f7;
|
||||
}
|
||||
.fm-tog {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.fm-tog .dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.fm-tog .fm-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-tog .fm-ct {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.fm-tog .fm-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.fm-tog .fm-check svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
.fm-item:not(.on) .fm-label {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.fm-item:not(.on) .dot {
|
||||
background: #d7dae0 !important;
|
||||
}
|
||||
.fm-acts {
|
||||
display: none;
|
||||
gap: 1px;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.fm-item:hover .fm-acts {
|
||||
display: flex;
|
||||
}
|
||||
.fm-act {
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.fm-act:hover {
|
||||
background: #e3e5e9;
|
||||
color: var(--text);
|
||||
}
|
||||
.fm-act svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.fm-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border);
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
padding: 10px 8px 6px;
|
||||
margin-top: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.fm-add:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
.fm-add svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
import { onBeforeUnmount, onMounted, type Ref } from 'vue'
|
||||
|
||||
// 바깥 클릭 / ESC / 스크롤 시 닫기 공용 훅.
|
||||
// 드롭다운·팝오버·메뉴 등에서 반복되던 document 리스너 등록/해제 로직을 한 곳으로 모은다.
|
||||
//
|
||||
// - isOpen: 열림 상태 ref (닫혀 있으면 아무 동작 안 함)
|
||||
// - close: 닫기 콜백
|
||||
// - containers: "내부"로 간주할 엘리먼트 ref 목록. 클릭이 이 안이면 무시한다
|
||||
// (Teleport 로 body 에 띄운 메뉴는 트리거와 메뉴 ref 를 모두 넣는다)
|
||||
// - opts.event: 바깥 클릭 감지 이벤트 (기본 'mousedown')
|
||||
// - opts.esc: Escape 로 닫을지 (기본 true)
|
||||
// - opts.closeOnScroll: 스크롤/리사이즈 시 닫을지 — fixed 로 띄운 메뉴 위치 어긋남 방지 (기본 false)
|
||||
export function useClickOutside(
|
||||
isOpen: Ref<boolean>,
|
||||
close: () => void,
|
||||
containers: Ref<HTMLElement | null>[],
|
||||
opts: {
|
||||
event?: 'mousedown' | 'click'
|
||||
esc?: boolean
|
||||
closeOnScroll?: boolean
|
||||
} = {},
|
||||
): void {
|
||||
const { event = 'mousedown', esc = true, closeOnScroll = false } = opts
|
||||
|
||||
function onPointer(e: Event): void {
|
||||
if (!isOpen.value) return
|
||||
const t = e.target as Node
|
||||
if (containers.some((r) => r.value?.contains(t))) return
|
||||
close()
|
||||
}
|
||||
function onKey(e: KeyboardEvent): void {
|
||||
if (isOpen.value && e.key === 'Escape') close()
|
||||
}
|
||||
function onScroll(): void {
|
||||
if (isOpen.value) close()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener(event, onPointer, true)
|
||||
if (esc) document.addEventListener('keydown', onKey)
|
||||
if (closeOnScroll) {
|
||||
window.addEventListener('scroll', onScroll, true)
|
||||
window.addEventListener('resize', onScroll)
|
||||
}
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener(event, onPointer, true)
|
||||
if (esc) document.removeEventListener('keydown', onKey)
|
||||
if (closeOnScroll) {
|
||||
window.removeEventListener('scroll', onScroll, true)
|
||||
window.removeEventListener('resize', onScroll)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { useClickOutside } from './useClickOutside'
|
||||
|
||||
// Teleport(body) + fixed 위치로 띄우는 드롭다운 메뉴 공용 훅.
|
||||
// 트리거 기준 좌표 계산 · open 토글 · 바깥클릭/ESC/스크롤 닫기를 한 곳으로 모은다.
|
||||
// (DropdownSelect, StatusSelect 등 스크롤/overflow 컨테이너 안에서도 안 잘리는 메뉴에 사용)
|
||||
//
|
||||
// - align: 트리거 좌/우 끝 기준 정렬 (기본 'left')
|
||||
// - matchWidth: 메뉴 최소 너비를 트리거 너비에 맞출지 (기본 false)
|
||||
// - gap: 트리거 하단과 메뉴 사이 간격(px) (기본 6)
|
||||
// - esc: ESC 로 닫을지 (기본 true)
|
||||
export function useDropdownMenu(
|
||||
opts: {
|
||||
align?: 'left' | 'right'
|
||||
matchWidth?: boolean
|
||||
gap?: number
|
||||
esc?: boolean
|
||||
} = {},
|
||||
) {
|
||||
const { align = 'left', matchWidth = false, gap = 6, esc = true } = opts
|
||||
|
||||
const open = ref(false)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
// 메뉴 fixed 위치(트리거 기준 계산)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
function position(): void {
|
||||
const el = triggerRef.value
|
||||
if (!el) return
|
||||
const r = el.getBoundingClientRect()
|
||||
const style: Record<string, string> = { top: `${r.bottom + gap}px` }
|
||||
if (matchWidth) style.minWidth = `${r.width}px`
|
||||
if (align === 'right') {
|
||||
style.right = `${Math.max(0, window.innerWidth - r.right)}px`
|
||||
} else {
|
||||
style.left = `${r.left}px`
|
||||
}
|
||||
menuStyle.value = style
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
open.value = !open.value
|
||||
if (open.value) void nextTick(position)
|
||||
}
|
||||
function close(): void {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
// 바깥클릭(트리거/메뉴 외부)·ESC·스크롤(위치 어긋남) 닫기
|
||||
useClickOutside(open, close, [triggerRef, menuRef], {
|
||||
event: 'click',
|
||||
esc,
|
||||
closeOnScroll: true,
|
||||
})
|
||||
|
||||
return { open, triggerRef, menuRef, menuStyle, toggle, close, position }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
<!-- 하단: 접기 토글 + 사용자 프로필 + 메뉴(위로 펼침) -->
|
||||
|
||||
@@ -7,6 +7,8 @@ import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import LoadMore from '@/components/common/LoadMore.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { useMyTaskStore } from '@/stores/my-task.store'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { countActive, type MyTaskRow } from '@/mock/relay.mock'
|
||||
@@ -215,18 +217,12 @@ function loadMore() {
|
||||
</div>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="state-msg"
|
||||
>
|
||||
<EmptyState v-if="loading">
|
||||
내 업무를 불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isEmpty"
|
||||
class="state-msg"
|
||||
>
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="isEmpty">
|
||||
{{ view === 'assigned' ? '담당 중인 업무가 없습니다.' : '지시한 업무가 없습니다.' }}
|
||||
</div>
|
||||
</EmptyState>
|
||||
|
||||
<!-- 상태 그룹 -->
|
||||
<div
|
||||
@@ -412,15 +408,11 @@ function loadMore() {
|
||||
</div>
|
||||
|
||||
<!-- 더보기 -->
|
||||
<button
|
||||
<LoadMore
|
||||
v-if="hasMore"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
:loading="loadingMore"
|
||||
@load="loadMore"
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -537,16 +529,6 @@ function loadMore() {
|
||||
}
|
||||
|
||||
/* 로딩/빈 상태 */
|
||||
.state-msg {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
/* 상태 그룹 */
|
||||
.group {
|
||||
|
||||
@@ -7,11 +7,20 @@ import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import LoadMore from '@/components/common/LoadMore.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useActivityStore } from '@/stores/activity.store'
|
||||
import type { Project, ActivityIcon } from '@/mock/relay.mock'
|
||||
|
||||
type Filter = 'all' | 'task' | 'member' | 'setting'
|
||||
const FILTER_OPTIONS: { value: Filter; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'task', label: '업무' },
|
||||
{ value: 'member', label: '멤버' },
|
||||
{ value: 'setting', label: '설정' },
|
||||
]
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
@@ -139,32 +148,10 @@ const ActIcon = defineComponent({
|
||||
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: filter === 'all' }"
|
||||
@click="filter = 'all'"
|
||||
>
|
||||
전체
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'task' }"
|
||||
@click="filter = 'task'"
|
||||
>
|
||||
업무
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'member' }"
|
||||
@click="filter = 'member'"
|
||||
>
|
||||
멤버
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'setting' }"
|
||||
@click="filter = 'setting'"
|
||||
>
|
||||
설정
|
||||
</button>
|
||||
</div>
|
||||
<SegmentedTabs
|
||||
v-model="filter"
|
||||
:options="FILTER_OPTIONS"
|
||||
/>
|
||||
<DropdownSelect
|
||||
v-model="memberFilter"
|
||||
:options="memberOptions"
|
||||
@@ -192,18 +179,12 @@ const ActIcon = defineComponent({
|
||||
</div>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="feed-state"
|
||||
>
|
||||
<EmptyState v-if="loading">
|
||||
활동을 불러오는 중입니다…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="isEmpty"
|
||||
class="feed-state"
|
||||
>
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="isEmpty">
|
||||
아직 기록된 활동이 없습니다.
|
||||
</div>
|
||||
</EmptyState>
|
||||
|
||||
<!-- 활동 피드 -->
|
||||
<!-- act-text 의 v-html 은 store 에서 사용자 입력을 escapeHtml 로 이스케이프한 뒤 신뢰 태그만 조립한 마크업이다 (XSS 위험 없음) -->
|
||||
@@ -243,20 +224,12 @@ const ActIcon = defineComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 더 보기 — 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
|
||||
<div
|
||||
<!-- 더보기 — 서버 페이지네이션(누적). 필터와 무관하게 다음 페이지를 불러온다 -->
|
||||
<LoadMore
|
||||
v-if="!loading && activityStore.hasMore"
|
||||
class="more-row"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="more-btn"
|
||||
:disabled="activityStore.loadingMore"
|
||||
@click="activityStore.loadMore()"
|
||||
>
|
||||
{{ activityStore.loadingMore ? '불러오는 중…' : '더 보기' }}
|
||||
</button>
|
||||
</div>
|
||||
:loading="activityStore.loadingMore"
|
||||
@load="activityStore.loadMore()"
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -388,39 +361,4 @@ const ActIcon = defineComponent({
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
|
||||
/* 더 보기 */
|
||||
.more-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: 0.875rem;
|
||||
}
|
||||
.more-btn {
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.813rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
.more-btn:hover:not(:disabled) {
|
||||
background: #f4f5f7;
|
||||
}
|
||||
.more-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* 로딩/빈 상태 */
|
||||
.feed-state {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.625rem;
|
||||
box-shadow: var(--shadow-sm);
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,10 +9,14 @@ import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import LoadMore from '@/components/common/LoadMore.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useTaskStore } from '@/stores/task.store'
|
||||
import type { TaskListQuery, TaskSegment, TaskSort } from '@/types/task'
|
||||
import type { Project, TaskStatus } from '@/mock/relay.mock'
|
||||
import { taskStatusLabel } from '@/shared/utils/format'
|
||||
import type { Project } from '@/mock/relay.mock'
|
||||
|
||||
const route = useRoute()
|
||||
const projectId = computed(() => String(route.params.projectId))
|
||||
@@ -32,6 +36,15 @@ async function loadProject() {
|
||||
|
||||
const keyword = ref('')
|
||||
const segment = ref<TaskSegment>('all')
|
||||
// 세그먼트 탭 옵션(수량 포함) — counts 갱신에 반응
|
||||
const segOptions = computed<{ value: TaskSegment; label: string; count: number }[]>(
|
||||
() => [
|
||||
{ value: 'all', label: '전체', count: counts.value.all },
|
||||
{ value: 'prog', label: '진행 중', count: counts.value.prog },
|
||||
{ value: 'todo', label: '대기', count: counts.value.todo },
|
||||
{ value: 'done', label: '완료', count: counts.value.done },
|
||||
],
|
||||
)
|
||||
const sort = ref<TaskSort>('latest')
|
||||
const SORT_OPTIONS: { value: TaskSort; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
@@ -96,14 +109,7 @@ const isFiltered = computed(() => keyword.value.trim() !== '' || segment.value !
|
||||
// 지연 건수 — 현재 로드된 목록 기준(전역 정확 집계는 백엔드 미지원, 알려진 한계)
|
||||
const overdueCount = computed(() => tasks.value.filter((t) => t.overdue).length)
|
||||
|
||||
// 업무 상태 → 배지 라벨
|
||||
const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
prog: '진행 중',
|
||||
review: '승인 대기',
|
||||
todo: '대기',
|
||||
done: '완료',
|
||||
changes: '수정 요청',
|
||||
}
|
||||
// 업무 상태 → 배지 라벨은 공용 SSOT(taskStatusLabel) 사용
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -188,32 +194,10 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
placeholder="업무 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: segment === 'all' }"
|
||||
@click="segment = 'all'"
|
||||
>
|
||||
전체 <span class="n">{{ counts.all }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: segment === 'prog' }"
|
||||
@click="segment = 'prog'"
|
||||
>
|
||||
진행 중 <span class="n">{{ counts.prog }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: segment === 'todo' }"
|
||||
@click="segment = 'todo'"
|
||||
>
|
||||
대기 <span class="n">{{ counts.todo }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: segment === 'done' }"
|
||||
@click="segment = 'done'"
|
||||
>
|
||||
완료 <span class="n">{{ counts.done }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<SegmentedTabs
|
||||
v-model="segment"
|
||||
:options="segOptions"
|
||||
/>
|
||||
<DropdownSelect
|
||||
v-model="sort"
|
||||
:options="SORT_OPTIONS"
|
||||
@@ -254,18 +238,12 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</div>
|
||||
|
||||
<!-- 업무 목록 -->
|
||||
<div
|
||||
v-if="loading && tasks.length === 0"
|
||||
class="state-msg"
|
||||
>
|
||||
<EmptyState v-if="loading && tasks.length === 0">
|
||||
업무를 불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="tasks.length === 0"
|
||||
class="state-msg"
|
||||
>
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="tasks.length === 0">
|
||||
{{ isFiltered ? '조건에 맞는 업무가 없습니다.' : '아직 등록된 업무가 없습니다. 새 업무를 지시해 보세요.' }}
|
||||
</div>
|
||||
</EmptyState>
|
||||
<div
|
||||
v-else
|
||||
class="list"
|
||||
@@ -399,7 +377,7 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
<span
|
||||
class="st-badge"
|
||||
:class="task.status"
|
||||
>{{ STATUS_LABEL[task.status] }}</span>
|
||||
>{{ taskStatusLabel(task.status) }}</span>
|
||||
<span class="chev">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
@@ -417,15 +395,11 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
</div>
|
||||
|
||||
<!-- 더보기 -->
|
||||
<button
|
||||
<LoadMore
|
||||
v-if="hasMore"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="loadMoreTasks()"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
:loading="loadingMore"
|
||||
@load="loadMoreTasks"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 프로젝트를 찾지 못한 경우 -->
|
||||
@@ -591,13 +565,4 @@ const STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
text-align: center;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.state-msg {
|
||||
padding: 2.5rem 1.125rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,8 @@ import { RouterLink } from 'vue-router'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import LoadMore from '@/components/common/LoadMore.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import type { ProjectListQuery, ProjectSort } from '@/types/project'
|
||||
|
||||
@@ -203,30 +205,20 @@ const isFiltered = computed(() => keyword.value.trim() !== '')
|
||||
</RouterLink>
|
||||
|
||||
<!-- 로딩 / 빈 상태 -->
|
||||
<div
|
||||
v-if="loading && projects.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
<EmptyState v-if="loading && projects.length === 0">
|
||||
불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="projects.length === 0"
|
||||
class="list-empty"
|
||||
>
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="projects.length === 0">
|
||||
{{ isFiltered ? '검색 결과가 없습니다.' : '아직 프로젝트가 없습니다. 새 프로젝트를 만들어 보세요.' }}
|
||||
</div>
|
||||
</EmptyState>
|
||||
</div>
|
||||
|
||||
<!-- 더보기 -->
|
||||
<button
|
||||
<LoadMore
|
||||
v-if="hasMore"
|
||||
class="load-more"
|
||||
type="button"
|
||||
:disabled="loadingMore"
|
||||
@click="projectStore.loadMore()"
|
||||
>
|
||||
{{ loadingMore ? '불러오는 중…' : '더보기' }}
|
||||
</button>
|
||||
:loading="loadingMore"
|
||||
@load="projectStore.loadMore()"
|
||||
/>
|
||||
</div>
|
||||
</AppShell>
|
||||
</template>
|
||||
@@ -371,10 +363,4 @@ const isFiltered = computed(() => keyword.value.trim() !== '')
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.list-empty {
|
||||
padding: 2.5rem 1.25rem;
|
||||
text-align: center;
|
||||
color: var(--text-3);
|
||||
font-size: 0.844rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -7,6 +7,9 @@ import ProjectHeader from '@/components/ProjectHeader.vue'
|
||||
import ProjectTabs from '@/components/ProjectTabs.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import DropdownSelect from '@/components/DropdownSelect.vue'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import SegmentedTabs from '@/components/common/SegmentedTabs.vue'
|
||||
import EmptyState from '@/components/common/EmptyState.vue'
|
||||
import { useProjectStore } from '@/stores/project.store'
|
||||
import { useMemberStore } from '@/stores/member.store'
|
||||
import { useUser } from '@/composables/useUser'
|
||||
@@ -70,6 +73,15 @@ const counts = computed(() => ({
|
||||
member: members.value.filter((m) => m.roleType === 'member').length,
|
||||
}))
|
||||
|
||||
// 역할 필터 세그먼트 옵션(수량 포함)
|
||||
const filterOptions = computed<{ value: RoleFilter; label: string; count: number }[]>(
|
||||
() => [
|
||||
{ value: 'all', label: '전체', count: counts.value.all },
|
||||
{ value: 'admin', label: '관리자', count: counts.value.admin },
|
||||
{ value: 'member', label: '멤버', count: counts.value.member },
|
||||
],
|
||||
)
|
||||
|
||||
const filteredMembers = computed(() =>
|
||||
members.value.filter((m) => {
|
||||
const matchRole = filter.value === 'all' || m.roleType === filter.value
|
||||
@@ -285,26 +297,10 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
placeholder="이름·이메일 검색…"
|
||||
>
|
||||
</div>
|
||||
<div class="segmented">
|
||||
<button
|
||||
:class="{ active: filter === 'all' }"
|
||||
@click="filter = 'all'"
|
||||
>
|
||||
전체 <span class="n">{{ counts.all }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'admin' }"
|
||||
@click="filter = 'admin'"
|
||||
>
|
||||
관리자 <span class="n">{{ counts.admin }}</span>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: filter === 'member' }"
|
||||
@click="filter = 'member'"
|
||||
>
|
||||
멤버 <span class="n">{{ counts.member }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<SegmentedTabs
|
||||
v-model="filter"
|
||||
:options="filterOptions"
|
||||
/>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
class="btn primary invite"
|
||||
@@ -330,18 +326,12 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
</div>
|
||||
|
||||
<!-- 멤버 목록 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="state-msg"
|
||||
>
|
||||
<EmptyState v-if="loading">
|
||||
멤버를 불러오는 중…
|
||||
</div>
|
||||
<div
|
||||
v-else-if="filteredMembers.length === 0"
|
||||
class="state-msg"
|
||||
>
|
||||
</EmptyState>
|
||||
<EmptyState v-else-if="filteredMembers.length === 0">
|
||||
표시할 멤버가 없습니다.
|
||||
</div>
|
||||
</EmptyState>
|
||||
<div
|
||||
v-else
|
||||
class="list"
|
||||
@@ -466,134 +456,109 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
</div>
|
||||
|
||||
<!-- 멤버 초대 모달 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
:class="{ open: showInvite }"
|
||||
@click.self="showInvite = false"
|
||||
>
|
||||
<div
|
||||
class="invite-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div class="modal-head">
|
||||
<div>
|
||||
<h2>멤버 초대</h2>
|
||||
<div class="mh-sub">
|
||||
<b>{{ repo?.name }}</b> 프로젝트에 멤버를 추가합니다.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="modal-close"
|
||||
type="button"
|
||||
@click="showInvite = false"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
class="modal-body"
|
||||
@submit.prevent="submitInvite"
|
||||
>
|
||||
<div class="m-field uc-field">
|
||||
<label class="m-label">초대할 사람 (이메일)</label>
|
||||
<input
|
||||
v-model="inviteEmail"
|
||||
class="m-input"
|
||||
type="email"
|
||||
placeholder="가입된 사용자의 이메일 입력…"
|
||||
autocomplete="off"
|
||||
@focus="onEmailFocus"
|
||||
@input="onEmailInput"
|
||||
@blur="onEmailBlur"
|
||||
>
|
||||
<!-- 사용자 자동완성 드롭다운 -->
|
||||
<div
|
||||
v-if="showUserDropdown"
|
||||
class="user-dd"
|
||||
>
|
||||
<div
|
||||
v-if="usersLoading"
|
||||
class="user-dd-msg"
|
||||
>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="u in userOptions"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="user-dd-item"
|
||||
@mousedown.prevent="selectUser(u)"
|
||||
>
|
||||
<span class="avatar user-av"><UserAvatar
|
||||
:url="u.avatarUrl"
|
||||
:name="u.name"
|
||||
/></span>
|
||||
<span class="user-nm">{{ u.name }}</span>
|
||||
<span class="user-em">{{ u.email }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="userOptions.length === 0"
|
||||
class="user-dd-msg"
|
||||
>
|
||||
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<BaseModal
|
||||
v-if="showInvite"
|
||||
title="멤버 초대"
|
||||
@close="closeInvite"
|
||||
>
|
||||
<template #subtitle>
|
||||
<b>{{ repo?.name }}</b> 프로젝트에 멤버를 추가합니다.
|
||||
</template>
|
||||
|
||||
<div class="m-field">
|
||||
<label class="m-label">역할</label>
|
||||
<DropdownSelect
|
||||
v-model="inviteRole"
|
||||
:options="ROLE_OPTIONS"
|
||||
block
|
||||
class="role-select"
|
||||
aria-label="역할"
|
||||
/>
|
||||
</div>
|
||||
<div class="role-hint">
|
||||
<b>멤버</b>는 담당자로 배정되어 업무를 수행하고, <b>관리자</b>는 업무 지시와 멤버 관리도 할 수 있습니다.
|
||||
</div>
|
||||
</form>
|
||||
<div class="modal-foot">
|
||||
<span class="fnote">이미 가입된 사용자만 초대할 수 있습니다.</span>
|
||||
<button
|
||||
class="mbtn"
|
||||
type="button"
|
||||
@click="closeInvite"
|
||||
<form
|
||||
class="invite-body"
|
||||
@submit.prevent="submitInvite"
|
||||
>
|
||||
<div class="form-field uc-field">
|
||||
<label class="form-label">초대할 사람 (이메일)</label>
|
||||
<input
|
||||
v-model="inviteEmail"
|
||||
class="form-input"
|
||||
type="email"
|
||||
placeholder="가입된 사용자의 이메일 입력…"
|
||||
autocomplete="off"
|
||||
@focus="onEmailFocus"
|
||||
@input="onEmailInput"
|
||||
@blur="onEmailBlur"
|
||||
>
|
||||
<!-- 사용자 자동완성 드롭다운 -->
|
||||
<div
|
||||
v-if="showUserDropdown"
|
||||
class="user-dd"
|
||||
>
|
||||
<div
|
||||
v-if="usersLoading"
|
||||
class="user-dd-msg"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="mbtn primary"
|
||||
type="button"
|
||||
:disabled="!canSubmitInvite"
|
||||
@click="submitInvite"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
||||
{{ inviteBusy ? '초대 중…' : '초대 보내기' }}
|
||||
</button>
|
||||
불러오는 중…
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
v-for="u in userOptions"
|
||||
:key="u.id"
|
||||
type="button"
|
||||
class="user-dd-item"
|
||||
@mousedown.prevent="selectUser(u)"
|
||||
>
|
||||
<span class="avatar user-av"><UserAvatar
|
||||
:url="u.avatarUrl"
|
||||
:name="u.name"
|
||||
/></span>
|
||||
<span class="user-nm">{{ u.name }}</span>
|
||||
<span class="user-em">{{ u.email }}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="userOptions.length === 0"
|
||||
class="user-dd-msg"
|
||||
>
|
||||
{{ inviteEmail.trim() ? '일치하는 사용자가 없습니다.' : '초대할 수 있는 사용자가 없습니다.' }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">역할</label>
|
||||
<DropdownSelect
|
||||
v-model="inviteRole"
|
||||
:options="ROLE_OPTIONS"
|
||||
block
|
||||
class="role-select"
|
||||
aria-label="역할"
|
||||
/>
|
||||
<div class="role-hint">
|
||||
<b>멤버</b>는 담당자로 배정되어 업무를 수행하고, <b>관리자</b>는 업무 지시와 멤버 관리도 할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #foot>
|
||||
<span class="fnote">이미 가입된 사용자만 초대할 수 있습니다.</span>
|
||||
<button
|
||||
class="mbtn"
|
||||
type="button"
|
||||
@click="closeInvite"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="mbtn primary"
|
||||
type="button"
|
||||
:disabled="!canSubmitInvite"
|
||||
@click="submitInvite"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m22 2-7 20-4-9-9-4Z" /><path d="M22 2 11 13" /></svg>
|
||||
{{ inviteBusy ? '초대 중…' : '초대 보내기' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
@@ -800,15 +765,6 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
}
|
||||
|
||||
/* 로딩/빈 상태 메시지 */
|
||||
.state-msg {
|
||||
padding: 2.5rem 1.125rem;
|
||||
text-align: center;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text-3);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 역할 변경 드롭다운 */
|
||||
/* 전역 .list 의 overflow:hidden 이 드롭다운을 잘라내므로, 이 화면에서는 보이도록 해제.
|
||||
@@ -867,73 +823,17 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
|
||||
<!-- 초대 모달은 Teleport 로 body 에 렌더되므로 비-scoped 전역 스타일 -->
|
||||
<style>
|
||||
.invite-modal {
|
||||
width: 100%;
|
||||
max-width: 32.75rem;
|
||||
background: #fff;
|
||||
border-radius: 0.875rem;
|
||||
box-shadow: 0 24px 64px rgba(20, 24, 33, 0.32);
|
||||
/* overflow:hidden 제거 — 자동완성 드롭다운이 모달 밖으로 넘쳐도 잘리지 않도록 */
|
||||
/* 멤버 초대 모달 — 셸/헤더/푸터/필드는 BaseModal + 전역 .form-* 사용.
|
||||
본문 폼은 display:contents 로 BaseModal 본문 간격을 상속받고,
|
||||
고유 요소(자동완성 드롭다운/역할 힌트/푸터 노트)만 여기서 정의.
|
||||
(BaseModal 이 body 로 Teleport 하므로 비-scoped 전역 스타일) */
|
||||
.invite-body {
|
||||
display: contents;
|
||||
}
|
||||
.invite-modal .modal-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1.375rem 1.5rem 1rem;
|
||||
}
|
||||
.invite-modal .modal-head h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.01875rem;
|
||||
}
|
||||
.invite-modal .mh-sub {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.invite-modal .mh-sub b {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.invite-modal .modal-close {
|
||||
margin-left: auto;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-3);
|
||||
border-radius: var(--radius-sm);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.invite-modal .modal-close:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.invite-modal .modal-close svg {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
.invite-modal .modal-body {
|
||||
padding: 0.25rem 1.5rem 0.5rem;
|
||||
/* 전역 .modal-body(text-align:center) 상속 차단 — 폼은 좌측 정렬 */
|
||||
text-align: left;
|
||||
}
|
||||
/* 모달 입력 필드(이메일/역할) — 세로 스택, 전체 너비 */
|
||||
.invite-modal .m-field {
|
||||
margin-top: 1.125rem;
|
||||
}
|
||||
.invite-modal .m-field:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
/* 사용자 자동완성 드롭다운 */
|
||||
.invite-modal .uc-field {
|
||||
.invite-body .uc-field {
|
||||
position: relative;
|
||||
}
|
||||
.invite-modal .user-dd {
|
||||
.invite-body .user-dd {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.375rem);
|
||||
left: 0;
|
||||
@@ -947,7 +847,7 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
max-height: 16rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.invite-modal .user-dd-item {
|
||||
.invite-body .user-dd-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.625rem;
|
||||
@@ -960,22 +860,22 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
font-family: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.invite-modal .user-dd-item:hover {
|
||||
.invite-body .user-dd-item:hover {
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.invite-modal .user-av {
|
||||
.invite-body .user-av {
|
||||
width: 1.625rem;
|
||||
height: 1.625rem;
|
||||
font-size: 0.6875rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.invite-modal .user-nm {
|
||||
.invite-body .user-nm {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.invite-modal .user-em {
|
||||
.invite-body .user-em {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin-left: auto;
|
||||
@@ -985,111 +885,32 @@ onUnmounted(() => window.removeEventListener('keydown', onKeydown))
|
||||
max-width: 14rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.invite-modal .user-dd-msg {
|
||||
.invite-body .user-dd-msg {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.781rem;
|
||||
color: var(--text-3);
|
||||
text-align: center;
|
||||
}
|
||||
.invite-modal .m-label {
|
||||
font-size: 0.781rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-2);
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
.invite-modal .m-label .muted {
|
||||
color: var(--text-3);
|
||||
font-weight: 500;
|
||||
}
|
||||
/* 역할 선택 드롭다운(DropdownSelect) — 모달 폼 입력 높이/서체에 맞춤 */
|
||||
.invite-modal .role-select :deep(.dd-trigger) {
|
||||
.invite-body .role-select .dd-trigger {
|
||||
height: 2.5rem;
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.invite-modal .m-input {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius);
|
||||
padding: 0 0.75rem;
|
||||
font-family: inherit;
|
||||
font-size: 0.844rem;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
}
|
||||
.invite-modal .m-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-weak);
|
||||
}
|
||||
.invite-modal .m-input::placeholder {
|
||||
color: var(--text-3);
|
||||
}
|
||||
.invite-modal .role-hint {
|
||||
.invite-body .role-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin-top: 0.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.invite-modal .role-hint b {
|
||||
.invite-body .role-hint b {
|
||||
color: var(--text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
.invite-modal .modal-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5625rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
background: #fafbfc;
|
||||
margin-top: 1rem;
|
||||
/* overflow:hidden 제거에 따른 하단 라운드 보정 */
|
||||
border-radius: 0 0 0.875rem 0.875rem;
|
||||
}
|
||||
.invite-modal .modal-foot .fnote {
|
||||
/* 푸터 좌측 안내 문구(BaseModal 푸터 슬롯) */
|
||||
.fnote {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-3);
|
||||
margin-right: auto;
|
||||
}
|
||||
.invite-modal .mbtn {
|
||||
/* 전역 .mbtn(flex:1, 확인 다이얼로그용) 상속 차단 — 버튼은 내용 너비로 우측 정렬 */
|
||||
flex: 0 0 auto;
|
||||
height: 2.375rem;
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.844rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.invite-modal .mbtn:hover {
|
||||
background: #f1f2f4;
|
||||
color: var(--text);
|
||||
}
|
||||
.invite-modal .mbtn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 1px 2px rgba(79, 70, 229, 0.4);
|
||||
}
|
||||
.invite-modal .mbtn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
.invite-modal .mbtn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.invite-modal .mbtn svg {
|
||||
width: 0.9375rem;
|
||||
height: 0.9375rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
// 일정 — 전사 공유 주간 타임라인(간트형) 캘린더
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import AppShell from '@/layouts/AppShell.vue'
|
||||
import ScheduleTimeline from '@/components/schedule/ScheduleTimeline.vue'
|
||||
import ScheduleDetailPanel from '@/components/schedule/ScheduleDetailPanel.vue'
|
||||
import ScheduleEventModal from '@/components/schedule/ScheduleEventModal.vue'
|
||||
import ScheduleCategoryModal from '@/components/schedule/ScheduleCategoryModal.vue'
|
||||
import ScheduleTypeFilter from '@/components/schedule/ScheduleTypeFilter.vue'
|
||||
import { useScheduleStore } from '@/stores/schedule.store'
|
||||
import { useDialogStore } from '@/stores/dialog.store'
|
||||
import type {
|
||||
ApiScheduleEvent,
|
||||
ScheduleCategoryPayload,
|
||||
ScheduleEventPayload,
|
||||
} from '@/types/schedule'
|
||||
|
||||
const store = useScheduleStore()
|
||||
const dialog = useDialogStore()
|
||||
|
||||
const selId = ref<string | null>(null)
|
||||
const eventModal = ref<{ init: ApiScheduleEvent | null } | null>(null)
|
||||
const catModal = ref<{ init: import('@/types/schedule').ApiScheduleCategory | null } | null>(null)
|
||||
|
||||
onMounted(() => {
|
||||
void store.init().catch(() => {
|
||||
// 인터셉터가 에러 토스트 처리
|
||||
})
|
||||
})
|
||||
|
||||
// 선택 일정 — 활성 유형 + 현재 주에 보이는 것만
|
||||
const selEvent = computed<ApiScheduleEvent | null>(() => {
|
||||
const ev = store.events.find((e) => e.id === selId.value)
|
||||
if (!ev) return null
|
||||
return store.activeTypeIds.has(ev.categoryId) ? ev : null
|
||||
})
|
||||
const selCategory = computed(
|
||||
() => store.categories.find((c) => c.id === selEvent.value?.categoryId) ?? null,
|
||||
)
|
||||
|
||||
// 새 일정 기본 날짜 — 보고 있는 주의 월요일
|
||||
const defaultDate = computed(() => store.weekStart)
|
||||
|
||||
// ----- 일정 -----
|
||||
function openNewEvent(): void {
|
||||
eventModal.value = { init: null }
|
||||
}
|
||||
function openEditEvent(ev: ApiScheduleEvent): void {
|
||||
eventModal.value = { init: ev }
|
||||
}
|
||||
async function onSaveEvent(payload: ScheduleEventPayload): Promise<void> {
|
||||
const init = eventModal.value?.init
|
||||
if (init) {
|
||||
await store.updateEvent(init.id, payload)
|
||||
selId.value = init.id
|
||||
} else {
|
||||
selId.value = await store.createEvent(payload)
|
||||
}
|
||||
eventModal.value = null
|
||||
}
|
||||
async function onDeleteEvent(ev: ApiScheduleEvent): Promise<void> {
|
||||
const ok = await dialog.confirm(
|
||||
`'${ev.title}' 일정을 삭제할까요? 이 작업은 되돌릴 수 없습니다.`,
|
||||
{ title: '일정 삭제', confirmText: '삭제', variant: 'danger' },
|
||||
)
|
||||
if (!ok) return
|
||||
await store.deleteEvent(ev.id)
|
||||
if (selId.value === ev.id) selId.value = null
|
||||
}
|
||||
|
||||
// ----- 카테고리 -----
|
||||
function openNewCategory(): void {
|
||||
catModal.value = { init: null }
|
||||
}
|
||||
function openEditCategory(id: string): void {
|
||||
catModal.value = { init: store.categories.find((c) => c.id === id) ?? null }
|
||||
}
|
||||
async function onSaveCategory(payload: ScheduleCategoryPayload): Promise<void> {
|
||||
const init = catModal.value?.init
|
||||
if (init) await store.updateCategory(init.id, payload)
|
||||
else await store.createCategory(payload)
|
||||
catModal.value = null
|
||||
}
|
||||
async function onDeleteCategory(id: string): Promise<void> {
|
||||
const cat = store.categories.find((c) => c.id === id)
|
||||
if (!cat) return
|
||||
const cnt = store.counts[id] || 0
|
||||
const msg = cnt
|
||||
? `'${cat.label}' 카테고리와 이번 주에 표시된 일정 ${cnt}건을 포함해 해당 카테고리의 모든 일정이 삭제됩니다. 계속할까요?`
|
||||
: `'${cat.label}' 카테고리를 삭제할까요?`
|
||||
const ok = await dialog.confirm(msg, {
|
||||
title: '카테고리 삭제',
|
||||
confirmText: '삭제',
|
||||
variant: 'danger',
|
||||
})
|
||||
if (!ok) return
|
||||
await store.deleteCategory(id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell>
|
||||
<div class="sched">
|
||||
<!-- 툴바 -->
|
||||
<div class="toolbar">
|
||||
<div class="datenav">
|
||||
<div class="navgroup">
|
||||
<button
|
||||
class="navbtn"
|
||||
title="이전 주"
|
||||
@click="store.goPrevWeek()"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m15 18-6-6 6-6" /></svg>
|
||||
</button>
|
||||
<button
|
||||
class="navbtn"
|
||||
title="다음 주"
|
||||
@click="store.goNextWeek()"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="m9 18 6-6-6-6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
<span class="range">{{ store.rangeLabel }}</span>
|
||||
</div>
|
||||
<div class="spacer" />
|
||||
<ScheduleTypeFilter
|
||||
:categories="store.categories"
|
||||
:counts="store.counts"
|
||||
:active-type-ids="store.activeTypeIds"
|
||||
@toggle="store.toggleType"
|
||||
@set-all="store.setAllTypes"
|
||||
@add="openNewCategory"
|
||||
@edit="openEditCategory"
|
||||
@delete="onDeleteCategory"
|
||||
/>
|
||||
<button
|
||||
class="btn primary"
|
||||
@click="openNewEvent"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
><path d="M5 12h14M12 5v14" /></svg>새 일정
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 콘텐츠 + 상세 패널 -->
|
||||
<div class="content-row">
|
||||
<div class="viewport">
|
||||
<ScheduleTimeline
|
||||
:categories="store.categories"
|
||||
:events="store.events"
|
||||
:days="store.days"
|
||||
:active-type-ids="store.activeTypeIds"
|
||||
:sel-id="selId"
|
||||
@select="selId = $event"
|
||||
@add-category="openNewCategory"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="selEvent"
|
||||
class="detail-backdrop"
|
||||
@click="selId = null"
|
||||
/>
|
||||
<ScheduleDetailPanel
|
||||
:event="selEvent"
|
||||
:category="selCategory"
|
||||
@close="selId = null"
|
||||
@edit="openEditEvent"
|
||||
@delete="onDeleteEvent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScheduleEventModal
|
||||
v-if="eventModal"
|
||||
:categories="store.categories"
|
||||
:people="store.people"
|
||||
:init="eventModal.init"
|
||||
:default-date="defaultDate"
|
||||
@save="onSaveEvent"
|
||||
@close="eventModal = null"
|
||||
/>
|
||||
<ScheduleCategoryModal
|
||||
v-if="catModal"
|
||||
:init="catModal.init"
|
||||
@save="onSaveCategory"
|
||||
@close="catModal = null"
|
||||
/>
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 상단 topbar(3.25rem) 아래 영역을 가득 채우는 풀-하이트 레이아웃 */
|
||||
.sched {
|
||||
height: calc(100vh - 3.25rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/* 프로젝트 목록 등 전역 .toolbar 와 동일한 컨트롤 간격 */
|
||||
gap: 0.625rem;
|
||||
padding: 11px 24px;
|
||||
flex-shrink: 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.datenav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.datenav .range {
|
||||
font-size: 15.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.3px;
|
||||
white-space: nowrap;
|
||||
margin: 0 6px;
|
||||
}
|
||||
.navgroup {
|
||||
display: flex;
|
||||
}
|
||||
.navgroup .navbtn:first-child {
|
||||
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||
}
|
||||
.navgroup .navbtn:last-child {
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
margin-left: -1px;
|
||||
}
|
||||
.navbtn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.navbtn:hover {
|
||||
background: #f7f8fa;
|
||||
color: var(--text);
|
||||
z-index: 1;
|
||||
}
|
||||
.navbtn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
/* '새 일정' 버튼은 전역 .btn / .btn.primary 표준(다른 화면의 주요 버튼과 동일)을 그대로 사용 */
|
||||
.content-row {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
background: var(--bg);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.viewport {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
padding: 18px 24px 28px;
|
||||
}
|
||||
.detail-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 25;
|
||||
background: rgba(20, 24, 33, 0.22);
|
||||
opacity: 0;
|
||||
animation: bd-in 0.22s ease forwards;
|
||||
}
|
||||
@keyframes bd-in {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -31,6 +31,10 @@ import { useDialog } from '@/composables/useDialog'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import StatusSelect from '@/components/StatusSelect.vue'
|
||||
import { attachmentKindOf, fileBadge } from '@/shared/utils/format'
|
||||
import {
|
||||
WORK_STATUS_META,
|
||||
WORK_STATUS_ORDER,
|
||||
} from '@/shared/constants/workStatus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -302,16 +306,7 @@ const isAssignee = computed(
|
||||
/* ============================================================
|
||||
* 중간 점검(체크포인트) — 작업자가 점검일 당일에 보고서로 전송, 지시자는 기록 열람
|
||||
* ============================================================ */
|
||||
const WORK_STATUS_ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
|
||||
// 진행 상태 표시 메타(라벨/색/약색)
|
||||
const WORK_STATUS_META: Record<
|
||||
ChecklistWorkStatus,
|
||||
{ label: string; color: string; weak: string }
|
||||
> = {
|
||||
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
|
||||
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
|
||||
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
|
||||
}
|
||||
// 진행 상태 메타/순서는 공용 SSOT(shared/constants/workStatus) 사용
|
||||
// 카테고리 색(탭/그룹 dot)
|
||||
const CAT_COLOR: Record<ChecklistCategory, string> = {
|
||||
필수: '#2563eb',
|
||||
|
||||
@@ -101,6 +101,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/pages/relay/MyTasksPage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
// 일정 — 전사 공유 주간 캘린더(타임라인)
|
||||
path: '/schedule',
|
||||
name: 'schedule',
|
||||
component: () => import('@/pages/relay/SchedulePage.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { ChecklistWorkStatus } from '@/mock/relay.mock'
|
||||
|
||||
// 작업자 진행상태(대기/진행 중/완료) 라벨·색 — 단일 진실 공급원(SSOT).
|
||||
// StatusSelect(드롭다운)·업무 상세(보고서)에서 공통 사용한다.
|
||||
export interface WorkStatusMeta {
|
||||
label: string
|
||||
color: string
|
||||
weak: string
|
||||
}
|
||||
|
||||
export const WORK_STATUS_META: Record<ChecklistWorkStatus, WorkStatusMeta> = {
|
||||
todo: { label: '대기', color: '#9298a3', weak: '#eef0f2' },
|
||||
doing: { label: '진행 중', color: '#2563eb', weak: '#e9f0fd' },
|
||||
done: { label: '완료', color: '#1f9d57', weak: '#e7f5ed' },
|
||||
}
|
||||
|
||||
// 표시 순서(대기 → 진행 중 → 완료)
|
||||
export const WORK_STATUS_ORDER: ChecklistWorkStatus[] = ['todo', 'doing', 'done']
|
||||
@@ -101,7 +101,7 @@ const WEEKDAYS_KO = ['일', '월', '화', '수', '목', '금', '토']
|
||||
|
||||
/** 업무 상태 → 한국어 라벨 */
|
||||
const TASK_STATUS_LABEL: Record<TaskStatus, string> = {
|
||||
todo: '할 일',
|
||||
todo: '대기',
|
||||
prog: '진행 중',
|
||||
review: '승인 대기',
|
||||
done: '완료',
|
||||
|
||||
@@ -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