Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
7 changes: 7 additions & 0 deletions src/features/ticket/api/ticket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { axiosClient } from "../../../shared/types/api/http-client";
import { CreateTicketRequest } from "../model/ticketCreation";

export const createTicket = async(data: CreateTicketRequest) => {
const response = await axiosClient.post('/tickets', data);
return response.data;
}
41 changes: 41 additions & 0 deletions src/features/ticket/model/TicketContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { createContext, ReactNode, useContext, useState } from 'react';

export interface TicketState {
ticketState: {
startDate: string;
endDate: string;
startTime: string;
endTime: string;
};
setTicketState: React.Dispatch<React.SetStateAction<TicketState['ticketState']>>;
setTicketChannelId: (ticketChannelId: number) => void;
}

const TicketContext = createContext<TicketState | undefined>(undefined);

export const TicketProvider = ({ children }: { children: ReactNode }) => {
const [ticketState, setTicketState] = useState<TicketState['ticketState']>({
startDate: '',
endDate: '',
startTime: '06:00',
endTime: '23:00',
});

const setTicketChannelId = (ticketChannelId: number) => {
setTicketState(prev => ({ ...prev, ticketChannelId }));
};

return (
<TicketContext.Provider value={{ ticketState, setTicketState, setTicketChannelId }}>
{children}
</TicketContext.Provider>
);
};

export const useTicketState = () => {
const context = useContext(TicketContext);
if (!context) {
throw new Error('useTicketState must be used within a TicketProvider');
}
return context;
};
176 changes: 176 additions & 0 deletions src/features/ticket/model/TicketDatePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { useEffect, useState } from 'react';
import DatePicker from 'react-datepicker';
import { ko } from 'date-fns/locale';
import 'react-datepicker/dist/react-datepicker.css';
import { TicketState } from '../model/TicketContext';

interface DatePickerProps {
className?: string;
ticketState?: TicketState['ticketState'];
setTicketState?: React.Dispatch<React.SetStateAction<TicketState['ticketState']>>;
isLabel?: boolean;
onDateChange: (dates: { startDate: string; endDate: string; startTime: string; endTime: string }) => void;
}

const TicketDatePicker = ({ className, ticketState, setTicketState, isLabel = false, onDateChange }: DatePickerProps) => {
const [startDate, setStartDate] = useState<Date | null>(
ticketState?.startDate ? new Date(ticketState.startDate) : new Date()
);
const [endDate, setEndDate] = useState<Date | null>(ticketState?.endDate ? new Date(ticketState.endDate) : new Date());
const [startTime, setStartTime] = useState<string>(ticketState?.startTime || '06:00');
const [endTime, setEndTime] = useState<string>(ticketState?.endTime || '23:00');

const generateTimeOptions = () => {
const options = [];
for (let i = 0; i < 24; i++) {
for (let j = 0; j < 4; j++) {
const hour = i.toString().padStart(2, '0');
const minute = (j * 15).toString().padEnd(2, '0');
options.push(`${hour}:${minute}`);
}
}
return options;
};

const formatDate = (date: Date | null) => {
if (!date) return '';
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, '0');
const day = date.getDate().toString().padStart(2, '0');
return `${year}-${month}-${day}`; // yyyy-mm-dd 형태로 포맷팅
};

const timeOptions = generateTimeOptions();

useEffect(() => {
if (setTicketState) {
// Update state only when values change
setTicketState(prev => {
const newStartDate = startDate ? formatDate(startDate) : '';
const newEndDate = endDate ? formatDate(endDate) : '';
if (
prev.startDate !== newStartDate ||
prev.endDate !== newEndDate ||
prev.startTime !== startTime ||
prev.endTime !== endTime
) {
// Call the parent function to pass updated values
onDateChange({
startDate: newStartDate,
endDate: newEndDate,
startTime,
endTime,
});
return {
...prev,
startDate: newStartDate,
endDate: newEndDate,
startTime,
endTime,
};
}
return prev; // Return previous state if no change
});
}
}, [startDate, endDate, startTime, endTime, setTicketState, onDateChange]);

return (
<div className={`flex flex-col w-full ${className}`}>
<div className="flex flex-wrap lg:flex-nowrap items-center justify-between gap-2">
<div className="flex flex-col w-full sm:w-auto gap-2">
{!isLabel && <span className="text-sm font-medium">시작 날짜</span>}
<div className="flex gap-1">
<DatePicker
id="startDate"
selected={startDate}
onChange={(date: Date | null) => setStartDate(date)}
locale={ko}
dateFormat="MM월 dd일"
className="w-20 h-9 md:w-24 md:h-10 border border-placeholderText text-sm md:text-md rounded-[5px] p-2"
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex justify-center gap-4">
<button onClick={decreaseMonth} disabled={prevMonthButtonDisabled} className="mb-1">
&lt;
</button>
<span>
{date.getFullYear()}년 {date.getMonth() + 1}월
</span>
<button onClick={increaseMonth} disabled={nextMonthButtonDisabled} className="mb-1">
&gt;
</button>
</div>
)}
/>
<select
id="startTime"
value={startTime}
onChange={e => setStartTime(e.target.value)}
className="w-20 h-9 md:w-24 md:h-10 border border-placeholderText text-sm md:text-md rounded-[5px] p-2"
>
{timeOptions.map(time => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
</div>

{isLabel && <span className="text-2xl hidden lg:inline">&gt;</span>}

<div className="flex flex-col w-full sm:w-auto gap-2">
{!isLabel && <span className="text-sm font-medium">종료 날짜</span>}
<div className="flex gap-1">
<DatePicker
id="endDate"
selected={endDate}
onChange={(date: Date | null) => setEndDate(date)}
locale={ko}
dateFormat="MM월 dd일"
className="w-20 h-9 md:w-24 md:h-10 border border-placeholderText text-sm md:text-md rounded-[5px] p-2"
renderCustomHeader={({
date,
decreaseMonth,
increaseMonth,
prevMonthButtonDisabled,
nextMonthButtonDisabled,
}) => (
<div className="flex justify-center gap-4">
<button onClick={decreaseMonth} disabled={prevMonthButtonDisabled} className="mb-1">
&lt;
</button>
<span>
{date.getFullYear()}년 {date.getMonth() + 1}월
</span>
<button onClick={increaseMonth} disabled={nextMonthButtonDisabled} className="mb-1">
&gt;
</button>
</div>
)}
/>
<select
id="endTime"
value={endTime}
onChange={e => setEndTime(e.target.value)}
className="w-20 h-9 md:w-24 md:h-10 border border-placeholderText text-sm md:text-md rounded-[5px] p-2"
>
{timeOptions.map(time => (
<option key={time} value={time}>
{time}
</option>
))}
</select>
</div>
</div>
</div>
</div>
);
};

export default TicketDatePicker;
12 changes: 12 additions & 0 deletions src/features/ticket/model/ticketCreation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export interface CreateTicketRequest {
eventId: number;
ticketType: string;
ticketName: string;
ticketDescription: string;
ticketPrice: number;
availableQuantity: number;
startDate: string;
endDate: string;
startTime: string;
endTime: string;
}
89 changes: 74 additions & 15 deletions src/pages/dashboard/ui/ticket/TicketCreatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,66 @@ import { TwoOptions } from '../../../../../design-system/stories/ChoiceChip.stor
import ChoiceChip from '../../../../../design-system/ui/ChoiceChip';
import DefaultTextField from '../../../../../design-system/ui/textFields/DefaultTextField';
import Button from '../../../../../design-system/ui/Button';
import EventDatePicker from '../../../../features/event-manage/event-create/ui/DatePicker';
import TicketDatePicker from '../../../../features/ticket/model/TicketDatePicker';
import { createTicket } from '../../../../features/ticket/api/ticket';
import { CreateTicketRequest } from '../../../../features/ticket/model/ticketCreation';

const TicketCreatePage = () => {
const [price, setPrice] = useState<number>(0);
const [quantity, setQuantity] = useState<number>(0);
const [ticketData, setTicketData] = useState<CreateTicketRequest>({
eventId: 1,
ticketType: 'FIRST_COME',
ticketName: '',
ticketDescription: '',
ticketPrice: 0,
availableQuantity: 0,
startDate: '',
endDate: '',
startTime: '',
endTime: '',
});
const [eventState, setEventState] = useState({
startDate: '',
endDate: '',
startTime: '',
endTime: '',
});

const handlePriceChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(e.target.value);
setPrice(Number.isNaN(value) ? 0 : value);
const handleTicketTypeChange = (type: string) => {
let mappedType: string;
if (type === '선착순') {
mappedType = 'FIRST_COME';
} else {
mappedType = 'SELECTION';
}
setTicketData((prev) => ({
...prev,
ticketType: mappedType,
}));
};

const handleQuantityChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = Number(e.target.value);
setQuantity(Number.isNaN(value) ? 0 : value);
// 필드값 업데이트
const handleInputChange = (field: keyof CreateTicketRequest) => (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setTicketData((prev) => ({
...prev,
[field]: field === 'ticketPrice' || field === 'availableQuantity' ? Number(value) : value,
}));
};

const sum = price * quantity;
// 시간 업데이트
const handleDateChange = (dates: { startDate: string; endDate: string; startTime: string; endTime: string }) => {
setEventState(dates);
setTicketData((prevState) => ({
...prevState,
startDate: dates.startDate,
endDate: dates.endDate,
startTime: dates.startTime,
endTime: dates.endTime,
}));
};

// 예상 수익
const sum = ticketData.ticketPrice * ticketData.availableQuantity;
const formatNumber = (num: number): string => {
if (num >= 1000000) {
return num / 1000000 + 'M';
Expand All @@ -32,6 +74,16 @@ const TicketCreatePage = () => {
return num.toString();
};

// API 호출
const handleSaveClick = async () => {
try {
const response = await createTicket(ticketData);
console.log('티켓 저장 성공:', response);
} catch (err) {
console.error('티켓 저장에 실패했습니다.', err);
}
};
Comment on lines +74 to +87
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

폼 유효성 검사가 불완전합니다.

handleSaveClick 함수에서 필수 필드에 대한 유효성 검사를 수행하고 있지만, 날짜와 시간 필드(startDate, endDate, startTime, endTime)에 대한 검사가 누락되어 있습니다. 또한 유효성 검사에서 ticketPrice < 0만 체크하고 있는데, 가격이 0이어도 괜찮은지 비즈니스 로직을 확인해야 합니다.

const handleSaveClick = async () => {
-  if (!ticketData.ticketName || !ticketData.ticketDescription || ticketData.ticketPrice < 0 || !ticketData.availableQuantity) {
+  if (!ticketData.ticketName || !ticketData.ticketDescription || ticketData.ticketPrice < 0 || 
+      !ticketData.availableQuantity || !ticketData.startDate || !ticketData.endDate || 
+      !ticketData.startTime || !ticketData.endTime) {
    alert('모든 필수 입력 항목을 작성해주세요.');
    return;
  }
+  
+  // 날짜 유효성 검사
+  const start = new Date(`${ticketData.startDate}T${ticketData.startTime}`);
+  const end = new Date(`${ticketData.endDate}T${ticketData.endTime}`);
+  
+  if (isNaN(start.getTime()) || isNaN(end.getTime())) {
+    alert('유효하지 않은 날짜 또는 시간 형식입니다.');
+    return;
+  }
+  
+  if (start >= end) {
+    alert('종료 일시는 시작 일시보다 이후여야 합니다.');
+    return;
+  }
  
  try {
    const response = await createTicket(ticketData);
    console.log('티켓 저장 성공:', response);
    alert('티켓이 성공적으로 저장되었습니다.');
  } catch (err) {
    console.error('티켓 저장에 실패했습니다.', err);
    alert('티켓 저장에 실패했습니다. 다시 시도해주세요.');
  }
};
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleSaveClick = async () => {
if (!ticketData.ticketName || !ticketData.ticketDescription || ticketData.ticketPrice < 0 || !ticketData.availableQuantity) {
alert('모든 필수 입력 항목을 작성해주세요.');
return;
}
try {
const response = await createTicket(ticketData);
console.log('티켓 저장 성공:', response);
alert('티켓이 성공적으로 저장되었습니다.');
} catch (err) {
console.error('티켓 저장에 실패했습니다.', err);
alert('티켓 저장에 실패했습니다. 다시 시도해주세요.');
}
};
const handleSaveClick = async () => {
if (
!ticketData.ticketName ||
!ticketData.ticketDescription ||
ticketData.ticketPrice < 0 ||
!ticketData.availableQuantity ||
!ticketData.startDate ||
!ticketData.endDate ||
!ticketData.startTime ||
!ticketData.endTime
) {
alert('모든 필수 입력 항목을 작성해주세요.');
return;
}
// 날짜 유효성 검사
const start = new Date(`${ticketData.startDate}T${ticketData.startTime}`);
const end = new Date(`${ticketData.endDate}T${ticketData.endTime}`);
if (isNaN(start.getTime()) || isNaN(end.getTime())) {
alert('유효하지 않은 날짜 또는 시간 형식입니다.');
return;
}
if (start >= end) {
alert('종료 일시는 시작 일시보다 이후여야 합니다.');
return;
}
try {
const response = await createTicket(ticketData);
console.log('티켓 저장 성공:', response);
alert('티켓이 성공적으로 저장되었습니다.');
} catch (err) {
console.error('티켓 저장에 실패했습니다.', err);
alert('티켓 저장에 실패했습니다. 다시 시도해주세요.');
}
};


return (
<DashboardLayout centerContent="WOOACON 2024">
<div className=" flex flex-col gap-3 md:gap-5 px-7 py-5">
Expand All @@ -45,7 +97,7 @@ const TicketCreatePage = () => {
<div>
<div className="w-32 md:w-40 my-1">
<p className="font-semibold px-1 mb-1 text-gray-700">티켓 종류</p>
<ChoiceChip {...TwoOptions.args} />
<ChoiceChip {...TwoOptions.args} onSelect={handleTicketTypeChange}/>
</div>
<p className="block px-1 mb-1 text-placeholderText text-11 md:text-13">
참가자가 선착순으로 발행된 티켓을 구매합니다.
Expand All @@ -58,6 +110,7 @@ const TicketCreatePage = () => {
label="티켓(입장권) 이름"
detail="티켓을 잘 나타낼 수 있는 이름을 써보세요.(무료 입장권, VIP 입장권,얼리버드)"
className="h-12"
onChange={handleInputChange('ticketName')}
/>
</div>
{/*티켓 설명 입력란*/}
Expand All @@ -67,14 +120,15 @@ const TicketCreatePage = () => {
label="티켓 설명"
detail="티켓에 대한 상세한 설명을 해주세요."
className="h-12"
onChange={handleInputChange('ticketDescription')}
/>
</div>

{/*가격 계산 란*/}
<div className="flex items-center gap-5">
<DefaultTextField label="1개당 가격" className="h-8 md:h-9" onChange={handlePriceChange} placeholder="0" />
<DefaultTextField label="1개당 가격" className="h-8 md:h-9" onChange={handleInputChange('ticketPrice')} placeholder="0" />
<p className="text-gray-700 text-2xl">X</p>
<DefaultTextField label="수량" className="h-8 md:h-9" onChange={handleQuantityChange} placeholder="1" />
<DefaultTextField label="수량" className="h-8 md:h-9" onChange={handleInputChange('availableQuantity')} placeholder="1" />
<p className="text-gray-700 text-2xl">=</p>
<div>
<p className="whitespace-nowrap text-gray-700 font-semibold text-15 md:text-base">예상 수익</p>
Expand All @@ -85,16 +139,21 @@ const TicketCreatePage = () => {
{/*캘린더가 들어갈 자리*/}
<div className="flex flex-col gap-2">
<p className="px-1 text-gray-700 font-semibold">판매 기간</p>
<EventDatePicker isLabel={true} />
<TicketDatePicker isLabel={true} ticketState={eventState}
setTicketState={setEventState} onDateChange={handleDateChange}/>
</div>
<div className="flex-grow"></div>
<div className="ticket-data-output">
<pre>{JSON.stringify(ticketData, null, 2)}</pre>
</div>

<div className="w-full ">
<Button label="저장하기" onClick={() => console.log('post 요청')} className="w-full h-12 rounded-full" />
<Button label="저장하기" onClick={handleSaveClick} className="w-full h-12 rounded-full" />
</div>
</div>
</DashboardLayout>
);
};

export default TicketCreatePage;

Loading