Skip to content

Commit cf8c65c

Browse files
committed
feat: 어드민의 수정 심사 중간 구현 반영
1 parent 2e1da22 commit cf8c65c

File tree

14 files changed

+549
-65
lines changed

14 files changed

+549
-65
lines changed

apps/pyconkr-admin/src/components/layouts/global.tsx

Lines changed: 61 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { MiniVariantAppBar, MiniVariantDrawer } from "./sidebar";
2525

2626
export type RouteDef =
2727
| {
28-
type: "routeDefinition";
28+
type: "autoAdminRouteDefinition";
2929
key: string; // Unique key for the route
3030
icon: typeof SvgIcon;
3131
title: string;
@@ -35,6 +35,15 @@ export type RouteDef =
3535
hideOnSidebar?: boolean;
3636
placeOnBottom?: boolean;
3737
}
38+
| {
39+
type: "routeDefinition";
40+
key: string; // Unique key for the route
41+
icon: typeof SvgIcon;
42+
title: string;
43+
route: string;
44+
hideOnSidebar?: boolean;
45+
placeOnBottom?: boolean;
46+
}
3847
| {
3948
type: "separator";
4049
key: string; // Unique key for the route
@@ -72,48 +81,57 @@ export const Layout: React.FC<{ routes: RouteDef[] }> = ({ routes }) => {
7281
const [state, dispatch] = React.useState<LayoutState>({ showDrawer: false });
7382
const toggleDrawer = () => dispatch((ps) => ({ ...ps, showDrawer: !ps.showDrawer }));
7483

75-
const SidebarItem: React.FC<{ routeInfo: RouteDef }> = ({ routeInfo }) =>
76-
routeInfo.type === "separator" ? (
77-
<ListItem key={routeInfo.title} disablePadding sx={{ minHeight: 48 }}>
78-
{state.showDrawer ? (
79-
<ListItemButton disabled>
80-
<ListItemText primary={routeInfo.title} />
81-
</ListItemButton>
82-
) : (
83-
<Stack
84-
alignItems="center"
85-
sx={(t) => ({
86-
width: t.spacing(7),
87-
[t.breakpoints.up("sm")]: { width: t.spacing(8) },
88-
})}
89-
>
90-
<Chip label={routeInfo.title} variant="outlined" size="small" sx={{ flexGrow: 0 }} />
91-
</Stack>
92-
)}
93-
</ListItem>
94-
) : (
95-
<ListItem key={`${routeInfo.app}-${routeInfo.resource}`} sx={routeInfo.placeOnBottom ? { marginTop: "auto" } : {}} disablePadding>
96-
<ListItemButton
97-
sx={{
98-
minHeight: 48,
99-
px: 2.5,
100-
justifyContent: state.showDrawer ? "initial" : "center",
101-
}}
102-
onClick={() => navigate(routeInfo.route || `/${routeInfo.app}/${routeInfo.resource}`)}
103-
>
104-
<ListItemIcon
105-
sx={{
106-
minWidth: 0,
107-
justifyContent: "center",
108-
mr: state.showDrawer ? 3 : "auto",
109-
}}
110-
>
111-
<routeInfo.icon />
112-
</ListItemIcon>
113-
{state.showDrawer && <ListItemText primary={routeInfo.title} />}
114-
</ListItemButton>
115-
</ListItem>
116-
);
84+
const SidebarItem: React.FC<{ routeInfo: RouteDef }> = ({ routeInfo }) => {
85+
switch (routeInfo.type) {
86+
case "separator":
87+
return (
88+
<ListItem key={routeInfo.key} disablePadding sx={{ minHeight: 48 }}>
89+
{state.showDrawer ? (
90+
<ListItemButton disabled>
91+
<ListItemText primary={routeInfo.title} />
92+
</ListItemButton>
93+
) : (
94+
<Stack
95+
alignItems="center"
96+
sx={(t) => ({
97+
width: t.spacing(7),
98+
[t.breakpoints.up("sm")]: { width: t.spacing(8) },
99+
})}
100+
>
101+
<Chip label={routeInfo.title} variant="outlined" size="small" sx={{ flexGrow: 0 }} />
102+
</Stack>
103+
)}
104+
</ListItem>
105+
);
106+
case "routeDefinition":
107+
case "autoAdminRouteDefinition":
108+
return (
109+
<ListItem key={routeInfo.key} sx={routeInfo.placeOnBottom ? { marginTop: "auto" } : {}} disablePadding>
110+
<ListItemButton
111+
sx={{
112+
minHeight: 48,
113+
px: 2.5,
114+
justifyContent: state.showDrawer ? "initial" : "center",
115+
}}
116+
onClick={() => navigate(routeInfo.type === "autoAdminRouteDefinition" ? `/${routeInfo.app}/${routeInfo.resource}` : routeInfo.route)}
117+
>
118+
<ListItemIcon
119+
sx={{
120+
minWidth: 0,
121+
justifyContent: "center",
122+
mr: state.showDrawer ? 3 : "auto",
123+
}}
124+
>
125+
<routeInfo.icon />
126+
</ListItemIcon>
127+
{state.showDrawer && <ListItemText primary={routeInfo.title} />}
128+
</ListItemButton>
129+
</ListItem>
130+
);
131+
default:
132+
return null;
133+
}
134+
};
117135

118136
const menuButtonStyle: (t: Theme) => React.CSSProperties = (t) => ({
119137
width: `calc(${t.spacing(7)} + 1px)`,
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import * as Common from "@frontend/common";
2+
import {
3+
Accordion,
4+
AccordionDetails,
5+
AccordionSummary,
6+
Box,
7+
Stack,
8+
styled,
9+
Table,
10+
TableBody,
11+
TableCell,
12+
TableHead,
13+
TableRow,
14+
TextField,
15+
TextFieldProps,
16+
Typography,
17+
} from "@mui/material";
18+
import * as React from "react";
19+
20+
type SharedPreviewFieldProps = {
21+
originalDataset: Record<string, unknown>;
22+
previewDataset: Record<string, unknown>;
23+
name: string;
24+
label: string;
25+
};
26+
27+
type PreviewFieldProps = Omit<TextFieldProps, "value" | "name" | "label"> & SharedPreviewFieldProps;
28+
29+
export const PreviewTextField: React.FC<PreviewFieldProps> = ({ originalDataset, previewDataset, name, ...props }) => {
30+
const textFieldSx: TextFieldProps["sx"] = {
31+
"& .MuiInputBase-input, & .Mui-disabled": {
32+
color: "black",
33+
WebkitTextFillColor: "black",
34+
"-webkit-text-fill-color": "black",
35+
},
36+
};
37+
const textFieldProps: TextFieldProps = {
38+
fullWidth: true,
39+
disabled: true,
40+
variant: "outlined",
41+
value: previewDataset[name] || "(값 없음)",
42+
sx: textFieldSx,
43+
...props,
44+
};
45+
const modifiedTextFieldProps: TextFieldProps = { ...textFieldProps, sx: { ...textFieldSx, backgroundColor: "rgba(255, 255, 0, 0.1)" } };
46+
const originalTextFieldProps: TextFieldProps = { ...textFieldProps, sx: { ...textFieldSx, backgroundColor: "rgba(0, 64, 64, 0.1)" } };
47+
const isModified = originalDataset[name] !== previewDataset[name];
48+
49+
return originalDataset[name] === previewDataset[name] ? (
50+
<TextField {...textFieldProps} sx={{ ...textFieldSx, my: 1 }} />
51+
) : (
52+
<Box sx={{ my: 1 }}>
53+
<Accordion>
54+
<AccordionSummary>
55+
<Stack sx={{ width: "100%" }} direction="column" alignItems="flex-start" justifyContent="space-between">
56+
<TextField {...(isModified ? modifiedTextFieldProps : textFieldProps)} />
57+
<Typography variant="caption">기존 값을 보려면 여기를 클릭해주세요.</Typography>
58+
</Stack>
59+
</AccordionSummary>
60+
<AccordionDetails>
61+
<TextField {...originalTextFieldProps} value={originalDataset[name] || "(값 없음)"} />
62+
</AccordionDetails>
63+
</Accordion>
64+
</Box>
65+
);
66+
};
67+
68+
export const PreviewMarkdownField: React.FC<SharedPreviewFieldProps> = ({ originalDataset, previewDataset, name, label }) => {
69+
return originalDataset[name] === previewDataset[name] ? (
70+
<Common.Components.Fieldset legend={label} style={{ width: "100%" }}>
71+
<Box sx={{ width: "100%", color: "black", "& .markdown-body": { width: "100%" } }}>
72+
<Common.Components.MDXRenderer format="md" text={(previewDataset[name] as string) || "(값 없음)"} />
73+
</Box>
74+
</Common.Components.Fieldset>
75+
) : (
76+
<Box sx={{ my: 1 }}>
77+
<Accordion>
78+
<AccordionSummary>
79+
<Stack sx={{ width: "100%" }} direction="column" alignItems="flex-start" justifyContent="space-between">
80+
<Common.Components.Fieldset legend={label} style={{ width: "100%", backgroundColor: "rgba(255, 255, 0, 0.1)" }}>
81+
<Box sx={{ width: "100%", color: "black", "& .markdown-body": { width: "100%" } }}>
82+
<Common.Components.MDXRenderer format="md" text={(previewDataset[name] as string) || "(값 없음)"} />
83+
</Box>
84+
</Common.Components.Fieldset>
85+
<Typography variant="caption">기존 값을 보려면 여기를 클릭해주세요.</Typography>
86+
</Stack>
87+
</AccordionSummary>
88+
<AccordionDetails>
89+
<Common.Components.Fieldset legend={label} style={{ backgroundColor: "rgba(0, 64, 64, 0.1)" }}>
90+
<Box sx={{ flexGrow: 1, color: "black", "& .markdown-body": { width: "100%" } }}>
91+
<Common.Components.MDXRenderer format="md" text={(originalDataset[name] as string) || "(값 없음)"} />
92+
</Box>
93+
</Common.Components.Fieldset>
94+
</AccordionDetails>
95+
</Accordion>
96+
</Box>
97+
);
98+
};
99+
100+
const ImageFallback: React.FC = () => (
101+
<Stack sx={{ width: "100%", height: "100%", alignItems: "center", justifyContent: "center" }}>
102+
<Typography variant="caption" color="textSecondary" children="이미지를 불러오는 중 문제가 발생했습니다." />
103+
</Stack>
104+
);
105+
106+
const WidthSpecifiedFallbackImage = styled(Common.Components.FallbackImage)({
107+
maxWidth: "20rem",
108+
objectFit: "cover",
109+
});
110+
111+
export const PreviewImageField: React.FC<SharedPreviewFieldProps> = ({ originalDataset, previewDataset, name, label }) => {
112+
const originalImage = originalDataset[name] as string;
113+
const previewImage = previewDataset[name] as string;
114+
115+
return originalImage === previewImage ? (
116+
<Common.Components.Fieldset legend={label} style={{ width: "100%" }}>
117+
<WidthSpecifiedFallbackImage src={previewImage} alt={label} errorFallback={<ImageFallback />} />
118+
</Common.Components.Fieldset>
119+
) : (
120+
<Box sx={{ my: 1 }}>
121+
<Accordion>
122+
<AccordionSummary>
123+
<Stack sx={{ width: "100%" }} direction="column" alignItems="flex-start" justifyContent="space-between">
124+
<Common.Components.Fieldset legend={label} style={{ width: "100%", backgroundColor: "rgba(255, 255, 0, 0.1)" }}>
125+
<WidthSpecifiedFallbackImage src={previewImage} alt={label} errorFallback={<ImageFallback />} />
126+
</Common.Components.Fieldset>
127+
<Typography variant="caption">기존 이미지를 보려면 여기를 클릭해주세요.</Typography>
128+
</Stack>
129+
</AccordionSummary>
130+
<AccordionDetails>
131+
<Common.Components.Fieldset legend={label} style={{ backgroundColor: "rgba(0, 64, 64, 0.1)" }}>
132+
<WidthSpecifiedFallbackImage src={originalImage} alt={label} errorFallback={<ImageFallback />} />
133+
</Common.Components.Fieldset>
134+
</AccordionDetails>
135+
</Accordion>
136+
</Box>
137+
);
138+
};
139+
140+
type SimplifiedModificationAudit = {
141+
id: string;
142+
created_at: string;
143+
created_by: string;
144+
status: string;
145+
};
146+
147+
export const ModificationAuditProperties: React.FC<{ audit: SimplifiedModificationAudit }> = ({ audit }) => (
148+
<Table>
149+
<TableHead>
150+
<TableRow>
151+
<TableCell>속성</TableCell>
152+
<TableCell></TableCell>
153+
</TableRow>
154+
</TableHead>
155+
<TableBody>
156+
<TableRow>
157+
<TableCell>심사 ID</TableCell>
158+
<TableCell>{audit.id}</TableCell>
159+
</TableRow>
160+
<TableRow>
161+
<TableCell>심사 요청 시간</TableCell>
162+
<TableCell>{new Date(audit.created_at).toLocaleString()}</TableCell>
163+
</TableRow>
164+
<TableRow>
165+
<TableCell>심사 요청자</TableCell>
166+
<TableCell>{audit.created_by}</TableCell>
167+
</TableRow>
168+
<TableRow>
169+
<TableCell>심사 상태</TableCell>
170+
<TableCell>{audit.status}</TableCell>
171+
</TableRow>
172+
</TableBody>
173+
</Table>
174+
);
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as Common from "@frontend/common";
2+
import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Typography } from "@mui/material";
3+
import { enqueueSnackbar, OptionsObject } from "notistack";
4+
import * as React from "react";
5+
6+
type SubmitConfirmDialogProps = {
7+
open: boolean;
8+
onClose: () => void;
9+
modificationAuditId: string;
10+
};
11+
12+
export const ApproveSubmitConfirmDialog: React.FC<SubmitConfirmDialogProps> = ({ open, onClose, modificationAuditId }) => {
13+
const backendAdminClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
14+
const approveModificationAuditMutation = Common.Hooks.BackendAdminAPI.useApproveModificationAuditMutation(backendAdminClient, modificationAuditId);
15+
16+
const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
17+
enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
18+
19+
const onApproveClick = () => {
20+
approveModificationAuditMutation.mutate(undefined, {
21+
onSuccess: () => {
22+
addSnackbar("수정 심사가 승인되었습니다.", "success");
23+
onClose();
24+
},
25+
onError: (error) => {
26+
console.error("Approve modification audit failed:", error);
27+
let errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
28+
if (error instanceof Common.BackendAPIs.BackendAPIClientError) errorMessage = error.message;
29+
addSnackbar(errorMessage, "error");
30+
},
31+
});
32+
};
33+
34+
return (
35+
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
36+
<DialogTitle>수정 심사 승인 확인</DialogTitle>
37+
<DialogContent>
38+
<Typography variant="body1" gutterBottom>
39+
승인하는 경우 바로 내용이 반영되어 홈페이지에 노출되게 됩니다.
40+
<br />
41+
승인 후에는 수정 심사를 반려할 수 없으니, 내용을 한번 더 확인해 주세요.
42+
</Typography>
43+
</DialogContent>
44+
<DialogActions>
45+
<Button onClick={onClose} color="error" children="취소" />
46+
<Button onClick={onApproveClick} color="primary" variant="contained" children="승인" />
47+
</DialogActions>
48+
</Dialog>
49+
);
50+
};
51+
52+
export const RejectSubmitConfirmDialog: React.FC<SubmitConfirmDialogProps> = ({ open, onClose, modificationAuditId }) => {
53+
const backendAdminClient = Common.Hooks.BackendAdminAPI.useBackendAdminClient();
54+
const rejectModificationAuditMutation = Common.Hooks.BackendAdminAPI.useRejectModificationAuditMutation(backendAdminClient, modificationAuditId);
55+
56+
const addSnackbar = (c: string | React.ReactNode, variant: OptionsObject["variant"]) =>
57+
enqueueSnackbar(c, { variant, anchorOrigin: { vertical: "bottom", horizontal: "center" } });
58+
59+
const onRejectClick = () => {
60+
rejectModificationAuditMutation.mutate(undefined, {
61+
onSuccess: () => {
62+
addSnackbar("수정 심사가 반려되었습니다.", "success");
63+
onClose();
64+
},
65+
onError: (error) => {
66+
console.error("Reject modification audit failed:", error);
67+
let errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
68+
if (error instanceof Common.BackendAPIs.BackendAPIClientError) errorMessage = error.message;
69+
addSnackbar(errorMessage, "error");
70+
},
71+
});
72+
};
73+
74+
return (
75+
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
76+
<DialogTitle>수정 심사 반려 확인</DialogTitle>
77+
<DialogContent>
78+
<Typography variant="body1" gutterBottom>
79+
수정 심사를 반려하시겠습니까?
80+
<br />
81+
반려 후에는 다시 승인할 수 없습니다!
82+
</Typography>
83+
</DialogContent>
84+
<DialogActions>
85+
<Button onClick={onClose} color="error" children="취소" />
86+
<Button onClick={onRejectClick} color="primary" variant="contained" children="반려" />
87+
</DialogActions>
88+
</Dialog>
89+
);
90+
};

0 commit comments

Comments
 (0)