Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions src/entities/calendar-event/api/create-calendar-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use server';

import { getCurrentUserId } from '@/shared/api/supabase/current-user';
import { createSupabaseServerClient } from '@/shared/api/supabase/server';

import { toCalendarEventInsert } from '../model/calendar-event.mapper';
import type { CalendarEventColor } from '../model/calendar-event.types';

export async function createCalendarEvent(params: {
workspaceId: string;
title: string;
date: string;
time: string | null;
color: CalendarEventColor;
}): Promise<void> {
const supabase = await createSupabaseServerClient();
const currentUserId = await getCurrentUserId();

const payload = toCalendarEventInsert({
workspaceId: params.workspaceId,
title: params.title.trim(),
date: params.date,
time: params.time?.trim() || null,
color: params.color,
createdBy: currentUserId,
});

const { error } = await supabase.from('calendar_events').insert(payload);

if (error) {
console.error('[calendar/createCalendarEvent] insert 실패:', error);
throw new Error('일정 생성에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
}
20 changes: 20 additions & 0 deletions src/entities/calendar-event/api/delete-calendar-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
'use server';

import { createSupabaseServerClient } from '@/shared/api/supabase/server';

export async function deleteCalendarEvent(params: {
workspaceId: string;
eventId: string;
}): Promise<void> {
const supabase = await createSupabaseServerClient();
const { error } = await supabase
.from('calendar_events')
.delete()
.eq('workspace_id', params.workspaceId)
.eq('id', params.eventId);

if (error) {
console.error('[calendar/deleteCalendarEvent] delete 실패:', error);
throw new Error('일정 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { getSupabaseBrowserClient } from '@/shared/api/supabase/client';

import { toCalendarEvent, type CalendarEventRow } from '../model/calendar-event.mapper';
import type { CalendarEvent } from '../model/calendar-event.types';

export async function getCalendarEventsByWorkspaceId(workspaceId: string): Promise<CalendarEvent[]> {
const supabase = getSupabaseBrowserClient();
const { data, error } = await supabase
.from('calendar_events')
.select('id, workspace_id, title, starts_at, description')
.eq('workspace_id', workspaceId)
.order('starts_at');

if (error) {
throw new Error(`일정 조회에 실패했습니다: ${error.message}`);
}

return ((data ?? []) as CalendarEventRow[]).map(toCalendarEvent);
}
47 changes: 47 additions & 0 deletions src/entities/calendar-event/api/update-calendar-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use server';

import { createSupabaseServerClient } from '@/shared/api/supabase/server';

import { toCalendarEventInsert } from '../model/calendar-event.mapper';
import type { CalendarEventColor } from '../model/calendar-event.types';

export async function updateCalendarEvent(params: {
workspaceId: string;
eventId: string;
title: string;
date: string;
time: string | null;
color: CalendarEventColor;
}): Promise<void> {
const supabase = await createSupabaseServerClient();
const payload = toCalendarEventInsert({
workspaceId: params.workspaceId,
title: params.title.trim(),
date: params.date,
time: params.time?.trim() || null,
color: params.color,
createdBy: '',
});

const { data, error } = await supabase
.from('calendar_events')
.update({
title: payload.title,
description: payload.description,
event_type: payload.event_type,
starts_at: payload.starts_at,
ends_at: payload.ends_at,
})
.eq('workspace_id', params.workspaceId)
.eq('id', params.eventId)
.select('id');

if (error) {
console.error('[calendar/updateCalendarEvent] update 실패:', error);
throw new Error('일정 수정에 실패했습니다. 잠시 후 다시 시도해주세요.');
}

if (!data || data.length === 0) {
throw new Error('수정할 일정을 찾을 수 없습니다.');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

import { useQuery } from '@tanstack/react-query';

import { getCalendarEventsByWorkspaceId } from './get-calendar-events-by-workspace-id';

export const calendarEventsByWorkspaceQueryKey = (workspaceId: string) =>
['calendar-events', 'workspace', workspaceId] as const;

export function useCalendarEventsByWorkspaceId(workspaceId: string) {
return useQuery({
queryKey: calendarEventsByWorkspaceQueryKey(workspaceId),
queryFn: () => getCalendarEventsByWorkspaceId(workspaceId),
});
}
27 changes: 27 additions & 0 deletions src/entities/calendar-event/api/use-create-calendar-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

import type { CalendarEventColor } from '../model/calendar-event.types';
import { createCalendarEvent } from './create-calendar-event';
import { calendarEventsByWorkspaceQueryKey } from './use-calendar-events-by-workspace-id';

export function useCreateCalendarEvent(workspaceId: string) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (params: {
title: string;
date: string;
time: string | null;
color: CalendarEventColor;
}) => createCalendarEvent({ workspaceId, ...params }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: calendarEventsByWorkspaceQueryKey(workspaceId) });
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '일정 생성에 실패했습니다');
},
});
}
21 changes: 21 additions & 0 deletions src/entities/calendar-event/api/use-delete-calendar-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

import { deleteCalendarEvent } from './delete-calendar-event';
import { calendarEventsByWorkspaceQueryKey } from './use-calendar-events-by-workspace-id';

export function useDeleteCalendarEvent(workspaceId: string) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (eventId: string) => deleteCalendarEvent({ workspaceId, eventId }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: calendarEventsByWorkspaceQueryKey(workspaceId) });
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '일정 삭제에 실패했습니다');
},
});
}
28 changes: 28 additions & 0 deletions src/entities/calendar-event/api/use-update-calendar-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
'use client';

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

import type { CalendarEventColor } from '../model/calendar-event.types';
import { updateCalendarEvent } from './update-calendar-event';
import { calendarEventsByWorkspaceQueryKey } from './use-calendar-events-by-workspace-id';

export function useUpdateCalendarEvent(workspaceId: string) {
const queryClient = useQueryClient();

return useMutation({
mutationFn: (params: {
eventId: string;
title: string;
date: string;
time: string | null;
color: CalendarEventColor;
}) => updateCalendarEvent({ workspaceId, ...params }),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: calendarEventsByWorkspaceQueryKey(workspaceId) });
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '일정 수정에 실패했습니다');
},
});
}
10 changes: 9 additions & 1 deletion src/entities/calendar-event/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
export { getMockCalendarEventsByWorkspaceId } from './model/mock-calendar-events-by-workspace';
export type {
CalendarEvent,
CalendarEventColor,
CalendarEventFormValues,
} from './model/calendar-event.types';
export { formatCalendarEventTimeLabel } from './model/calendar-event.mapper';
export { getCalendarEventsByWorkspaceId } from './api/get-calendar-events-by-workspace-id';
export {
calendarEventsByWorkspaceQueryKey,
useCalendarEventsByWorkspaceId,
} from './api/use-calendar-events-by-workspace-id';
export { useCreateCalendarEvent } from './api/use-create-calendar-event';
export { useDeleteCalendarEvent } from './api/use-delete-calendar-event';
export { useUpdateCalendarEvent } from './api/use-update-calendar-event';
export { CalendarEventChip } from './ui/CalendarEventChip';
159 changes: 159 additions & 0 deletions src/entities/calendar-event/model/calendar-event.mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { CalendarEvent, CalendarEventColor } from './calendar-event.types';

export interface CalendarEventRow {
id: string;
workspace_id: string;
title: string;
starts_at: string;
description: string | null;
}

interface CalendarEventMetadata {
color?: CalendarEventColor;
time?: string | null;
}

const defaultColor: CalendarEventColor = 'violet';

function isCalendarEventColor(value: unknown): value is CalendarEventColor {
return (
value === 'violet' ||
value === 'purple' ||
value === 'blue' ||
value === 'green' ||
value === 'amber' ||
value === 'coral' ||
value === 'pink'
);
}

function parseMetadata(description: string | null): CalendarEventMetadata {
if (!description) {
return {};
}

try {
const parsed = JSON.parse(description) as CalendarEventMetadata;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}

function formatIsoDateInKst(isoDateTime: string) {
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Seoul',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});

return formatter.format(new Date(isoDateTime));
}

export function normalizeCalendarEventTime(value: string | null | undefined): string | null {
if (!value) {
return null;
}

const trimmedTime = value.trim();
if (!trimmedTime) {
return null;
}

const basicMatch = trimmedTime.match(/^(\d{1,2}):(\d{2})$/);
if (basicMatch) {
const hour = Number(basicMatch[1]);
const minute = Number(basicMatch[2]);

if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) {
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}
}

const koreanMatch = trimmedTime.match(/^(오전|오후)\s*(\d{1,2}):(\d{2})$/);
if (koreanMatch) {
const [, meridiem, rawHour, rawMinute] = koreanMatch;
let hour = Number(rawHour);
const minute = Number(rawMinute);

if (minute < 0 || minute > 59 || hour < 1 || hour > 12) {
return null;
}

if (meridiem === '오전') {
if (hour === 12) hour = 0;
} else if (hour < 12) {
hour += 12;
}

return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
}

return null;
}

export function formatCalendarEventTimeLabel(value: string | null | undefined): string {
const normalizedTime = normalizeCalendarEventTime(value);

if (!normalizedTime) {
return '시간 미정';
}

const [rawHour, rawMinute] = normalizedTime.split(':');
const hour = Number(rawHour);
const minute = Number(rawMinute);
const meridiem = hour < 12 ? '오전' : '오후';
const displayHour = hour % 12 === 0 ? 12 : hour % 12;

return `${meridiem} ${displayHour}:${String(minute).padStart(2, '0')}`;
}

export function toCalendarEvent(row: CalendarEventRow): CalendarEvent {
const metadata = parseMetadata(row.description);

return {
id: row.id,
workspaceId: row.workspace_id,
title: row.title,
date: formatIsoDateInKst(row.starts_at),
time: normalizeCalendarEventTime(metadata.time),
color: isCalendarEventColor(metadata.color) ? metadata.color : defaultColor,
};
}

function parseTimeTo24Hour(time: string) {
const normalizedTime = normalizeCalendarEventTime(time);
if (!normalizedTime) {
return { hour: 0, minute: 0 };
}

const [hour, minute] = normalizedTime.split(':');
return { hour: Number(hour), minute: Number(minute) };
}

export function toCalendarEventInsert(params: {
workspaceId: string;
title: string;
date: string;
time: string | null;
color: CalendarEventColor;
createdBy: string;
}) {
const { hour, minute } = parseTimeTo24Hour(params.time ?? '');
const startsAt = `${params.date}T${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00+09:00`;

return {
workspace_id: params.workspaceId,
created_by: params.createdBy,
task_id: null,
title: params.title,
description: JSON.stringify({
time: params.time,
color: params.color,
}),
event_type: 'meeting' as const,
starts_at: startsAt,
ends_at: null,
};
}
Loading