feat: ip 관리 서비스 작업

This commit is contained in:
2025-03-19 16:23:33 +09:00
parent 6f0e1a6e40
commit 915543628d
4 changed files with 295 additions and 141 deletions
+20
View File
@@ -0,0 +1,20 @@
import { client } from "@/shared/api";
import { ENDPOINT } from "@/shared/config";
import type { IP, IpList, IpService } from "../model/ip-interface";
export class IpApiService implements IpService {
public async getIpList(): Promise<IpList> {
const response = await client.get<IpList>(`${ENDPOINT.CMS_SERVICE.IPS}`);
return response.data;
}
public async saveIpList(body: IP[]): Promise<void> {
const response = await client.post<void>(
`${ENDPOINT.CMS_SERVICE.IPS}`,
body,
);
return response.data;
}
}
export const IpApi = new IpApiService();
@@ -0,0 +1,50 @@
import { z } from "zod";
export const ipSchema = z.object({
mngNo: z.number().nullish(),
siteId: z.string(),
ipAddr: z.string().nonempty(),
prmYn: z.string(),
mode: z.string(),
});
export const ipFormSchema = z.object({
ips: z.array(ipSchema),
});
export type IP = z.infer<typeof ipSchema>;
export type IPFormData = z.infer<typeof ipFormSchema>;
export interface IpList {
content: IP[];
pageable: {
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
offset: number;
pageNumber: number;
pageSize: number;
paged: boolean;
unpaged: boolean;
};
last: boolean;
totalPages: number;
totalElements: number;
number: number;
sort: {
empty: boolean;
sorted: boolean;
unsorted: boolean;
};
size: number;
first: boolean;
numberOfElements: number;
empty: boolean;
}
export interface IpService {
getIpList(): Promise<IpList>;
saveIpList(body: IP[]): Promise<void>;
}
+224 -141
View File
@@ -1,4 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { zodResolver } from "@hookform/resolvers/zod";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { useForm, useFieldArray, type FieldArrayWithId } from "react-hook-form";
import { import {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
@@ -15,162 +19,241 @@ import {
Input, Input,
RadioGroup, RadioGroup,
RadioGroupItem, RadioGroupItem,
Checkbox,
DialogFooter, DialogFooter,
DialogClose, DialogClose,
Checkbox,
} from "@common/components"; } from "@common/components";
import {
type IP = { Form,
id: number; FormControl,
ipAddress: string; FormField,
allowed: boolean; FormItem,
}; FormMessage,
} from "@/shared/ui/form";
import { ENDPOINT, SITE_ID } from "@/shared/config";
import { IpApi } from "../api/ip-service";
import { ipFormSchema, type IP, type IPFormData } from "../model/ip-interface";
import { ConfirmDialog } from "@/shared/ui/confirm-dialog";
import { useAlertStore } from "@/shared/lib/use-alert-store";
type IPManagementDialogProps = { type IPManagementDialogProps = {
triggerDisabled?: boolean; triggerDisabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
}; };
function IPManagementDialog({ triggerDisabled }: IPManagementDialogProps) { function IPManagementDialog({
const [rows, setRows] = useState<IP[]>([]); triggerDisabled,
const [rowSelection, setRowSelection] = useState<IP["id"][]>([]); ...props
}: IPManagementDialogProps) {
const { confirm } = useConfirm();
const { setAlertMessage } = useAlertStore((state) => state);
const onSave = () => {}; useQuery({
queryKey: [ENDPOINT.CMS_SERVICE.IPS, props.open],
queryFn: () => IpApi.getIpList(),
select: (data) => {
const ips = data.content.map((item) => ({
...item,
mode: "U",
}));
replace(ips);
return data;
},
});
const { mutateAsync } = useMutation({
mutationFn: (body: IP[]) => IpApi.saveIpList(body),
});
const form = useForm<IPFormData>({
resolver: zodResolver(ipFormSchema),
});
const { fields, append, remove, update, replace } = useFieldArray({
control: form.control,
name: "ips",
});
const [isOpen, setIsOpen] = useState<boolean>(false);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const onSubmit = async (data: IPFormData) => {
setIsOpen(true);
if (await confirm("저장하시겠습니까?")) {
setIsOpen(false);
try {
await mutateAsync(data.ips);
setAlertMessage("저장되었습니다.");
} catch (error) {
console.error(error);
setAlertMessage("저장에 실패했습니다.");
}
}
};
const handleAddIP = () => { const handleAddIP = () => {
const newIP: IP = { append({
id: Date.now(), mngNo: null,
ipAddress: "", siteId: SITE_ID,
allowed: true, ipAddr: "",
}; prmYn: "Y",
setRows([...rows, newIP]); mode: "C",
};
const handleDeleteIP = () => {
const filteredRows = rows.filter((row) => !rowSelection.includes(row.id));
setRows(filteredRows);
setRowSelection([]);
};
const handleIPAddressChange = (id: number, value: string) => {
setRows(
rows.map((row) => (row.id === id ? { ...row, ipAddress: value } : row)),
);
};
const handleAllowedChange = (id: number, value: boolean) => {
setRows(
rows.map((row) => (row.id === id ? { ...row, allowed: value } : row)),
);
};
const toggleRowSelection = (id: number) => {
setRowSelection((prev) => {
if (prev.includes(id)) {
return prev.filter((item) => item !== id);
} else {
return [...prev, id];
}
}); });
}; };
const handleSelectRow = (i: number) => {
setSelectedRows((prev) =>
prev.includes(i) ? prev.filter((j) => j !== i) : [...prev, i],
);
};
const handleDeleteIP = () => {
selectedRows.forEach((i) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { id, ...field } = fields[i] as FieldArrayWithId<IPFormData>;
if (field.mngNo) {
update(i, {
...field,
mode: "D",
});
} else {
remove(i);
}
});
setSelectedRows([]);
};
return ( return (
<Dialog> <>
<DialogTrigger asChild> <Dialog {...props}>
<Button disabled={triggerDisabled}>IP 관리</Button> <DialogTrigger asChild>
</DialogTrigger> <Button disabled={triggerDisabled}>IP 관리</Button>
<DialogContent className="sm:max-w-[60rem]"> </DialogTrigger>
<DialogHeader> <DialogContent className="max-w-[60rem]" aria-describedby={undefined}>
<DialogTitle>IP 관리</DialogTitle> <DialogHeader>
</DialogHeader> <DialogTitle>IP 관리</DialogTitle>
<div className="mb-4 flex justify-end gap-2"> </DialogHeader>
<Button size="sm" color="green"> <Form {...form}>
엑셀 업로드 <form
</Button> onSubmit={(e) => {
<Button size="sm" color="white" onClick={handleAddIP}> e.preventDefault();
항목 추가 e.stopPropagation();
</Button> form.handleSubmit(onSubmit)(e);
<Button }}
size="sm" >
color="red" <div className="mb-4 flex justify-end gap-2">
onClick={handleDeleteIP} <Button type="button" size="sm" onClick={handleAddIP}>
disabled={rowSelection.length === 0} 항목 추가
> </Button>
항목 삭제 <Button
</Button> type="button"
</div> size="sm"
<div className="card card-border"> color="red"
<Table variant="secondary"> onClick={handleDeleteIP}
<colgroup> disabled={selectedRows.length === 0}
<col width="10%" /> >
<col width="10%" /> 항목 삭제
<col width="55%" /> </Button>
<col width="25%" /> </div>
</colgroup> <div className="card card-border max-h-[28.5rem] overflow-auto">
<TableHeader> <Table variant="secondary">
<TableRow> <colgroup>
<TableHead>NO</TableHead> <col width="10%" />
<TableHead>선택</TableHead> <col width="65%" />
<TableHead>IP 주소</TableHead> <col width="25%" />
<TableHead>허용여부</TableHead> </colgroup>
</TableRow> <TableHeader className="sticky -top-[0.6rem]">
</TableHeader> <TableRow>
<TableBody> <TableHead>선택</TableHead>
{rows.map((row, idx) => ( <TableHead>IP 주소</TableHead>
<TableRow key={row.id}> <TableHead>허용여부</TableHead>
<TableCell align="center">{idx + 1}</TableCell> </TableRow>
<TableCell align="center"> </TableHeader>
<Checkbox <TableBody>
checked={rowSelection.includes(row.id)} {fields.map((field, i) => {
onCheckedChange={() => toggleRowSelection(row.id)} if (field.mode === "D") return null;
className="cursor-pointer" return (
/> <TableRow key={field.id}>
</TableCell> <TableCell align="center" className="!px-0">
<TableCell align="center"> <Checkbox
<Input checked={selectedRows.includes(i)}
value={row.ipAddress} onCheckedChange={() => handleSelectRow(i)}
onChange={(e) => />
handleIPAddressChange(row.id, e.target.value) </TableCell>
} <TableCell align="center">
className="!text-[1.3rem]" <FormField
/> control={form.control}
</TableCell> name={`ips.${i}.ipAddr`}
<TableCell align="center"> render={({ field }) => (
<RadioGroup <FormItem>
value={row.allowed ? "true" : "false"} <FormControl>
onValueChange={(value) => <Input
handleAllowedChange(row.id, value === "true") {...field}
} className="!text-[1.3rem]"
className="flex justify-center" />
> </FormControl>
<div className="flex items-center space-x-2"> <FormMessage className="text-left" />
<RadioGroupItem value="true" id={`allow-${row.id}`} /> </FormItem>
<label htmlFor={`allow-${row.id}`}>예</label> )}
</div> />
<div className="flex items-center space-x-2"> </TableCell>
<RadioGroupItem value="false" id={`deny-${row.id}`} /> <TableCell align="center">
<label htmlFor={`deny-${row.id}`}>아니오</label> <FormField
</div> control={form.control}
</RadioGroup> name={`ips.${i}.prmYn`}
</TableCell> render={({ field }) => (
</TableRow> <FormItem>
))} <FormControl>
</TableBody> <RadioGroup
</Table> value={field.value}
</div> onValueChange={field.onChange}
<DialogFooter className="!justify-center"> className="flex justify-center"
<DialogClose asChild> >
<Button size="lg" onClick={onSave}> <div className="flex items-center space-x-2">
저장 <RadioGroupItem
</Button> value="Y"
</DialogClose> id={`prmY-${i}`}
<DialogClose asChild> />
<Button size="lg" color="white"> <label htmlFor={`prmY-${i}`}>예</label>
취소 </div>
</Button> <div className="flex items-center space-x-2">
</DialogClose> <RadioGroupItem
</DialogFooter> value="N"
</DialogContent> id={`prmN-${i}`}
</Dialog> />
<label htmlFor={`prmN-${i}`}>
아니오
</label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
<DialogFooter className="!justify-center">
<Button type="submit" size="lg">
저장
</Button>
<DialogClose asChild>
<Button type="button" size="lg" color="white">
취소
</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
<ConfirmDialog open={isOpen} />
</>
); );
} }
+1
View File
@@ -11,6 +11,7 @@ export const ENDPOINT = {
CMS_SERVICE: { CMS_SERVICE: {
SITES: "/cms-service/api/v1/sites", SITES: "/cms-service/api/v1/sites",
CODE: "/cms-service/api/v1/code/cache", CODE: "/cms-service/api/v1/code/cache",
IPS: "/cms-service/api/v1/site-ips",
}, },
}; };