Skip to content
This repository was archived by the owner on Jan 2, 2026. It is now read-only.

Commit 235c546

Browse files
authored
✨ 학생회 회의록 페이지 구현 (#320)
1 parent a233df9 commit 235c546

File tree

17 files changed

+427
-23
lines changed

17 files changed

+427
-23
lines changed

actions/council.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,26 @@
33
import { revalidateTag } from 'next/cache';
44

55
import { putCouncilIntro } from '@/apis/v2/council/intro';
6+
import {
7+
deleteCouncilMinute,
8+
postCouncilMinutesByYear,
9+
putCouncilMinute,
10+
} from '@/apis/v2/council/meeting-minute';
611
import { postCouncilReport } from '@/apis/v2/council/report';
7-
import { FETCH_TAG_COUNCIL_INTRO, FETCH_TAG_COUNCIL_REPORT } from '@/constants/network';
8-
import { councilIntro, councilReportList } from '@/constants/segmentNode';
12+
import {
13+
FETCH_TAG_COUNCIL_INTRO,
14+
FETCH_TAG_COUNCIL_MINUTE,
15+
FETCH_TAG_COUNCIL_REPORT,
16+
} from '@/constants/network';
17+
import { councilIntro, councilMinute, councilReportList } from '@/constants/segmentNode';
918
import { redirectKo } from '@/i18n/routing';
1019
import { getPath } from '@/utils/page';
20+
import { decodeFormDataFileName } from '@/utils/string';
1121

1222
import { withErrorHandler } from './errorHandler';
1323

24+
/** 소개 */
25+
1426
const introPath = getPath(councilIntro);
1527

1628
export const putIntroAction = withErrorHandler(async (formData: FormData) => {
@@ -19,6 +31,35 @@ export const putIntroAction = withErrorHandler(async (formData: FormData) => {
1931
redirectKo(introPath);
2032
});
2133

34+
/** 회의록 */
35+
36+
const minutePath = getPath(councilMinute);
37+
38+
export const postMinutesByYearAction = withErrorHandler(
39+
async (year: number, formData: FormData) => {
40+
decodeFormDataFileName(formData, 'attachments');
41+
await postCouncilMinutesByYear(year, formData);
42+
revalidateTag(FETCH_TAG_COUNCIL_MINUTE);
43+
redirectKo(minutePath);
44+
},
45+
);
46+
47+
export const putMinuteAction = withErrorHandler(
48+
async (year: number, index: number, formData: FormData) => {
49+
decodeFormDataFileName(formData, 'newAttachments');
50+
await putCouncilMinute(year, index, formData);
51+
revalidateTag(FETCH_TAG_COUNCIL_MINUTE);
52+
redirectKo(minutePath);
53+
},
54+
);
55+
56+
export const deleteMinuteAction = withErrorHandler(async (year: number, index: number) => {
57+
await deleteCouncilMinute(year, index);
58+
revalidateTag(FETCH_TAG_COUNCIL_MINUTE);
59+
});
60+
61+
/** 활동보고 */
62+
2263
const councilReportPath = getPath(councilReportList);
2364

2465
export const postCouncilReportAction = withErrorHandler(async (formData: FormData) => {

apis/types/council.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
import { Attachment } from './attachment';
2+
3+
export interface CouncilMeetingMinute {
4+
year: number;
5+
index: number;
6+
attachments: Attachment[];
7+
}
8+
19
export interface CouncilReport {
210
id: number;
311
title: string;

apis/v2/council/meeting-minute.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { deleteRequest, postRequest, putRequest } from '@/apis';
2+
import { getRequest } from '@/apis';
3+
import { CouncilMeetingMinute } from '@/apis/types/council';
4+
import { FETCH_TAG_COUNCIL_MINUTE } from '@/constants/network';
5+
6+
interface GETMinutesResponse {
7+
[year: string]: CouncilMeetingMinute[];
8+
}
9+
10+
export const getCouncilMinutes = () =>
11+
getRequest<GETMinutesResponse>(`/v2/council/meeting-minute`, undefined, {
12+
next: { tags: [FETCH_TAG_COUNCIL_MINUTE] },
13+
});
14+
15+
export const getCouncilMinutesByYear = (year: number) =>
16+
getRequest<CouncilMeetingMinute[]>(`/v2/council/meeting-minute/${year}`, undefined, {
17+
next: { tags: [FETCH_TAG_COUNCIL_MINUTE] },
18+
});
19+
20+
export const postCouncilMinutesByYear = (year: number, formData: FormData) =>
21+
postRequest(`/v2/council/meeting-minute/${year}`, { body: formData, jsessionID: true });
22+
23+
export const getCouncilMinute = (year: number, index: number) =>
24+
getRequest<CouncilMeetingMinute>(`/v2/council/meeting-minute/${year}/${index}`, undefined, {
25+
next: { tags: [FETCH_TAG_COUNCIL_MINUTE] },
26+
});
27+
28+
export const putCouncilMinute = (year: number, index: number, formData: FormData) =>
29+
putRequest(`/v2/council/meeting-minute/${year}/${index}`, { body: formData, jsessionID: true });
30+
31+
export const deleteCouncilMinute = (year: number, index: number) =>
32+
deleteRequest(`/v2/council/meeting-minute/${year}/${index}`, { jsessionID: true });
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use client';
2+
3+
import { FormProvider, useForm } from 'react-hook-form';
4+
5+
import Fieldset from '@/components/form/Fieldset';
6+
import Form from '@/components/form/Form';
7+
import { EditorFile } from '@/types/form';
8+
import { handleServerResponse } from '@/utils/serverActionError';
9+
10+
export type MinuteFormData = { year: number; file: EditorFile[] };
11+
12+
interface Props {
13+
defaultValues?: MinuteFormData;
14+
onSubmit: (formData: MinuteFormData) => Promise<void>;
15+
onCancel: () => void;
16+
}
17+
18+
export default function CouncilMeetingMinuteEditor({
19+
defaultValues,
20+
onSubmit: _onSubmit,
21+
onCancel,
22+
}: Props) {
23+
const formMethods = useForm<MinuteFormData>({
24+
defaultValues: defaultValues ?? {
25+
year: new Date().getFullYear() + 1,
26+
file: [],
27+
},
28+
});
29+
const { handleSubmit } = formMethods;
30+
31+
const onSubmit = async (requestObject: MinuteFormData) => {
32+
const resp = await _onSubmit(requestObject);
33+
handleServerResponse(resp, { successMessage: '저장되었습니다.' });
34+
};
35+
36+
return (
37+
<FormProvider {...formMethods}>
38+
<Form>
39+
<Fieldset title="연도" mb="mb-6" titleMb="mb-2">
40+
<Form.Text
41+
name="year"
42+
maxWidth="w-[55px]"
43+
disabled={defaultValues !== undefined}
44+
options={{ required: true, valueAsNumber: true }}
45+
/>
46+
</Fieldset>
47+
<Fieldset.File>
48+
<Form.File name="file" />
49+
</Fieldset.File>
50+
<Form.Action onCancel={onCancel} onSubmit={handleSubmit(onSubmit)} />
51+
</Form>
52+
</FormProvider>
53+
);
54+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
5+
import { deleteMinuteAction } from '@/actions/council';
6+
import { CouncilMeetingMinute } from '@/apis/types/council';
7+
import Timeline from '@/app/[locale]/academics/components/timeline/Timeline';
8+
import { DeleteButton, EditButton } from '@/components/common/Buttons';
9+
import LoginVisible from '@/components/common/LoginVisible';
10+
import PageLayout from '@/components/layout/pageLayout/PageLayout';
11+
import { councilMinute } from '@/constants/segmentNode';
12+
import { Link } from '@/i18n/routing';
13+
import { getPath } from '@/utils/page';
14+
import { handleServerResponse } from '@/utils/serverActionError';
15+
16+
import CouncilAttachment from '../components/CouncilAttachments';
17+
18+
const minutePath = getPath(councilMinute);
19+
const THIS_YEAR = new Date().getFullYear();
20+
21+
export default function MinutePageContent({
22+
contents,
23+
}: {
24+
contents: { [year: string]: CouncilMeetingMinute[] };
25+
}) {
26+
const [selectedYear, setSelectedYear] = useState(THIS_YEAR);
27+
const timeLineYears = Object.keys(contents)
28+
.map(Number)
29+
.sort((a, b) => b - a);
30+
const selectedContents = contents[selectedYear.toString()] ?? [];
31+
32+
return (
33+
<PageLayout titleType="big">
34+
<YearAddButton />
35+
<Timeline
36+
times={timeLineYears}
37+
selectedTime={selectedYear}
38+
setSelectedTime={setSelectedYear}
39+
/>
40+
<div className="mt-7">
41+
{selectedContents.map((minute, i) => {
42+
return (
43+
<Minutes
44+
minute={minute}
45+
key={`${minute.year}_${minute.index}`}
46+
isLast={i === selectedContents.length - 1}
47+
/>
48+
);
49+
})}
50+
</div>
51+
<MinuteAddButton year={selectedYear} />
52+
</PageLayout>
53+
);
54+
}
55+
56+
function MinuteAddButton({ year }: { year: number }) {
57+
return (
58+
<LoginVisible role={['ROLE_COUNCIL', 'ROLE_STAFF']}>
59+
<Link
60+
href={`${minutePath}/create?year=${year}`}
61+
className="mt-3 flex w-[220px] items-center gap-1.5 rounded-sm border border-main-orange px-2 py-2.5 text-main-orange duration-200 hover:bg-main-orange hover:text-white"
62+
>
63+
<span className="material-symbols-outlined font-light">add</span>
64+
<span className="text-base font-medium">회의록 추가</span>
65+
</Link>
66+
</LoginVisible>
67+
);
68+
}
69+
70+
function YearAddButton() {
71+
return (
72+
<LoginVisible role={['ROLE_COUNCIL', 'ROLE_STAFF']}>
73+
<Link
74+
href={`${minutePath}/create`}
75+
className="mb-7 ml-0.5 flex h-[30px] w-fit items-center rounded-2xl border border-main-orange pl-0.5 pr-2 pt-px text-md text-main-orange duration-200 hover:bg-main-orange hover:text-white"
76+
>
77+
<span className="material-symbols-outlined text-xl font-light">add</span>
78+
<span className="font-semibold">연도 추가</span>
79+
</Link>
80+
</LoginVisible>
81+
);
82+
}
83+
84+
function Minutes({ minute, isLast }: { minute: CouncilMeetingMinute; isLast: boolean }) {
85+
const handleDelete = async () => {
86+
const resp = await deleteMinuteAction(minute.year, minute.index);
87+
handleServerResponse(resp, {
88+
successMessage: `${minute.year}${minute.index}차 회의록을 삭제했습니다.`,
89+
});
90+
};
91+
92+
return (
93+
<div className="mb-10 w-fit border-b border-neutral-200 pb-10">
94+
<div className="flex items-center justify-between gap-2.5 ">
95+
<div className="font-semibold">{minute.index}차 회의 회의록</div>
96+
<LoginVisible role={['ROLE_COUNCIL', 'ROLE_STAFF']}>
97+
<div className="flex justify-end gap-3">
98+
{isLast && <DeleteButton onDelete={handleDelete} />}
99+
<EditButton href={`${minutePath}/edit?year=${minute.year}&index=${minute.index}`} />
100+
</div>
101+
</LoginVisible>
102+
</div>
103+
{minute.attachments.map((file) => (
104+
<CouncilAttachment {...file} key={file.id} />
105+
))}
106+
</div>
107+
);
108+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
'use client';
2+
3+
import { postMinutesByYearAction } from '@/actions/council';
4+
import PageLayout from '@/components/layout/pageLayout/PageLayout';
5+
import { councilMinute } from '@/constants/segmentNode';
6+
import { useRouter } from '@/i18n/routing';
7+
import { contentToFormData } from '@/utils/formData';
8+
import { getPath } from '@/utils/page';
9+
10+
import CouncilMeetingMinuteEditor, { MinuteFormData } from '../CouncilMeetingMinuteEditor';
11+
12+
const minutePath = getPath(councilMinute);
13+
14+
export default function CouncilMinuteCreatePage({
15+
searchParams,
16+
}: {
17+
searchParams: { year?: string };
18+
}) {
19+
const year = Number(searchParams.year);
20+
if (searchParams.year !== undefined && Number.isNaN(year))
21+
throw new Error('/meeting-minute?year=[year]: year가 숫자가 아닙니다.');
22+
23+
const router = useRouter();
24+
25+
const onCancel = () => router.push(minutePath);
26+
27+
const onSubmit = async (requestObject: MinuteFormData) => {
28+
const formData = contentToFormData('CREATE', { attachments: requestObject.file });
29+
await postMinutesByYearAction(requestObject.year, formData);
30+
};
31+
32+
return (
33+
// TODO: 영문 번역
34+
<PageLayout title={`${year ? `${year}년 ` : ''}학생회 회의록 추가`} titleType="big" hideNavbar>
35+
<CouncilMeetingMinuteEditor
36+
defaultValues={year ? { year, file: [] } : undefined}
37+
onSubmit={onSubmit}
38+
onCancel={onCancel}
39+
/>
40+
</PageLayout>
41+
);
42+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
'use client';
2+
3+
import { putMinuteAction } from '@/actions/council';
4+
import { CouncilMeetingMinute } from '@/apis/types/council';
5+
import PageLayout from '@/components/layout/pageLayout/PageLayout';
6+
import { councilMinute } from '@/constants/segmentNode';
7+
import { useRouter } from '@/i18n/routing';
8+
import { contentToFormData, getAttachmentDeleteIds, getEditorFile } from '@/utils/formData';
9+
import { getPath } from '@/utils/page';
10+
11+
import CouncilMeetingMinuteEditor, { MinuteFormData } from '../CouncilMeetingMinuteEditor';
12+
13+
const minutePath = getPath(councilMinute);
14+
15+
export default function EditMinutePageContent({
16+
year,
17+
index,
18+
data,
19+
}: {
20+
year: number;
21+
index: number;
22+
data: CouncilMeetingMinute;
23+
}) {
24+
const router = useRouter();
25+
26+
const onCancel = () => router.push(minutePath);
27+
28+
const onSubmit = async (requestObject: MinuteFormData) => {
29+
const deleteIds = getAttachmentDeleteIds(requestObject.file, data.attachments);
30+
const formData = contentToFormData('EDIT', {
31+
requestObject: { deleteIds },
32+
attachments: requestObject.file,
33+
});
34+
35+
await putMinuteAction(year, index, formData);
36+
};
37+
38+
return (
39+
// TODO: 영문 번역
40+
<PageLayout title={`${year}년 학생회 ${index}차 회의록 편집`} titleType="big" hideNavbar>
41+
<CouncilMeetingMinuteEditor
42+
defaultValues={{ year, file: getEditorFile(data.attachments) }}
43+
onSubmit={onSubmit}
44+
onCancel={onCancel}
45+
/>
46+
</PageLayout>
47+
);
48+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { getCouncilMinute } from '@/apis/v2/council/meeting-minute';
2+
3+
import EditMinutePageContent from './EditMinutePageContent';
4+
5+
interface MinuteEditPageProps {
6+
searchParams: Promise<{ year: string; index: string }>;
7+
}
8+
9+
export default async function CouncilMinuteEditPage(props: MinuteEditPageProps) {
10+
const searchParams = await props.searchParams;
11+
const year = Number(searchParams.year);
12+
const index = Number(searchParams.index);
13+
14+
if (Number.isNaN(year) || Number.isNaN(index))
15+
throw new Error('/meeting-minute?year=[year]&index=[index]: year나 index가 숫자가 아닙니다.');
16+
17+
const data = await getCouncilMinute(year, index);
18+
19+
return <EditMinutePageContent year={year} index={index} data={data} />;
20+
}

0 commit comments

Comments
 (0)