Skip to content

Commit 5f2cf03

Browse files
authored
Merge branch 'main' into refactor/discipleship
2 parents 21ea2dd + 6816976 commit 5f2cf03

102 files changed

Lines changed: 2471 additions & 662 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import {
2+
GetCongregationNewsListRequest,
3+
PostCongregationNewsRequest,
4+
PutCongregationNewsRequest,
5+
} from "@/types/congregationNews/request";
6+
import {
7+
GetCongregationNewsResponse as GetCongregationNewsResponseType,
8+
GetCongregationNewsListResponse,
9+
} from "@/types/congregationNews/response";
10+
import { api } from ".";
11+
12+
export const getCongregationNewsList = async (params?: GetCongregationNewsListRequest) => {
13+
const { data } = await api.get<GetCongregationNewsListResponse>("/congregation-news", { params });
14+
15+
return data;
16+
};
17+
18+
export const getCongregationNews = async ({ id }: { id: string }) => {
19+
const { data } = await api.get<GetCongregationNewsResponseType>(`/congregation-news/${id}`);
20+
21+
return data;
22+
};
23+
24+
export const postCongregationNews = async (request: PostCongregationNewsRequest) => {
25+
const { data } = await api.post("/congregation-news", request);
26+
27+
return data;
28+
};
29+
30+
export const putCongregationNews = async ({
31+
id,
32+
request,
33+
}: {
34+
id: string;
35+
request: PutCongregationNewsRequest;
36+
}) => {
37+
const { data } = await api.put(`/congregation-news/${id}`, request);
38+
39+
return data;
40+
};
41+
42+
export const deleteCongregationNews = async ({ id }: { id: string }) => {
43+
const { data } = await api.delete(`/congregation-news/${id}`);
44+
45+
return data;
46+
};
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { CongregationNewsType } from "@/types/congregationNews/request";
2+
3+
export const CONGREGATION_NEWS_TYPE_MAP = {
4+
marriage: "결혼",
5+
birth: "출산",
6+
funeral: "장례",
7+
opening: "개업",
8+
hospitalization: "입원",
9+
sundayManna: "주일 만나",
10+
iceCream: "아이스크림",
11+
} as const satisfies Record<CongregationNewsType, string>;
12+
13+
export const CONGREGATION_NEWS_TYPE_OPTIONS = Object.entries(CONGREGATION_NEWS_TYPE_MAP).map(([value, label]) => ({
14+
value: value as CongregationNewsType,
15+
label,
16+
}));
17+
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import {
2+
PostCongregationNewsRequest,
3+
PutCongregationNewsRequest,
4+
} from "@/types/congregationNews/request";
5+
import { Button } from "@/components/ui/button";
6+
import { Input } from "@/components/ui/input";
7+
import {
8+
Select,
9+
SelectContent,
10+
SelectItem,
11+
SelectTrigger,
12+
SelectValue,
13+
} from "@/components/ui/select";
14+
import { usePostCongregationNews, usePutCongregationNews } from "@/query/congregationNews";
15+
import { useRouter } from "next/router";
16+
import { SubmitHandler, useForm } from "react-hook-form";
17+
import { CONGREGATION_NEWS_TYPE_OPTIONS } from "./config";
18+
import { cn } from "@/lib/utils";
19+
20+
interface CongregationNewsFormProps {
21+
initialData?: {
22+
_id: string;
23+
type: PostCongregationNewsRequest["type"];
24+
description: string;
25+
};
26+
}
27+
28+
const CongregationNewsForm = ({ initialData }: CongregationNewsFormProps) => {
29+
const { push } = useRouter();
30+
const {
31+
register,
32+
handleSubmit,
33+
watch,
34+
setValue,
35+
formState: { errors },
36+
} = useForm<PostCongregationNewsRequest | PutCongregationNewsRequest>({
37+
defaultValues: initialData
38+
? {
39+
type: initialData.type,
40+
description: initialData.description,
41+
}
42+
: undefined,
43+
});
44+
45+
// Select validation을 위해 register 추가
46+
register("type", { required: true });
47+
48+
const selectedType = watch("type");
49+
const { mutate: postCongregationNews, isPending: isPostPending } = usePostCongregationNews();
50+
const { mutate: putCongregationNews, isPending: isPutPending } = usePutCongregationNews();
51+
52+
const isPending = isPostPending || isPutPending;
53+
const isEditMode = !!initialData;
54+
55+
const onSubmit: SubmitHandler<PostCongregationNewsRequest | PutCongregationNewsRequest> = (
56+
data,
57+
) => {
58+
if (!selectedType || !data.description) {
59+
return alert("모든 정보를 입력해주세요.");
60+
}
61+
62+
if (isEditMode && initialData) {
63+
putCongregationNews(
64+
{ id: initialData._id, request: data as PutCongregationNewsRequest },
65+
{
66+
onSuccess: () => {
67+
push("/congregation-news");
68+
},
69+
},
70+
);
71+
} else {
72+
postCongregationNews(data as PostCongregationNewsRequest, {
73+
onSuccess: () => {
74+
push("/congregation-news");
75+
},
76+
});
77+
}
78+
};
79+
80+
return (
81+
<form className="flex flex-col gap-5" onSubmit={handleSubmit(onSubmit)}>
82+
<label className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-5">
83+
<p className="min-w-[100px] text-xl font-bold">타입</p>
84+
<Select
85+
value={selectedType}
86+
onValueChange={(value) =>
87+
setValue("type", value as PostCongregationNewsRequest["type"], { shouldValidate: true })
88+
}
89+
required
90+
>
91+
<SelectTrigger
92+
className={cn("w-[233px]", !selectedType && errors.type && "border-red-500")}
93+
>
94+
<SelectValue placeholder="타입을 선택하세요" />
95+
</SelectTrigger>
96+
<SelectContent>
97+
{CONGREGATION_NEWS_TYPE_OPTIONS.map((option) => (
98+
<SelectItem key={option.value} value={option.value}>
99+
{option.label}
100+
</SelectItem>
101+
))}
102+
</SelectContent>
103+
</Select>
104+
</label>
105+
106+
<label className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-5">
107+
<p className="min-w-[100px] pt-2 text-xl font-bold">내용</p>
108+
<textarea
109+
{...register("description", { required: true })}
110+
className={cn(
111+
"flex min-h-[120px] w-full rounded-md border border-zinc-200 bg-transparent px-3 py-2 text-sm shadow-sm transition-colors",
112+
"placeholder:text-zinc-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950",
113+
"disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-800 dark:placeholder:text-zinc-400 dark:focus-visible:ring-zinc-300",
114+
errors.description && "border-red-500",
115+
)}
116+
placeholder="내용을 입력하세요"
117+
/>
118+
</label>
119+
120+
{errors.type && <p className="text-sm text-red-500">타입을 선택해주세요.</p>}
121+
{errors.description && <p className="text-sm text-red-500">내용을 입력해주세요.</p>}
122+
123+
<div className="flex justify-end">
124+
<Button variant="outline" type="submit" isLoading={isPending} disabled={isPending}>
125+
{isEditMode ? "수정하기" : "추가하기"}
126+
</Button>
127+
</div>
128+
</form>
129+
);
130+
};
131+
132+
export default CongregationNewsForm;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useDeleteCongregationNews, useGetCongregationNewsList } from "@/query/congregationNews";
2+
import { CongregationNews } from "@/types/congregationNews/response";
3+
import dayjs from "dayjs";
4+
import { Edit, MoreHorizontal, Trash2 } from "lucide-react";
5+
import { useRouter } from "next/router";
6+
import { Spinner } from "ui";
7+
import { Button } from "../ui/button";
8+
import {
9+
DropdownMenu,
10+
DropdownMenuContent,
11+
DropdownMenuItem,
12+
DropdownMenuSeparator,
13+
DropdownMenuTrigger,
14+
} from "../ui/dropdown-menu";
15+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
16+
import { CONGREGATION_NEWS_TYPE_MAP } from "./config";
17+
18+
const CongregationNewsList = () => {
19+
const { push } = useRouter();
20+
const { data, isFetching } = useGetCongregationNewsList();
21+
const { mutate: deleteCongregationNews } = useDeleteCongregationNews();
22+
23+
const handleDelete = (news: CongregationNews) => {
24+
if (!confirm("삭제하시겠습니까?")) return;
25+
26+
deleteCongregationNews({ id: news._id });
27+
};
28+
29+
const handleEdit = (news: CongregationNews) => {
30+
push(`/congregation-news/${news._id}`);
31+
};
32+
33+
if (isFetching)
34+
return (
35+
<div className="flex h-full items-center justify-center">
36+
<Spinner />
37+
</div>
38+
);
39+
40+
return (
41+
<Table>
42+
<TableHeader>
43+
<TableRow>
44+
<TableHead>타입</TableHead>
45+
<TableHead>내용</TableHead>
46+
<TableHead>생성일</TableHead>
47+
<TableHead>수정일</TableHead>
48+
<TableHead>작업</TableHead>
49+
</TableRow>
50+
</TableHeader>
51+
<TableBody>
52+
{data?.congregationNewsList.map((news) => (
53+
<TableRow key={news._id}>
54+
<TableCell>{CONGREGATION_NEWS_TYPE_MAP[news.type]}</TableCell>
55+
<TableCell className="max-w-md">{news.description}</TableCell>
56+
<TableCell>{dayjs(news.createdAt).format("YYYY-MM-DD HH:mm:ss")}</TableCell>
57+
<TableCell>{dayjs(news.updatedAt).format("YYYY-MM-DD HH:mm:ss")}</TableCell>
58+
<TableCell>
59+
<DropdownMenu>
60+
<DropdownMenuTrigger asChild>
61+
<Button variant="ghost" size="icon" className="h-8 w-8">
62+
<MoreHorizontal className="h-4 w-4" />
63+
</Button>
64+
</DropdownMenuTrigger>
65+
<DropdownMenuContent align="end">
66+
<DropdownMenuItem onClick={() => handleEdit(news)}>
67+
<Edit className="mr-2 h-4 w-4" />
68+
<span>편집</span>
69+
</DropdownMenuItem>
70+
<DropdownMenuSeparator />
71+
<DropdownMenuItem className="text-destructive" onClick={() => handleDelete(news)}>
72+
<Trash2 className="mr-2 h-4 w-4" />
73+
<span>삭제</span>
74+
</DropdownMenuItem>
75+
</DropdownMenuContent>
76+
</DropdownMenu>
77+
</TableCell>
78+
</TableRow>
79+
))}
80+
</TableBody>
81+
</Table>
82+
);
83+
};
84+
85+
export default CongregationNewsList;

apps/admin/src/components/layout/sidebar.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88
Library,
99
LucideIcon,
1010
Megaphone,
11+
MessageCircle,
1112
Radio,
1213
Video,
14+
Users,
1315
} from "lucide-react";
1416
import Link from "next/link";
1517
import { useRouter } from "next/router";
@@ -86,6 +88,12 @@ const NAV_LIST: INav[] = [
8688
tooltip: "각종 공지와 알림을 팝업으로 띄우도록 관리할 수 있습니다.",
8789
icon: Megaphone,
8890
},
91+
{
92+
title: "교우 소식",
93+
href: "/congregation-news",
94+
tooltip: "교우 소식을 등록하고 수정할 수 있습니다.",
95+
icon: Users,
96+
},
8997
];
9098

9199
const Sidebar = () => {
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { MissionLocation } from "type";
2+
3+
export const MISSION_LOCATION_MAP = {
4+
bangladesh: "방글라데시",
5+
bulgaria: "불가리아",
6+
uk: "영국",
7+
uganda: "우간다",
8+
indiaThailand: "인도/태국",
9+
} as const satisfies Record<MissionLocation, string>;
10+
11+
export const MISSION_LOCATION_OPTIONS = Object.entries(MISSION_LOCATION_MAP).map(([value, label]) => ({
12+
value: value as MissionLocation,
13+
label,
14+
}));
15+

0 commit comments

Comments
 (0)