issue #60
All checks were successful
Test CI / build (push) Successful in 15s

- 일정 목록 조회 1차 구현
- 일정 당일 목록 조회 1차 구현
This commit is contained in:
geonhee-min
2025-12-11 17:03:25 +09:00
parent b23b58e680
commit 78e3bdbda0
25 changed files with 405 additions and 111 deletions

View File

@@ -214,7 +214,7 @@ function CalendarDayButton({
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-day={day.date.toISOString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-indigo-200 py-6 shadow-sm shadow-indigo-200",
className
)}
{...props}

View File

@@ -30,7 +30,7 @@ function PopoverContent({
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border border-indigo-200 p-4 shadow-md shadow-indigo-200 outline-hidden",
className
)}
{...props}

View File

@@ -1,7 +1,7 @@
export type ScheduleType = 'once' | 'daily' | 'weekly' | 'monthly' | 'annual';
export const ScheduleTypeLabel: Record<ScheduleType, string> = {
'once': '한 번만',
'once': '반복없음',
'daily': '매일',
'weekly': '매주',
'monthly': '매월',

View File

@@ -4,12 +4,8 @@ export const ListScheduleSchema = z.object({
name: z
.string()
.optional()
, startDate: z
, date: z
.date()
.optional()
, endDate: z
.date()
.optional()
, status: z
.string()
.optional()

View File

@@ -4,8 +4,8 @@ import type { ScheduleType } from "@/const/schedule/ScheduleType";
export class CreateScheduleRequest {
name: string;
content: string;
startDate: Date;
endDate: Date;
startDate: string;
endDate: string;
status: ScheduleStatus;
type: ScheduleType;
startTime: string;
@@ -16,8 +16,8 @@ export class CreateScheduleRequest {
constructor (
name: string,
content: string,
startDate: Date,
endDate: Date,
startDate: string,
endDate: string,
status: ScheduleStatus,
type: ScheduleType,
startTime: string,

View File

@@ -2,8 +2,9 @@ import type { ScheduleStatus } from "@/const/schedule/ScheduleStatus";
import type { ScheduleType } from "@/const/schedule/ScheduleType";
export class ScheduleListRequest {
startDate?: Date;
endDate?: Date;
date?: string;
startDate?: string;
endDate?: string;
typeList?: ScheduleType[];
styleList?: string[];
status?: ScheduleStatus;

View File

@@ -144,9 +144,9 @@ input[type="number"] {
}
.custom-rdp-week:not(:first-child) {
@apply border-t!;
@apply border-t! border-indigo-200!;
}
.custom-rdp-day:not(:first-child) {
@apply border-l!;
@apply border-l! border-indigo-200!;
}

View File

@@ -17,7 +17,7 @@ export default function Header() {
}
return (
<header className="w-full flex shrink-0 flex-row justify-between items-center border-b px-4 h-12">
<header className="w-full flex shrink-0 flex-row justify-between items-center border-b border-b-indigo-200 px-4 h-12">
<div className="flex flex-row gap-2 items-center">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />

View File

@@ -1,37 +1,261 @@
import { cn } from "@/lib/utils";
import { Calendar } from "@/components/ui/calendar";
import { useLayoutEffect, useRef, useState } from "react";
import { getDefaultClassNames } from "react-day-picker";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { isSameDay, getWeeksInMonth, getWeekOfMonth } from "date-fns";
import { SchedulePopover } from "../popover/schedule/SchedulePopover";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { Popover, PopoverTrigger } from "@/components/ui/popover";
import { isSameDay, getWeeksInMonth, getWeekOfMonth, eachDayOfInterval, parse, startOfMonth, startOfWeek, endOfWeek, endOfMonth, format, isSameMonth } from "date-fns";
import { SchedulePopover } from "../schedule/SchedulePopover";
import { ScheduleNetwork } from "@/network/ScheduleNetwork";
import { ScheduleListData } from "@/data/response";
import { CustomCalendarCN } from "./CustomCalendarCN";
import { toast } from "sonner";
import { Converter } from "@/util/Converter";
interface CustomCalendarProps {
data?: any;
}
interface EventBarPosition extends ScheduleListData {
positionStyle: React.CSSProperties;
trackIndex: number;
}
const MAX_VISIBLE_EVENTS = 3;
const OVERFLOW_TRACK_INDEX = MAX_VISIBLE_EVENTS + 1;
const TRACK_HEIGHT = 20;
const TOP_OFFSET_FROM_CELL = 35;
const DATE_FORMAT_ARIA = 'EEEE, MMMM do, yyyy';
const DATE_FORMAT_KEY = 'yyyyMMdd';
export const CustomCalendar = ({ data }: CustomCalendarProps) => {
const [weekCount, setWeekCount] = useState(5);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(undefined);
const [popoverOpen, setPopoverOpen] = useState(false);
const [popoverSide, setPopoverSide] = useState<'right' | 'left'>('right');
const [popoverAlign, setPopoverAlign] = useState<'start' | 'end'>('end');
const defaultClassNames = getDefaultClassNames();
const [month, setMonth] = useState(new Date());
const [currentDataMonth, setCurrentDataMonth] = useState(month);
const [scheduleList, setScheduleList] = useState<Array<ScheduleListData>>([]);
const [barPositions, setBarPositions] = useState<Array<EventBarPosition>>([]);
const scheduleNetwork = new ScheduleNetwork();
const containerRef = useRef<HTMLDivElement>(null);
const updateWeekCount = () => {
if (containerRef === null) return;
if (!containerRef.current) return;
const weeks = containerRef.current.querySelectorAll('.rdp-week');
if (weeks?.length) setWeekCount(weeks.length);
}
useLayoutEffect(() => {
useEffect(() => {
updateWeekCount();
}, []);
useLayoutEffect(() => {
updateWeekCount();
const reqList = async () => {
const requestedMonth = month;
const monthStart = startOfMonth(month);
const monthEnd = endOfMonth(month);
const startDate = startOfWeek(monthStart, { weekStartsOn: 0 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 0 });
const data = {
startDate: Converter.dateToUTC9(startDate),
endDate: Converter.dateToUTC9(endDate)
};
const result = await scheduleNetwork.getList(data);
if (result.data.success) {
if (result.data.data) {
if (isSameMonth(requestedMonth, month)) {
setScheduleList(result.data.data!);
setCurrentDataMonth(month);
}
setScheduleList(result.data.data);
}
}
}
requestAnimationFrame(() => {
const reqListPromise = reqList;
toast.promise(
reqListPromise,
{
loading: `${month.getFullYear()}${month.getMonth() + 1}월 일정을 불러오는 중입니다`,
success: `일정을 불러왔습니다.`,
error: `일정을 불러오는 데에 실패하였습니다.\n잠시 후 다시 시도해주십시오.`,
duration: 1000
},
)
updateWeekCount();
});
}, [month]);
useLayoutEffect(() => {
if (!isSameMonth(month, currentDataMonth)) {
setBarPositions([]);
return;
}
if (!containerRef.current || scheduleList.length === 0) {
setBarPositions([]);
return;
}
const containerRect = containerRef.current.getBoundingClientRect();
const dayCells = containerRef.current.querySelectorAll('.rdp-day button');
const cellInfoMap = new Map<string, { cell: HTMLElement, rect: DOMRect }>();
dayCells.forEach((cell) => {
const dayButton = cell as HTMLButtonElement;
const ariaLabel = dayButton.getAttribute('aria-label');
if (ariaLabel) {
try {
const parsedDate = parse(ariaLabel, DATE_FORMAT_ARIA, new Date());
if (!isNaN(parsedDate.getTime())) {
const dateKey = format(parsedDate, DATE_FORMAT_KEY);
cellInfoMap.set(dateKey, {
cell: dayButton,
rect: dayButton.getBoundingClientRect()
});
}
} catch (e) {}
}
});
const scheduleListWithTrack: (ScheduleListData & { trackIndex: number })[] = [];
const occupiedTrackList = new Map<string, number[]>();
const overflowCountMap = new Map<string, number>();
const sortedScheduleList = [...scheduleList].sort((a, b) =>
new Date(b.endDate).getTime() - new Date(b.startDate).getTime() -
(new Date(a.endDate).getTime() - new Date(a.startDate).getTime())
);
sortedScheduleList.forEach(schedule => {
const startDateObj = new Date(schedule.startDate);
const endDateObj = new Date(schedule.endDate);
const eventDays = eachDayOfInterval({ start: startDateObj, end: endDateObj });
let assignedTrack = -1;
for (let track = 0; track < MAX_VISIBLE_EVENTS; track++) {
let isAvailable = true;
for (const day of eventDays) {
const dayKey = format(day, DATE_FORMAT_KEY);
if (cellInfoMap.has(dayKey)) {
const occupied = occupiedTrackList.get(dayKey) || [];
if (occupied.includes(track)) {
isAvailable = false;
break;
}
}
}
if (isAvailable) {
assignedTrack = track;
break;
}
}
if (assignedTrack !== -1) {
for (const day of eventDays) {
const dayKey = format(day, DATE_FORMAT_KEY);
if (cellInfoMap.has(dayKey)) {
const occupied = occupiedTrackList.get(dayKey) || [];
occupiedTrackList.set(dayKey, [...occupied, assignedTrack]);
}
}
scheduleListWithTrack.push({ ...schedule, trackIndex: assignedTrack });
} else {
for (const day of eventDays) {
const dayKey = format(day, DATE_FORMAT_KEY);
if (cellInfoMap.has(dayKey)) {
overflowCountMap.set(dayKey, (overflowCountMap.get(dayKey) || 0) + 1);
}
}
}
});
const regularPositions: EventBarPosition[] = [];
scheduleListWithTrack.forEach(schedule => {
const startKey = format(new Date(schedule.startDate), DATE_FORMAT_KEY);
const endKey = format(new Date(schedule.endDate), DATE_FORMAT_KEY);
const startInfo = cellInfoMap.get(startKey);
const endInfo = cellInfoMap.get(endKey);
if (startInfo && endInfo) {
const startRect = startInfo.rect;
const endRect = endInfo.rect;
const baseTop = startRect.top - containerRect.top;
const top = baseTop + TOP_OFFSET_FROM_CELL + (schedule.trackIndex * (TRACK_HEIGHT + 5));
const left = startRect.left - containerRect.left + 5;
const width = endRect.right - startRect.left - 10;
regularPositions.push({
...schedule,
trackIndex: schedule.trackIndex,
positionStyle: {
top: `${top}px`,
left: `${left}px`,
width: `${width}px`,
height: `${TRACK_HEIGHT}px`
}
});
}
});
const overflowPositions: EventBarPosition[] = [];
overflowCountMap.forEach((count, dayKey) => {
const dayInfo = cellInfoMap.get(dayKey);
if (dayInfo) {
const dayRect = dayInfo.rect;
const baseTop = dayRect.top - containerRect.top;
const top = baseTop + TOP_OFFSET_FROM_CELL + ((OVERFLOW_TRACK_INDEX - 1) * (TRACK_HEIGHT + 5));
const left = dayRect.left - containerRect.left + 5;
const width = dayInfo.cell.clientWidth - 10;
overflowPositions.push({
id: `overflow-${dayKey}`,
name: `${count} more`,
startDate: Converter.dateToUTC9(new Date()),
endDate: Converter.dateToUTC9(new Date()),
style: '#9CA3AF',
trackIndex: OVERFLOW_TRACK_INDEX,
positionStyle: {
top: `${top}px`,
left: `${left}px`,
width: `${width}px`,
height: `${TRACK_HEIGHT}px`
}
} as EventBarPosition)
}
});
setBarPositions([...regularPositions, ...overflowPositions]);
}, [scheduleList, month, currentDataMonth]);
const handleMonthChange = async (month: Date) => {
setMonth(month);
}
const handleOpenChange = (open: boolean) => {
setPopoverOpen(open);
if (!open) {
@@ -82,7 +306,7 @@ export const CustomCalendar = ({ data }: CustomCalendarProps) => {
return (
<div
className="w-full h-full"
className="w-full h-full relative"
ref={containerRef}
>
<Popover
@@ -91,66 +315,12 @@ export const CustomCalendar = ({ data }: CustomCalendarProps) => {
>
<Calendar
mode="single"
className="h-full w-full border rounded-lg"
className="h-full w-full border border-indigo-200 rounded-lg shadow-sm shadow-indigo-200"
selected={selectedDate}
onSelect={handleDaySelect}
onMonthChange={() => {
// month 바뀐 직후 DOM 변화가 생기므로 다음 프레임에서 계산
requestAnimationFrame(() => {
updateWeekCount();
});
}}
classNames={{
months: cn(
defaultClassNames.months,
"w-full h-full relative"
),
nav: cn(
defaultClassNames.nav,
"flex w-full item-center gap-1 justify-around absolute top-0 inset-x-0"
),
month: cn(
defaultClassNames.month,
"h-full w-full flex flex-col"
),
month_grid: cn(
defaultClassNames.month_grid,
"w-full h-full flex-1"
),
weeks: cn(
defaultClassNames.weeks,
"w-full h-full"
),
weekdays: cn(
defaultClassNames.weekdays,
"w-full"
),
week: cn(
defaultClassNames.week,
`w-full`,
'custom-rdp-week'
),
day: cn(
defaultClassNames.day,
`w-[calc(100%/7)] rounded-none`,
'custom-rdp-day'
),
day_button: cn(
defaultClassNames.day_button,
"h-full w-full flex p-2 justify-start items-start",
"hover:bg-transparent",
"data-[selected-single=true]:bg-transparent data-[selected-single=true]:text-black"
),
selected: cn(
defaultClassNames.selected,
"h-full border-0 fill-transparent"
),
today: cn(
defaultClassNames.today,
"h-full"
),
}}
month={month}
onMonthChange={handleMonthChange}
classNames={CustomCalendarCN}
styles={{
day: {
height: `calc(100%/${weekCount})`
@@ -174,13 +344,26 @@ export const CustomCalendar = ({ data }: CustomCalendarProps) => {
DayButton: ({ day, ...props}) => (
<button
{...props}
disabled={day.outside}
>
{props.children}
</button>
)
}}
/>
{
barPositions.map(pos => (
<div
key={pos.id}
className={cn(
`flex flex-row justify-start items-center absolute`,
"py-0.5 px-2 rounded-sm text-xs text-white overflow-hidden pointer-events-none"
)}
style={{...pos.positionStyle, backgroundColor: pos.style}}
>
{pos.name}
</div>
))
}
<SchedulePopover
date={selectedDate}
open={popoverOpen}

View File

@@ -0,0 +1,55 @@
import { cn } from "@/lib/utils";
import { getDefaultClassNames } from "react-day-picker";
const defaultCN = getDefaultClassNames();
export const CustomCalendarCN = {
months: cn(
defaultCN.months,
"w-full h-full relative"
),
nav: cn(
defaultCN.nav,
"flex w-full item-center gap-1 justify-around absolute top-0 inset-x-0"
),
month: cn(
defaultCN.month,
"h-full w-full flex flex-col"
),
month_grid: cn(
defaultCN.month_grid,
"w-full h-full flex-1"
),
weeks: cn(
defaultCN.weeks,
"w-full h-full"
),
weekdays: cn(
defaultCN.weekdays,
"w-full"
),
week: cn(
defaultCN.week,
`w-full`,
'custom-rdp-week'
),
day: cn(
defaultCN.day,
`w-[calc(100%/7)] rounded-none`,
'custom-rdp-day'
),
day_button: cn(
defaultCN.day_button,
"h-full w-full flex p-2 justify-start items-start",
"hover:bg-transparent",
"data-[selected-single=true]:bg-transparent data-[selected-single=true]:text-black"
),
selected: cn(
defaultCN.selected,
"h-full border-0 fill-transparent"
),
today: cn(
defaultCN.today,
"h-full"
),
}

View File

@@ -1,12 +1,6 @@
import { PopoverContent } from '@/components/ui/popover';
import { ScrollArea } from '@/components/ui/scroll-area';
import { cn } from '@/lib/utils';
import { useState } from 'react';
import { PenSquare } from 'lucide-react';
import { useEffect, useState } from 'react';
import { ScheduleCreateContent } from './content/ScheduleCreateContent';
import { Button } from '@/components/ui/button';
import { ScheduleNetwork } from '@/network/ScheduleNetwork';
import { ScheduleListContent } from './content/ScheduleListContent';
interface ScheduleSheetProps {
@@ -19,6 +13,14 @@ interface ScheduleSheetProps {
export const SchedulePopover = ({ date, open, popoverSide, popoverAlign }: ScheduleSheetProps) => {
const [mode, setMode] = useState<'list' | 'create' | 'detail' | 'update'>('list');
useEffect(() => {
if (!open) {
setTimeout(() => {
setMode('list');
}, 150);
}
}, [open]);
const DetailContent = () => {
return (
<div>

View File

@@ -21,11 +21,12 @@ import { useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import * as z from 'zod';
import { ColorPickPopover } from '../ColorPickPopover';
import { DatePickPopover } from '../DatePickPopover';
import { TimePickPopover } from '../TimePickPopover';
import { TypePickPopover } from '../TypePickPopover';
import { ColorPickPopover } from '../popover/ColorPickPopover';
import { DatePickPopover } from '../popover/DatePickPopover';
import { TimePickPopover } from '../popover/TimePickPopover';
import { TypePickPopover } from '../popover/TypePickPopover';
import type { ScheduleCreateContentProps } from './ContentProps';
import { Converter } from '@/util/Converter';
export const ScheduleCreateContent = ({ date, setMode, popoverSide, popoverAlign }: ScheduleCreateContentProps) => {
const [colorPopoverOpen, setColorPopoverOpen] = useState(false);
@@ -75,8 +76,8 @@ export const ScheduleCreateContent = ({ date, setMode, popoverSide, popoverAlign
if (isLoading) return;
const data = {
name,
startDate,
endDate,
startDate: Converter.dateToUTC9(startDate),
endDate: Converter.dateToUTC9(endDate),
content,
startTime: standardTimeToContinentalTime(startTime),
endTime: standardTimeToContinentalTime(endTime),
@@ -226,7 +227,7 @@ export const ScheduleCreateContent = ({ date, setMode, popoverSide, popoverAlign
<PopoverTrigger asChild>
<div
className={cn(
'rounded-full w-5 h-5 border-2 border-gray-300',
'rounded-full w-5 h-5 border-2 border-gray-300 hover:border-indigo-300 transition-all duration-150',
)}
style={{
backgroundColor: `${style}`,

View File

@@ -11,6 +11,8 @@ import { ScheduleNetwork } from "@/network/ScheduleNetwork";
import type { ScheduleStatus } from "@/const/schedule/ScheduleStatus";
import type { ScheduleType } from "@/const/schedule/ScheduleType";
import { ScheduleListData } from "@/data/response";
import { Converter } from "@/util/Converter";
import { ScheduleListTile } from "../tile/ScheduleListTile";
export const ScheduleListContent = ({ date, setMode, popoverAlign, popoverSide, open }: ScheduleListContentProps) => {
const [isLoading, setIsLoading] = useState(false);
@@ -21,8 +23,7 @@ export const ScheduleListContent = ({ date, setMode, popoverAlign, popoverSide,
resolver: zodResolver(ListScheduleSchema),
defaultValues: {
name: undefined,
startDate: undefined,
endDate: undefined,
date: date,
status: undefined,
typeList: undefined,
styleList: undefined
@@ -31,8 +32,7 @@ export const ScheduleListContent = ({ date, setMode, popoverAlign, popoverSide,
const {
name,
startDate,
endDate,
date: searchDate,
status,
typeList,
styleList
@@ -52,8 +52,7 @@ export const ScheduleListContent = ({ date, setMode, popoverAlign, popoverSide,
const reqList = async () => {
const data = {
name,
startDate: date,
endDate: date,
date: Converter.dateToUTC9(searchDate),
status: status as ScheduleStatus | undefined,
styleList,
typeList: typeList as ScheduleType[] | undefined
@@ -80,9 +79,16 @@ export const ScheduleListContent = ({ date, setMode, popoverAlign, popoverSide,
</div>
<div className="w-full h-[calc(100%-40px)]">
<ScrollArea
className="w-full h-full flex flex-col justify-start items-center"
className="w-full h-full"
>
<div className="w-full h-full flex flex-col justify-start items-start gap-3">
{ scheduleList.map(schedule => (
<ScheduleListTile
data={schedule}
setMode={setMode}
/>
)) }
</div>
</ScrollArea>
</div>
</div>

View File

@@ -81,7 +81,7 @@ export const ColorPickPopover = ({ setColor }: ColorPickPopoverProps) => {
className={cn(
"w-0 h-0 border-l-8 border-l-transparent border-r-8 border-r-transparent border-b-14 border-b-indigo-300",
"group-hover:border-b-white trnasition-all duration-150",
seeMore && "rotate-180"
!seeMore && "rotate-180"
)}
/>
</div>

View File

@@ -0,0 +1,34 @@
import { ScheduleTypeLabel } from "@/const/schedule/ScheduleType";
import { ScheduleListData } from "@/data/response";
import { Converter } from "@/util/Converter";
interface ScheduleListTileProps {
setMode: (mode: 'list' | 'create' | 'detail' | 'update') => void;
data: ScheduleListData;
}
export const ScheduleListTile = ({ setMode, data }: ScheduleListTileProps) => {
const formatter = Converter.isoStringToFormattedString;
return (
<div
className="w-full h-15 rounded-sm border flex flex-row items-center cursor-default group"
>
<div className={`w-6 h-full rounded-l-xs group-hover:w-10 transition-all duration-150`} style={{backgroundColor: `${data.style}CC`}} />
<div
className="w-[calc(100%-24px)] px-2 h-full flex flex-col justify-center items-start"
>
<div className="flex-6 h-full flex flex-row justify-end items-start">
<span
className="text-lg font-semibold text-gray-700"
>
{data.name}
</span>
</div>
<div className="flex-4 w-full flex flex-row text-xs font-light items-center text-gray-300">
{formatter(data.startDate)} - {formatter(data.endDate)}
</div>
</div>
</div>
)
}

16
src/util/Converter.ts Normal file
View File

@@ -0,0 +1,16 @@
import { format } from "date-fns";
export class Converter {
static dateToUTC9(date: Date) {
const utc9Date = new Date(date);
utc9Date.setHours(9);
return utc9Date.toISOString();
}
static isoStringToFormattedString(isoString: string) {
const isoDate = new Date(isoString);
const dateFormatter = "yyyy년 MM월 dd일";
return format(isoDate, dateFormatter);
}
}