Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ 학생회 회의록 페이지 구현 #320

Merged
merged 21 commits into from
Mar 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
e01f8f1
feat: api 구현
Limchansol Feb 20, 2025
520aaf0
feat: 회의록 페이지 타임라인 임시 구현
Limchansol Feb 20, 2025
dd7f001
feat: 회의록 연도 추가 구현
Limchansol Feb 21, 2025
af54cf9
feat: 회의록 뷰어 구현
Limchansol Feb 21, 2025
0e86011
feat: togglable 뷰어 추가
Limchansol Feb 21, 2025
7a721f5
Merge branch 'develop' into feat/minute
Limchansol Feb 24, 2025
3360d1d
Merge branch 'feat/minute' of https://github.com/wafflestudio/csereal…
Limchansol Feb 24, 2025
058c911
fix: 회의록 타임라인 뷰어 수정하여 구현
Limchansol Feb 24, 2025
a25c7bd
Merge branch 'develop' into feat/minute
Limchansol Feb 24, 2025
245fbd0
feat: 회의록 편집 기능
Limchansol Feb 24, 2025
009c543
feat: 새로운 회의록 추가 기능
Limchansol Feb 24, 2025
54465b9
fix: 컴포넌트명 변경 등 리뷰 반영
Limchansol Feb 25, 2025
6efdf98
fix: year index NaN 에러 처리
Limchansol Feb 25, 2025
82bb8d7
fix: contentToFormData 확장해서 사용
Limchansol Feb 25, 2025
32d8bf4
fix: MinuteEditor 리팩토링
Limchansol Feb 25, 2025
30c377a
fix: 학생회 편집 권한 council로 변경
Limchansol Feb 25, 2025
d8bc903
fix: middleware 학생회 권한 허용
Limchansol Feb 26, 2025
83ca9e5
fix: 학생회 편집 페이지 서브내비 숨김
Limchansol Feb 26, 2025
f459f5c
fix: 회의록 삭제는 마지막 회차만 가능
Limchansol Mar 4, 2025
d35d112
fix: council PUT api 필드명 통일
Limchansol Mar 5, 2025
6e08697
fix: 첨부파일 디코딩 키 변경
Limchansol Mar 7, 2025
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
45 changes: 43 additions & 2 deletions actions/council.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,26 @@
import { revalidateTag } from 'next/cache';

import { putCouncilIntro } from '@/apis/v2/council/intro';
import {
deleteCouncilMinute,
postCouncilMinutesByYear,
putCouncilMinute,
} from '@/apis/v2/council/meeting-minute';
import { postCouncilReport } from '@/apis/v2/council/report';
import { FETCH_TAG_COUNCIL_INTRO, FETCH_TAG_COUNCIL_REPORT } from '@/constants/network';
import { councilIntro, councilReportList } from '@/constants/segmentNode';
import {
FETCH_TAG_COUNCIL_INTRO,
FETCH_TAG_COUNCIL_MINUTE,
FETCH_TAG_COUNCIL_REPORT,
} from '@/constants/network';
import { councilIntro, councilMinute, councilReportList } from '@/constants/segmentNode';
import { redirectKo } from '@/i18n/routing';
import { getPath } from '@/utils/page';
import { decodeFormDataFileName } from '@/utils/string';

import { withErrorHandler } from './errorHandler';

/** 소개 */

const introPath = getPath(councilIntro);

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

/** 회의록 */

const minutePath = getPath(councilMinute);

export const postMinutesByYearAction = withErrorHandler(
async (year: number, formData: FormData) => {
decodeFormDataFileName(formData, 'attachments');
await postCouncilMinutesByYear(year, formData);
revalidateTag(FETCH_TAG_COUNCIL_MINUTE);
redirectKo(minutePath);
},
);

export const putMinuteAction = withErrorHandler(
async (year: number, index: number, formData: FormData) => {
decodeFormDataFileName(formData, 'newAttachments');
await putCouncilMinute(year, index, formData);
revalidateTag(FETCH_TAG_COUNCIL_MINUTE);
redirectKo(minutePath);
},
);

export const deleteMinuteAction = withErrorHandler(async (year: number, index: number) => {
await deleteCouncilMinute(year, index);
revalidateTag(FETCH_TAG_COUNCIL_MINUTE);
});

/** 활동보고 */

const councilReportPath = getPath(councilReportList);

export const postCouncilReportAction = withErrorHandler(async (formData: FormData) => {
Expand Down
8 changes: 8 additions & 0 deletions apis/types/council.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
import { Attachment } from './attachment';

export interface CouncilMeetingMinute {
year: number;
index: number;
attachments: Attachment[];
}

export interface CouncilReport {
id: number;
title: string;
Expand Down
32 changes: 32 additions & 0 deletions apis/v2/council/meeting-minute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { deleteRequest, postRequest, putRequest } from '@/apis';
import { getRequest } from '@/apis';
import { CouncilMeetingMinute } from '@/apis/types/council';
import { FETCH_TAG_COUNCIL_MINUTE } from '@/constants/network';

interface GETMinutesResponse {
[year: string]: CouncilMeetingMinute[];
}

export const getCouncilMinutes = () =>
getRequest<GETMinutesResponse>(`/v2/council/meeting-minute`, undefined, {
next: { tags: [FETCH_TAG_COUNCIL_MINUTE] },
});

export const getCouncilMinutesByYear = (year: number) =>
getRequest<CouncilMeetingMinute[]>(`/v2/council/meeting-minute/${year}`, undefined, {
next: { tags: [FETCH_TAG_COUNCIL_MINUTE] },
});

export const postCouncilMinutesByYear = (year: number, formData: FormData) =>
postRequest(`/v2/council/meeting-minute/${year}`, { body: formData, jsessionID: true });

export const getCouncilMinute = (year: number, index: number) =>
getRequest<CouncilMeetingMinute>(`/v2/council/meeting-minute/${year}/${index}`, undefined, {
next: { tags: [FETCH_TAG_COUNCIL_MINUTE] },
});

export const putCouncilMinute = (year: number, index: number, formData: FormData) =>
putRequest(`/v2/council/meeting-minute/${year}/${index}`, { body: formData, jsessionID: true });

export const deleteCouncilMinute = (year: number, index: number) =>
deleteRequest(`/v2/council/meeting-minute/${year}/${index}`, { jsessionID: true });
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
'use client';

import { FormProvider, useForm } from 'react-hook-form';

import Fieldset from '@/components/form/Fieldset';
import Form from '@/components/form/Form';
import { EditorFile } from '@/types/form';
import { handleServerResponse } from '@/utils/serverActionError';

export type MinuteFormData = { year: number; file: EditorFile[] };

interface Props {
defaultValues?: MinuteFormData;
onSubmit: (formData: MinuteFormData) => Promise<void>;
onCancel: () => void;
}

export default function CouncilMeetingMinuteEditor({
defaultValues,
onSubmit: _onSubmit,
onCancel,
}: Props) {
const formMethods = useForm<MinuteFormData>({
defaultValues: defaultValues ?? {
year: new Date().getFullYear() + 1,
file: [],
},
});
const { handleSubmit } = formMethods;

const onSubmit = async (requestObject: MinuteFormData) => {
const resp = await _onSubmit(requestObject);
handleServerResponse(resp, { successMessage: '저장되었습니다.' });
};

return (
<FormProvider {...formMethods}>
<Form>
<Fieldset title="연도" mb="mb-6" titleMb="mb-2">
<Form.Text
name="year"
maxWidth="w-[55px]"
disabled={defaultValues !== undefined}
options={{ required: true, valueAsNumber: true }}
/>
</Fieldset>
<Fieldset.File>
<Form.File name="file" />
</Fieldset.File>
<Form.Action onCancel={onCancel} onSubmit={handleSubmit(onSubmit)} />
</Form>
</FormProvider>
);
}
108 changes: 108 additions & 0 deletions app/[locale]/community/council/meeting-minute/MinutePageContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
'use client';

import { useState } from 'react';

import { deleteMinuteAction } from '@/actions/council';
import { CouncilMeetingMinute } from '@/apis/types/council';
import Timeline from '@/app/[locale]/academics/components/timeline/Timeline';
import { DeleteButton, EditButton } from '@/components/common/Buttons';
import LoginVisible from '@/components/common/LoginVisible';
import PageLayout from '@/components/layout/pageLayout/PageLayout';
import { councilMinute } from '@/constants/segmentNode';
import { Link } from '@/i18n/routing';
import { getPath } from '@/utils/page';
import { handleServerResponse } from '@/utils/serverActionError';

import CouncilAttachment from '../components/CouncilAttachments';

const minutePath = getPath(councilMinute);
const THIS_YEAR = new Date().getFullYear();

export default function MinutePageContent({
contents,
}: {
contents: { [year: string]: CouncilMeetingMinute[] };
}) {
const [selectedYear, setSelectedYear] = useState(THIS_YEAR);
const timeLineYears = Object.keys(contents)
.map(Number)
.sort((a, b) => b - a);
const selectedContents = contents[selectedYear.toString()] ?? [];

return (
<PageLayout titleType="big">
<YearAddButton />
<Timeline
times={timeLineYears}
selectedTime={selectedYear}
setSelectedTime={setSelectedYear}
/>
<div className="mt-7">
{selectedContents.map((minute, i) => {
return (
<Minutes
minute={minute}
key={`${minute.year}_${minute.index}`}
isLast={i === selectedContents.length - 1}
/>
);
})}
</div>
<MinuteAddButton year={selectedYear} />
</PageLayout>
);
}

function MinuteAddButton({ year }: { year: number }) {
return (
<LoginVisible role={['ROLE_COUNCIL', 'ROLE_STAFF']}>
<Link
href={`${minutePath}/create?year=${year}`}
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"
>
<span className="material-symbols-outlined font-light">add</span>
<span className="text-base font-medium">회의록 추가</span>
</Link>
</LoginVisible>
);
}

function YearAddButton() {
return (
<LoginVisible role={['ROLE_COUNCIL', 'ROLE_STAFF']}>
<Link
href={`${minutePath}/create`}
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"
>
<span className="material-symbols-outlined text-xl font-light">add</span>
<span className="font-semibold">연도 추가</span>
</Link>
</LoginVisible>
);
}

function Minutes({ minute, isLast }: { minute: CouncilMeetingMinute; isLast: boolean }) {
const handleDelete = async () => {
const resp = await deleteMinuteAction(minute.year, minute.index);
handleServerResponse(resp, {
successMessage: `${minute.year}년 ${minute.index}차 회의록을 삭제했습니다.`,
});
};

return (
<div className="mb-10 w-fit border-b border-neutral-200 pb-10">
<div className="flex items-center justify-between gap-2.5 ">
<div className="font-semibold">{minute.index}차 회의 회의록</div>
<LoginVisible role={['ROLE_COUNCIL', 'ROLE_STAFF']}>
<div className="flex justify-end gap-3">
{isLast && <DeleteButton onDelete={handleDelete} />}
<EditButton href={`${minutePath}/edit?year=${minute.year}&index=${minute.index}`} />
</div>
</LoginVisible>
</div>
{minute.attachments.map((file) => (
<CouncilAttachment {...file} key={file.id} />
))}
</div>
);
}
42 changes: 42 additions & 0 deletions app/[locale]/community/council/meeting-minute/create/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use client';

import { postMinutesByYearAction } from '@/actions/council';
import PageLayout from '@/components/layout/pageLayout/PageLayout';
import { councilMinute } from '@/constants/segmentNode';
import { useRouter } from '@/i18n/routing';
import { contentToFormData } from '@/utils/formData';
import { getPath } from '@/utils/page';

import CouncilMeetingMinuteEditor, { MinuteFormData } from '../CouncilMeetingMinuteEditor';

const minutePath = getPath(councilMinute);

export default function CouncilMinuteCreatePage({
searchParams,
}: {
searchParams: { year?: string };
}) {
const year = Number(searchParams.year);
if (searchParams.year !== undefined && Number.isNaN(year))
throw new Error('/meeting-minute?year=[year]: year가 숫자가 아닙니다.');

const router = useRouter();

const onCancel = () => router.push(minutePath);

const onSubmit = async (requestObject: MinuteFormData) => {
const formData = contentToFormData('CREATE', { attachments: requestObject.file });
await postMinutesByYearAction(requestObject.year, formData);
};

return (
// TODO: 영문 번역
<PageLayout title={`${year ? `${year}년 ` : ''}학생회 회의록 추가`} titleType="big" hideNavbar>
<CouncilMeetingMinuteEditor
defaultValues={year ? { year, file: [] } : undefined}
onSubmit={onSubmit}
onCancel={onCancel}
/>
</PageLayout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
'use client';

import { putMinuteAction } from '@/actions/council';
import { CouncilMeetingMinute } from '@/apis/types/council';
import PageLayout from '@/components/layout/pageLayout/PageLayout';
import { councilMinute } from '@/constants/segmentNode';
import { useRouter } from '@/i18n/routing';
import { contentToFormData, getAttachmentDeleteIds, getEditorFile } from '@/utils/formData';
import { getPath } from '@/utils/page';

import CouncilMeetingMinuteEditor, { MinuteFormData } from '../CouncilMeetingMinuteEditor';

const minutePath = getPath(councilMinute);

export default function EditMinutePageContent({
year,
index,
data,
}: {
year: number;
index: number;
data: CouncilMeetingMinute;
}) {
const router = useRouter();

const onCancel = () => router.push(minutePath);

const onSubmit = async (requestObject: MinuteFormData) => {
const deleteIds = getAttachmentDeleteIds(requestObject.file, data.attachments);
const formData = contentToFormData('EDIT', {
requestObject: { deleteIds },
attachments: requestObject.file,
});

await putMinuteAction(year, index, formData);
};

return (
// TODO: 영문 번역
<PageLayout title={`${year}년 학생회 ${index}차 회의록 편집`} titleType="big" hideNavbar>
<CouncilMeetingMinuteEditor
defaultValues={{ year, file: getEditorFile(data.attachments) }}
onSubmit={onSubmit}
onCancel={onCancel}
/>
</PageLayout>
);
}
20 changes: 20 additions & 0 deletions app/[locale]/community/council/meeting-minute/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getCouncilMinute } from '@/apis/v2/council/meeting-minute';

import EditMinutePageContent from './EditMinutePageContent';

interface MinuteEditPageProps {
searchParams: Promise<{ year: string; index: string }>;
}

export default async function CouncilMinuteEditPage(props: MinuteEditPageProps) {
const searchParams = await props.searchParams;
const year = Number(searchParams.year);
const index = Number(searchParams.index);

if (Number.isNaN(year) || Number.isNaN(index))
throw new Error('/meeting-minute?year=[year]&index=[index]: year나 index가 숫자가 아닙니다.');

const data = await getCouncilMinute(year, index);

return <EditMinutePageContent year={year} index={index} data={data} />;
}
Loading