Skip to content

Commit dd4bdfe

Browse files
committed
feat: 이전 수정 요청 조회 페이지 추가
1 parent 80f34ea commit dd4bdfe

File tree

10 files changed

+213
-35
lines changed

10 files changed

+213
-35
lines changed

apps/pyconkr-participant-portal/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Navigate, Route, Routes } from "react-router-dom";
33

44
import { Layout } from "./components/layout.tsx";
55
import { LandingPage } from "./components/pages/home.tsx";
6+
import { ModificationAuditPreview } from "./components/pages/modification_audit_preview.tsx";
67
import { ProfileEditor } from "./components/pages/profile_editor.tsx";
78
import { SessionEditor } from "./components/pages/session_editor";
89
import { SignInPage } from "./components/pages/signin.tsx";
@@ -16,6 +17,7 @@ export const App: React.FC = () => (
1617
<Route path="/user" element={<ProfileEditor />} />
1718
<Route path="/sponsor/:id" element={<SponsorEditor />} />
1819
<Route path="/session/:sessionId" element={<SessionEditor />} />
20+
<Route path="/modification-audit/:auditId" element={<ModificationAuditPreview />} />
1921
<Route path="*" element={<Navigate to="/" replace />} />
2022
</Route>
2123
</Routes>

apps/pyconkr-participant-portal/src/components/dialogs/submit_confirm.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ type SubmitConfirmDialogProps = {
77
open: boolean;
88
onClose: () => void;
99
onSubmit: () => void;
10+
disabled?: boolean;
1011
};
1112

12-
export const SubmitConfirmDialog: React.FC<SubmitConfirmDialogProps> = ({ open, onClose, onSubmit }) => {
13+
export const SubmitConfirmDialog: React.FC<SubmitConfirmDialogProps> = ({ open, onClose, onSubmit, disabled }) => {
1314
const { language } = useAppContext();
1415

1516
const titleStr = language === "ko" ? "제출 확인" : "Confirm Submission";
@@ -39,8 +40,8 @@ export const SubmitConfirmDialog: React.FC<SubmitConfirmDialogProps> = ({ open,
3940
<DialogTitle children={titleStr} />
4041
<DialogContent children={content} />
4142
<DialogActions>
42-
<Button onClick={onClose} color="error" children={cancelStr} />
43-
<Button onClick={onSubmit} color="primary" variant="contained" children={submitStr} />
43+
<Button disabled={disabled} onClick={onClose} color="error" children={cancelStr} />
44+
<Button disabled={disabled} onClick={onSubmit} color="primary" variant="contained" children={submitStr} />
4445
</DialogActions>
4546
</Dialog>
4647
);

apps/pyconkr-participant-portal/src/components/elements/public_file_selector.tsx

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const ScaledFallbackImage = styled(Common.Components.FallbackImage)({
2727

2828
export const PublicFileSelector: React.FC<PublicFileSelectorProps> = ErrorBoundary.with(
2929
{ fallback: Common.Components.ErrorFallback },
30-
Suspense.with({ fallback: <CircularProgress /> }, ({ value, onChange, ...props }) => {
30+
Suspense.with({ fallback: <CircularProgress /> }, ({ value, onChange, disabled, ...props }) => {
3131
const selectInputRef = React.useRef<HTMLSelectElement | null>(null);
3232
const [selectorState, setSelectorState] = React.useState<PublicFileSelectorState>({ value });
3333
const { language } = useAppContext();
@@ -68,22 +68,31 @@ export const PublicFileSelector: React.FC<PublicFileSelectorProps> = ErrorBounda
6868
<Stack direction={isMobile ? "column" : "row"} spacing={2} sx={{ width: "100%" }} alignItems="center">
6969
<FormControl fullWidth>
7070
<InputLabel id="public-file-label">{props.label}</InputLabel>
71-
<Select labelId="public-file-label" ref={selectInputRef} value={selectorState.value || ""} onChange={setSelectedFile} {...props}>
71+
<Select
72+
labelId="public-file-label"
73+
ref={selectInputRef}
74+
value={selectorState.value || ""}
75+
disabled={disabled}
76+
onChange={setSelectedFile}
77+
{...props}
78+
>
7279
{files.map((file) => (
7380
<MenuItem key={file.id} value={file.id} children={file.name} />
7481
))}
7582
</Select>
7683
</FormControl>
77-
<Button
78-
variant="contained"
79-
size="small"
80-
disabled={props.disabled}
81-
onClick={openUploadDialog}
82-
startIcon={<PermMedia />}
83-
fullWidth={isMobile}
84-
children={uploadStr}
85-
sx={{ wordBreak: "keep-all" }}
86-
/>
84+
{!disabled && (
85+
<Button
86+
variant="contained"
87+
size="small"
88+
disabled={disabled}
89+
onClick={openUploadDialog}
90+
startIcon={<PermMedia />}
91+
fullWidth={isMobile}
92+
children={uploadStr}
93+
sx={{ wordBreak: "keep-all" }}
94+
/>
95+
)}
8796
</Stack>
8897
</Stack>
8998
</Fieldset>

apps/pyconkr-participant-portal/src/components/pages/home.tsx

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -162,14 +162,17 @@ const InnerLandingPage: React.FC = () => {
162162
)}
163163
<List>
164164
{filteredAudits.length > 0 ? (
165-
filteredAudits.map((audit) => (
166-
<ListItem key={audit.id} disablePadding sx={{ cursor: "pointer", border: "1px solid #ccc" }}>
167-
<ListItemButton
168-
children={<ListItemText primary={audit.str_repr} secondary={TranslatedAuditState[audit.status][language]} />}
169-
onClick={() => navigate(`/session/${audit.instance_id}/`)}
170-
/>
171-
</ListItem>
172-
))
165+
filteredAudits.map((audit) => {
166+
const navigateTo = audit.status === "requested" ? `/session/${audit.instance_id}` : `/modification-audit/${audit.id}`;
167+
return (
168+
<ListItem key={audit.id} disablePadding sx={{ cursor: "pointer", border: "1px solid #ccc" }}>
169+
<ListItemButton
170+
children={<ListItemText primary={audit.str_repr} secondary={TranslatedAuditState[audit.status][language]} />}
171+
onClick={() => navigate(navigateTo)}
172+
/>
173+
</ListItem>
174+
);
175+
})
173176
) : (
174177
<ListItem disablePadding sx={{ cursor: "pointer", border: "1px solid #ccc" }}>
175178
<ListItemButton children={<ListItemText primary={state.showAllAudits ? auditEmptyStr : ongoingAuditEmptyStr} />} />
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import * as Common from "@frontend/common";
2+
import { Card, CardContent, Palette, PaletteColor, styled, Typography } from "@mui/material";
3+
import { ErrorBoundary, Suspense } from "@suspensive/react";
4+
import * as React from "react";
5+
import { Navigate, useParams } from "react-router-dom";
6+
import * as R from "remeda";
7+
8+
import { useAppContext } from "../../contexts/app_context";
9+
import { ErrorPage } from "../elements/error_page";
10+
import { LoadingPage } from "../elements/loading_page";
11+
import { SignInGuard } from "../elements/signin_guard";
12+
import { Page } from "../page";
13+
import { ProfileEditorForm, ProfileSchema } from "./profile_editor";
14+
import { SessionEditorForm, SessionSchema } from "./session_editor";
15+
16+
const StyledAlertCard = styled(Card)(({ theme }) => ({
17+
width: "100%",
18+
marginBottom: theme.spacing(2),
19+
".MuiCardContent-root": {
20+
"&:last-child": {
21+
paddingBottom: "initial",
22+
padding: theme.spacing(2),
23+
},
24+
},
25+
}));
26+
27+
const StyledNoticeHeader: React.FC<{ pName: keyof Palette; text: React.ReactNode }> = ({ pName, text }) => (
28+
<StyledAlertCard sx={(t) => ({ backgroundColor: (t.palette[pName] as PaletteColor).main, color: (t.palette[pName] as PaletteColor).contrastText })}>
29+
<CardContent>
30+
<Typography variant="body1" sx={{ fontWeight: 700 }} children={text} />
31+
</CardContent>
32+
</StyledAlertCard>
33+
);
34+
35+
type ModificationAuditStatus = "requested" | "approved" | "rejected" | "cancelled";
36+
37+
type AuditNoticeHeaderProps = {
38+
language: "ko" | "en";
39+
audit: {
40+
status: ModificationAuditStatus;
41+
comments: {
42+
content: string;
43+
created_by: { is_superuser: boolean };
44+
}[];
45+
};
46+
};
47+
48+
const ApprovedAuditNoticeHeader: React.FC<AuditNoticeHeaderProps> = ({ language }) => {
49+
const text = language === "ko" ? "승인되어 반영된 수정 요청입니다." : "This is a modification request that has been approved and applied.";
50+
return <StyledNoticeHeader pName="success" text={text} />;
51+
};
52+
53+
const RequestedAuditNoticeHeader: React.FC<AuditNoticeHeaderProps> = ({ language }) => {
54+
const text =
55+
language === "ko"
56+
? "현재 파이콘 준비 위원회가 수정 요청을 확인 중입니다."
57+
: "The PyCon Korea Organizing Committee is currently reviewing your modification request.";
58+
return <StyledNoticeHeader pName="info" text={text} />;
59+
};
60+
61+
const RejectedAuditNoticeHeader: React.FC<AuditNoticeHeaderProps> = ({ language, audit }) => {
62+
const auditRejectedText = language === "ko" ? "수정 요청이 반려되었습니다. " : "Your modification request has been rejected. ";
63+
const rejectReasonIsText = language === "ko" ? "반려 사유는 다음과 같습니다: " : "The reason for rejection is as follows: ";
64+
65+
let body: React.ReactNode = auditRejectedText;
66+
if (R.isArray(audit.comments) && !R.isEmpty(audit.comments.filter((c) => c.created_by.is_superuser))) {
67+
// 운영자가 쓴 가장 첫번째 코멘트를 반려 사유로 사용
68+
const rejectedReason = audit.comments.filter((c) => c.created_by.is_superuser)[0].content;
69+
body = (
70+
<>
71+
{auditRejectedText + rejectReasonIsText}
72+
<br />
73+
{rejectedReason}
74+
</>
75+
);
76+
}
77+
return <StyledNoticeHeader pName="error" text={body} />;
78+
};
79+
80+
const CancelledAuditNoticeHeader: React.FC<AuditNoticeHeaderProps> = ({ language }) => {
81+
const text = language === "ko" ? "취소한 수정 요청입니다." : "This is a modification request that you have cancelled.";
82+
return <StyledNoticeHeader pName="warning" text={text} />;
83+
};
84+
85+
const AuditNoticeHeader: React.FC<AuditNoticeHeaderProps> = ({ language, audit }) => {
86+
switch (audit.status) {
87+
case "approved":
88+
return <ApprovedAuditNoticeHeader language={language} audit={audit} />;
89+
case "requested":
90+
return <RequestedAuditNoticeHeader language={language} audit={audit} />;
91+
case "rejected":
92+
return <RejectedAuditNoticeHeader language={language} audit={audit} />;
93+
case "cancelled":
94+
return <CancelledAuditNoticeHeader language={language} audit={audit} />;
95+
default:
96+
return <StyledAlertCard children={<CardContent children="알 수 없는 상태입니다." />} />;
97+
}
98+
};
99+
100+
export const InnerModificationAuditPreview: React.FC = () => {
101+
const { language } = useAppContext();
102+
const { auditId } = useParams<{ auditId?: string }>();
103+
const participantPortalClient = Common.Hooks.BackendParticipantPortalAPI.useParticipantPortalClient();
104+
const { data: auditData } = Common.Hooks.BackendParticipantPortalAPI.useModificationAuditPreviewQuery(participantPortalClient, auditId ?? "");
105+
106+
if (!auditData) return <Navigate to="/" replace />;
107+
108+
return (
109+
<Page>
110+
<AuditNoticeHeader language={language} audit={auditData.modification_audit} />
111+
{auditData.modification_audit.instance_type === "presentation" && (
112+
<SessionEditorForm disabled language={language} defaultValue={auditData.modified as SessionSchema} />
113+
)}
114+
{auditData.modification_audit.instance_type === "userext" && (
115+
<ProfileEditorForm disabled language={language} defaultValue={auditData.modified as ProfileSchema} />
116+
)}
117+
</Page>
118+
);
119+
};
120+
121+
export const ModificationAuditPreview: React.FC = ErrorBoundary.with(
122+
{ fallback: ErrorPage },
123+
Suspense.with({ fallback: <LoadingPage /> }, () => <SignInGuard children={<InnerModificationAuditPreview />} />)
124+
);

apps/pyconkr-participant-portal/src/components/pages/profile_editor.tsx

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,19 @@ import { SignInGuard } from "../elements/signin_guard";
1717
import { PrimaryTitle } from "../elements/titles";
1818
import { Page } from "../page";
1919

20-
type ProfileType = {
20+
export type ProfileSchema = {
2121
email: string;
2222
nickname_ko: string | null;
2323
nickname_en: string | null;
2424
image: string | null;
2525
};
2626

27-
type ProfileEditorState = ProfileType & {
27+
type ProfileEditorState = ProfileSchema & {
2828
openChangePasswordDialog: boolean;
2929
openSubmitConfirmDialog: boolean;
3030
};
3131

32-
const DummyProfile: ProfileType = {
32+
const DummyProfile: ProfileSchema = {
3333
email: "",
3434
nickname_ko: "",
3535
nickname_en: "",
@@ -100,7 +100,12 @@ const InnerProfileEditor: React.FC = () => {
100100
return (
101101
<>
102102
<ChangePasswordDialog open={editorState.openChangePasswordDialog} onClose={closeChangePasswordDialog} />
103-
<SubmitConfirmDialog open={editorState.openSubmitConfirmDialog} onClose={closeSubmitConfirmDialog} onSubmit={updateMe} />
103+
<SubmitConfirmDialog
104+
open={editorState.openSubmitConfirmDialog}
105+
onClose={closeSubmitConfirmDialog}
106+
onSubmit={updateMe}
107+
disabled={formDisabled}
108+
/>
104109
<Page>
105110
{profile?.has_requested_modification_audit && <CurrentlyModAuditInProgress language={language} modificationAuditId={modificationAuditId} />}
106111
<PrimaryTitle variant="h4" children={titleStr} />
@@ -154,14 +159,14 @@ export const ProfileEditor: React.FC = ErrorBoundary.with(
154159
type ProfileEditorFormProps = {
155160
disabled?: boolean;
156161
showSubmitButton?: boolean;
157-
onSubmit?: (profile: ProfileType) => void;
162+
onSubmit?: (profile: ProfileSchema) => void;
158163
language: "ko" | "en";
159-
defaultValue: ProfileType;
164+
defaultValue: ProfileSchema;
160165
};
161166

162167
// TODO: FIXME: 언젠간 위의 ProfileEditor를 아래 ProfileEditorForm에 마이그레이션해야 함
163168
export const ProfileEditorForm: React.FC<ProfileEditorFormProps> = ({ disabled, language, defaultValue, showSubmitButton, onSubmit }) => {
164-
const [formState, setFormState] = React.useState<ProfileType>(defaultValue);
169+
const [formState, setFormState] = React.useState<ProfileSchema>(defaultValue);
165170
const setImageId = (image: string | null) => setFormState((ps) => ({ ...ps, image }));
166171
const onImageSelectChange = (e: SelectChangeEvent<string | null>) => setImageId(e.target.value);
167172
const setNickname = (value: string | undefined, lang: "ko" | "en") => setFormState((ps) => ({ ...ps, [`nickname_${lang}`]: value }));
@@ -176,7 +181,7 @@ export const ProfileEditorForm: React.FC<ProfileEditorFormProps> = ({ disabled,
176181
<>
177182
<PrimaryTitle variant="h4" children={titleStr} />
178183
<Stack spacing={2} sx={{ width: "100%", flexGrow: 1 }}>
179-
<PublicFileSelector label={speakerImageStr} value={formState.image} onChange={onImageSelectChange} />
184+
<PublicFileSelector label={speakerImageStr} value={formState.image} disabled={disabled} onChange={onImageSelectChange} />
180185
<MultiLanguageField
181186
label={{ ko: "닉네임", en: "Nickname" }}
182187
value={{

apps/pyconkr-participant-portal/src/components/pages/session_editor.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type SessionUpdateSchema = {
3535
}[];
3636
};
3737

38-
type SessionSchema = SessionUpdateSchema & {
38+
export type SessionSchema = SessionUpdateSchema & {
3939
speakers: (SessionUpdateSchema["speakers"][number] & {
4040
user: {
4141
id: number; // UUID of the user
@@ -238,7 +238,12 @@ const InnerSessionEditor: React.FC = () => {
238238

239239
return (
240240
<>
241-
<SubmitConfirmDialog open={editorState.openSubmitConfirmDialog} onClose={closeSubmitConfirmDialog} onSubmit={updateSession} />
241+
<SubmitConfirmDialog
242+
open={editorState.openSubmitConfirmDialog}
243+
onClose={closeSubmitConfirmDialog}
244+
onSubmit={updateSession}
245+
disabled={formDisabled}
246+
/>
242247
<Page>
243248
{session.has_requested_modification_audit && <CurrentlyModAuditInProgress language={language} modificationAuditId={modificationAuditId} />}
244249
<SessionEditorForm disabled={formDisabled} language={language} defaultValue={session} onSubmit={openSubmitConfirmDialog} showSubmitButton />

packages/common/src/apis/participant_portal_api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,15 @@ namespace BackendParticipantPortalAPIs {
5858
export const listModificationAudits = (client: BackendAPIClient) => () =>
5959
client.get<ParticipantPortalAPISchemas.ModificationAuditSchema[]>("v1/participant-portal/modification-audit/");
6060

61+
export const previewModificationAudit = (client: BackendAPIClient, id: string) => () => {
62+
try {
63+
return client.get<ParticipantPortalAPISchemas.ModificationAuditPreviewSchema>(`v1/participant-portal/modification-audit/${id}/preview/`);
64+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
65+
} catch (_) {
66+
return Promise.resolve(null);
67+
}
68+
};
69+
6170
export const retrieveModificationAudit = (client: BackendAPIClient, id: string) => () =>
6271
client.get<ParticipantPortalAPISchemas.ModificationAuditSchema[]>(`v1/participant-portal/modification-audit/${id}`);
6372

packages/common/src/hooks/useParticipantPortalAPI.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ namespace BackendParticipantPortalAPIHooks {
107107
queryFn: ParticipantPortalAPI.listModificationAudits(client),
108108
});
109109

110+
export const useModificationAuditPreviewQuery = (client: BackendAPIClient, id: string) =>
111+
useSuspenseQuery({
112+
queryKey: [...QUERY_KEYS.PARTICIPANT_LIST_MODIFICATION_AUDIT, "preview", id, client.language],
113+
queryFn: ParticipantPortalAPI.previewModificationAudit(client, id),
114+
});
115+
110116
export const useRetrieveModificationAuditQuery = (client: BackendAPIClient, id: string) =>
111117
useSuspenseQuery({
112118
queryKey: [...QUERY_KEYS.PARTICIPANT_RETRIEVE_MODIFICATION_AUDIT, id, client.language],

packages/common/src/schemas/backendParticipantPortalAPI.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,14 +95,14 @@ namespace BackendParticipantPortalAPISchemas {
9595
}[];
9696
};
9797

98-
export type ModificationAuditSchema = {
98+
export type ModificationAuditSchema<T = string> = {
9999
id: string; // UUID
100100
str_repr: string; // String representation of the modification audit, e.g., "Presentation Title - Status"
101101
status: "requested" | "approved" | "rejected" | "cancelled"; // Status of the modification request
102102
created_at: string; // ISO 8601 timestamp
103103
updated_at: string; // ISO 8601 timestamp
104104

105-
instance_type: string; // Type of the instance being modified (e.g., "presentation")
105+
instance_type: T; // Type of the instance being modified (e.g., "presentation")
106106
instance_id: string; // UUID of the instance being modified (e.g., presentation ID)
107107
modification_data: string; // JSON string containing the modification data
108108

@@ -119,6 +119,20 @@ namespace BackendParticipantPortalAPISchemas {
119119
}[];
120120
};
121121

122+
type ModificationAuditPresentationPreviewSchema = {
123+
modification_audit: ModificationAuditSchema<"presentation">;
124+
original: PresentationRetrieveSchema;
125+
modified: PresentationRetrieveSchema;
126+
};
127+
128+
type ModificationAuditUserPreviewSchema = {
129+
modification_audit: ModificationAuditSchema<"userext">;
130+
original: UserSchema;
131+
modified: UserSchema;
132+
};
133+
134+
export type ModificationAuditPreviewSchema = ModificationAuditPresentationPreviewSchema | ModificationAuditUserPreviewSchema;
135+
122136
export type ModificationAuditCancelRequestSchema = {
123137
id: string; // UUID of the modification audit
124138
reason: string | null; // Reason for cancelling the modification request

0 commit comments

Comments
 (0)