Skip to content

Commit 9b4c8c2

Browse files
committed
feat: 발표 어드민에서 스케줄 정보 등록 기능 추가
1 parent 41543ca commit 9b4c8c2

File tree

3 files changed

+334
-20
lines changed

3 files changed

+334
-20
lines changed

apps/pyconkr-admin/src/components/pages/presentation/editor.tsx

Lines changed: 183 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import * as Common from "@frontend/common";
22
import { Autocomplete, Box, Button, Card, CardContent, CircularProgress, Stack, styled, Tab, Tabs, TextField, Typography } from "@mui/material";
3+
import { DateTimePicker, LocalizationProvider } from "@mui/x-date-pickers";
4+
import { AdapterLuxon } from "@mui/x-date-pickers/AdapterLuxon";
5+
import { PickerValue } from "@mui/x-date-pickers/internals";
36
import { ErrorBoundary, Suspense } from "@suspensive/react";
7+
import { DateTime } from "luxon";
48
import { enqueueSnackbar, OptionsObject } from "notistack";
59
import * as React from "react";
610
import { useParams } from "react-router-dom";
@@ -155,8 +159,107 @@ const PresentationSpeakerForm: React.FC<PresentationSpeakerFormPropType> = ({ di
155159
);
156160
};
157161

162+
type ScheduleSchemaType = {
163+
schema: {
164+
type: "object";
165+
properties: {
166+
room: { type: "string"; oneOf: enumItemType[] };
167+
presentation: { type: "string"; oneOf: enumItemType[] };
168+
start_at: { type: "string"; format: "date-time" };
169+
end_at: { type: "string"; format: "date-time" };
170+
};
171+
required?: string[];
172+
$schema?: string;
173+
};
174+
ui_schema?: Record<string, { "ui:widget"?: string; "ui:field"?: string }>;
175+
translation_fields?: string[];
176+
};
177+
178+
type OnMemorySchedule = {
179+
id?: string;
180+
trackId: string; // Unique identifier for the schedule item, used for local state management
181+
room: string;
182+
presentation: string;
183+
start_at: string; // ISO 8601 date-time string
184+
end_at: string; // ISO 8601 date-time string
185+
};
186+
187+
type Schedule = Omit<OnMemorySchedule, "trackId"> & { id: string };
188+
189+
type ScheduleFormPropType = {
190+
schema: ScheduleSchemaType;
191+
disabled?: boolean;
192+
schedule: OnMemorySchedule;
193+
onChange: (schedule: OnMemorySchedule) => void;
194+
onRemove: (schedule: OnMemorySchedule) => void;
195+
};
196+
197+
const PresentationScheduleForm: React.FC<ScheduleFormPropType> = ({ schema, disabled, schedule, onChange, onRemove }) => {
198+
const roomOptions: AutoCompleteType[] = schema.schema.properties.room.oneOf.map((item) => ({
199+
name: "room",
200+
value: item.const || "",
201+
label: item.title,
202+
}));
203+
const currentSelectedRoom = roomOptions.find((r) => r.value === schedule.room?.toString());
204+
205+
const onSelectChange = (fieldName: string) => (_: React.SyntheticEvent, selected: AutoCompleteType | null) => {
206+
onChange({ ...schedule, [fieldName]: selected?.value || "" });
207+
};
208+
209+
const onScheduleTimeChange = (fieldName: string) => (value: PickerValue) => {
210+
if (!value || !DateTime.isDateTime(value)) {
211+
console.warn(`Invalid date-time value for ${fieldName}:`, value);
212+
return;
213+
}
214+
onChange({ ...schedule, [fieldName]: value.toISO({ includeOffset: false }) });
215+
};
216+
const onScheduleRemove = () => window.confirm("스케줄을 삭제하시겠습니까?") && onRemove(schedule);
217+
218+
return (
219+
<Card>
220+
<CardContent>
221+
<Stack spacing={2}>
222+
<Autocomplete
223+
fullWidth
224+
defaultValue={currentSelectedRoom}
225+
value={currentSelectedRoom}
226+
onChange={onSelectChange("room")}
227+
options={roomOptions}
228+
disabled={disabled}
229+
renderInput={(params) => <TextField {...params} label="발표 장소" />}
230+
/>
231+
<LocalizationProvider dateAdapter={AdapterLuxon} adapterLocale="ko-kr">
232+
<DateTimePicker
233+
disabled={disabled}
234+
name="start_at"
235+
label="시작 시각"
236+
value={DateTime.fromISO(schedule.start_at)}
237+
onChange={onScheduleTimeChange("start_at")}
238+
minDateTime={DateTime.local(2000, 1, 1, 0, 0, 0)}
239+
maxDateTime={DateTime.local(2100, 12, 31, 23, 59, 59)}
240+
disablePast
241+
/>
242+
<DateTimePicker
243+
disabled={disabled}
244+
name="end_at"
245+
label="종료 시각"
246+
value={DateTime.fromISO(schedule.end_at)}
247+
onChange={onScheduleTimeChange("end_at")}
248+
minDateTime={DateTime.local(2000, 1, 1, 0, 0, 0)}
249+
maxDateTime={DateTime.local(2100, 12, 31, 23, 59, 59)}
250+
disablePast
251+
/>
252+
</LocalizationProvider>
253+
<Button variant="outlined" color="error" onClick={onScheduleRemove} children="스케줄 삭제" />
254+
</Stack>
255+
</CardContent>
256+
</Card>
257+
);
258+
};
259+
158260
type PresentationEditorStateType = {
159261
speakers: OnMemoeryPresentationSpeaker[];
262+
schedules: OnMemorySchedule[];
160263
};
161264

162265
export const AdminPresentationEditor: React.FC = ErrorBoundary.with(
@@ -177,6 +280,14 @@ export const AdminPresentationEditor: React.FC = ErrorBoundary.with(
177280
const { data: speakerInitialData } = Common.Hooks.BackendAdminAPI.useListQuery<PresentationSpeaker>(...speakerQueryParams, { presentation });
178281
const speakers = speakerInitialData.map((s) => ({ ...s, trackId: s.id || Math.random().toString(36).substring(2, 15) }));
179282

283+
const scheduleQueryParams = [backendAdminAPIClient, "event", "roomschedule"] as const;
284+
const scheduleCreateMutation = Common.Hooks.BackendAdminAPI.useCreateMutation<OnMemorySchedule>(...scheduleQueryParams);
285+
const scheduleUpdateMutation = Common.Hooks.BackendAdminAPI.useUpdatePreparedMutation<Schedule>(...scheduleQueryParams);
286+
const scheduleDeleteMutation = Common.Hooks.BackendAdminAPI.useRemovePreparedMutation(...scheduleQueryParams);
287+
const { data: scheduleJsonSchema } = Common.Hooks.BackendAdminAPI.useSchemaQuery(...scheduleQueryParams);
288+
const { data: scheduleInitialData } = Common.Hooks.BackendAdminAPI.useListQuery<Schedule>(...scheduleQueryParams, { presentation });
289+
const schedules = scheduleInitialData.map((s) => ({ ...s, trackId: s.id || Math.random().toString(36).substring(2, 15) }));
290+
180291
const createEmptySpeaker = (): OnMemoeryPresentationSpeaker => ({
181292
trackId: Math.random().toString(36).substring(2, 15),
182293
presentation,
@@ -186,13 +297,30 @@ export const AdminPresentationEditor: React.FC = ErrorBoundary.with(
186297
biography_en: "",
187298
});
188299

189-
const [editorState, setEditorState] = React.useState<PresentationEditorStateType>({ speakers });
300+
const createEmptySchedule = (): OnMemorySchedule => ({
301+
trackId: Math.random().toString(36).substring(2, 15),
302+
room: "",
303+
presentation,
304+
start_at: DateTime.now().toISO({ includeOffset: false }),
305+
end_at: DateTime.now().plus({ hours: 1 }).toISO({ includeOffset: false }),
306+
});
307+
308+
const [editorState, setEditorState] = React.useState<PresentationEditorStateType>({ speakers, schedules });
190309
const onSpeakerCreate = () => setEditorState((ps) => ({ ...ps, speakers: [...ps.speakers, createEmptySpeaker()] }));
191310
const onSpeakerRemove = (oldSpeaker: OnMemoeryPresentationSpeaker) =>
192311
setEditorState((ps) => ({ ...ps, speakers: ps.speakers.filter((s) => s.trackId !== oldSpeaker.trackId) }));
193312
const onSpeakerChange = (newSpeaker: OnMemoeryPresentationSpeaker) =>
194313
setEditorState((ps) => ({ ...ps, speakers: ps.speakers.map((s) => (s.trackId === newSpeaker.trackId ? newSpeaker : s)) }));
195314

315+
const onScheduleCreate = () => setEditorState((ps) => ({ ...ps, schedules: [...ps.schedules, createEmptySchedule()] }));
316+
const onScheduleRemove = (oldSchedule: OnMemorySchedule) =>
317+
setEditorState((ps) => ({ ...ps, schedules: ps.schedules.filter((s) => s.trackId !== oldSchedule.trackId) }));
318+
const onScheduleChange = (newSchedule: OnMemorySchedule) =>
319+
setEditorState((ps) => ({
320+
...ps,
321+
schedules: ps.schedules.map((s) => (s.trackId === newSchedule.trackId ? newSchedule : s)),
322+
}));
323+
196324
const onSpeakerSubmit = () => {
197325
if (!id) return;
198326

@@ -204,31 +332,68 @@ export const AdminPresentationEditor: React.FC = ErrorBoundary.with(
204332
const deleteMut = deletedSpeakerIds.map((id) => speakerDeleteMutation.mutateAsync(id));
205333
const createMut = newSpeakers.filter((s) => s.id === undefined).map((s) => speakerCreateMutation.mutateAsync(s));
206334
const updateMut = newSpeakers.filter((s) => s.id !== undefined).map((s) => speakerUpdateMutation.mutateAsync(s as PresentationSpeaker));
207-
Promise.all([...deleteMut, ...createMut, ...updateMut]).then(() => addSnackbar("발표자 정보가 저장되었습니다.", "success"));
335+
return Promise.all([...deleteMut, ...createMut, ...updateMut]).then(() => addSnackbar("발표자 정보가 저장되었습니다.", "success"));
336+
};
337+
338+
const onScheduleSubmit = () => {
339+
if (!id) return;
340+
341+
addSnackbar("스케줄 정보를 저장하는 중입니다...", "info");
342+
const newSchedules = editorState.schedules;
343+
const editorScheduleIds = newSchedules.filter((s) => s.id).map((s) => s.id!);
344+
const deletedScheduleIds = scheduleInitialData.filter((s) => !editorScheduleIds.includes(s.id)).map((s) => s.id!);
345+
346+
const deleteMut = deletedScheduleIds.map((id) => scheduleDeleteMutation.mutateAsync(id));
347+
const createMut = newSchedules.filter((s) => s.id === undefined).map((s) => scheduleCreateMutation.mutateAsync(s));
348+
const updateMut = newSchedules.filter((s) => s.id !== undefined).map((s) => scheduleUpdateMutation.mutateAsync(s as Schedule));
349+
return Promise.all([...deleteMut, ...createMut, ...updateMut]).then(() => addSnackbar("스케줄 정보가 저장되었습니다.", "success"));
350+
};
351+
352+
const onPresentationSubmit = () => {
353+
if (!id) return;
354+
addSnackbar("발표 정보를 저장하는 중입니다...", "info");
355+
return Promise.all([onSpeakerSubmit(), onScheduleSubmit()]).then(() => addSnackbar("발표 정보가 저장되었습니다.", "success"));
208356
};
209357

210358
return (
211-
<AdminEditor app="event" resource="presentation" id={id} afterSubmit={onSpeakerSubmit}>
359+
<AdminEditor app="event" resource="presentation" id={id} afterSubmit={onPresentationSubmit}>
212360
{id ? (
213361
<Stack sx={{ mb: 2 }} spacing={2}>
214-
<Typography variant="h6">발표자 정보</Typography>
215-
<Stack spacing={2}>
216-
{editorState.speakers.map((s) => (
217-
<PresentationSpeakerForm
218-
key={s.id}
219-
schema={speakerJsonSchema as SpeakerSchemaType}
220-
speaker={s}
221-
onChange={onSpeakerChange}
222-
onRemove={onSpeakerRemove}
223-
/>
224-
))}
225-
<Button variant="outlined" onClick={onSpeakerCreate} children="발표자 추가" />
226-
</Stack>
362+
<Common.Components.Fieldset legend="스케줄 정보">
363+
<Typography variant="h6">스케줄 정보</Typography>
364+
<Stack spacing={2}>
365+
{editorState.schedules.map((s) => (
366+
<PresentationScheduleForm
367+
key={s.trackId}
368+
schema={scheduleJsonSchema as ScheduleSchemaType}
369+
schedule={s}
370+
onChange={onScheduleChange}
371+
onRemove={onScheduleRemove}
372+
/>
373+
))}
374+
<Button variant="outlined" onClick={onScheduleCreate} children="스케줄 추가" />
375+
</Stack>
376+
</Common.Components.Fieldset>
377+
<Common.Components.Fieldset legend="발표자 정보">
378+
<Typography variant="h6">발표자 정보</Typography>
379+
<Stack spacing={2}>
380+
{editorState.speakers.map((s) => (
381+
<PresentationSpeakerForm
382+
key={s.id}
383+
schema={speakerJsonSchema as SpeakerSchemaType}
384+
speaker={s}
385+
onChange={onSpeakerChange}
386+
onRemove={onSpeakerRemove}
387+
/>
388+
))}
389+
<Button variant="outlined" onClick={onSpeakerCreate} children="발표자 추가" />
390+
</Stack>
391+
</Common.Components.Fieldset>
227392
</Stack>
228393
) : (
229394
<Stack>
230-
<Typography variant="h6">발표자 정보</Typography>
231-
<Typography variant="body1">발표자를 추가하려면 발표를 먼저 저장하세요.</Typography>
395+
<Typography variant="h6">발표자 & 스케줄 정보</Typography>
396+
<Typography variant="body1">발표자나 스케줄 정보를 추가하려면 발표를 먼저 저장하세요.</Typography>
232397
</Stack>
233398
)}
234399
</AdminEditor>

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@mui/icons-material": "^7.1.0",
3535
"@mui/material": "^7.1.0",
3636
"@mui/system": "^7.1.1",
37+
"@mui/x-date-pickers": "^8.9.0",
3738
"@rjsf/core": "6.0.0-beta.10",
3839
"@rjsf/mui": "6.0.0-beta.10",
3940
"@rjsf/utils": "6.0.0-beta.10",
@@ -47,6 +48,7 @@
4748
"crypto-js": "^4.2.0",
4849
"globals": "^15.15.0",
4950
"json-schema": "^0.4.0",
51+
"luxon": "^3.7.1",
5052
"mui-mdx-components": "^0.5.0",
5153
"notistack": "^3.0.2",
5254
"react": "^19.1.0",
@@ -71,6 +73,7 @@
7173
"@types/crypto-js": "^4.2.2",
7274
"@types/json-schema": "^7.0.15",
7375
"@types/kakaomaps": "^1.1.5",
76+
"@types/luxon": "^3.6.2",
7477
"@types/mdx": "^2.0.13",
7578
"@types/node": "^22.15.18",
7679
"@types/react": "^19.1.4",

0 commit comments

Comments
 (0)