[as] feat: 결재선 관리 페이지 구현

This commit is contained in:
2026-05-18 17:40:21 +09:00
parent 0e067bb5fc
commit 25affc06b9
43 changed files with 2071 additions and 236 deletions
@@ -0,0 +1,2 @@
export { AprvrPickerDialog } from "./ui/aprvr-picker-dialog";
export type { ReviewerOption } from "./model/mock-reviewers";
@@ -0,0 +1,17 @@
import { fakerKO as faker } from "@faker-js/faker";
export interface ReviewerOption {
uuid: string;
name: string;
deptNm: string;
}
/** TBD: 검토인력 목록 API 연동 전 목 데이터 */
export function createMockReviewers(count = 12): ReviewerOption[] {
faker.seed(42);
return Array.from({ length: count }, () => ({
uuid: faker.string.uuid(),
name: faker.person.fullName(),
deptNm: faker.commerce.department(),
}));
}
@@ -0,0 +1,99 @@
"use client";
import { useMemo, useState } from "react";
import {
Button,
DataTable,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
} from "@common/components";
import {
createColumnHelper,
getCoreRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { ReviewerOption } from "../model/mock-reviewers";
import { createMockReviewers } from "../model/mock-reviewers";
interface AprvrPickerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelect: (reviewer: ReviewerOption) => void;
}
const columnHelper = createColumnHelper<ReviewerOption>();
function AprvrPickerDialog({ open, onOpenChange, onSelect }: AprvrPickerDialogProps) {
const [keyword, setKeyword] = useState("");
const reviewers = useMemo(() => createMockReviewers(), []);
const filtered = useMemo(() => {
const q = keyword.trim().toLowerCase();
if (!q) return reviewers;
return reviewers.filter(
(r) =>
r.name.toLowerCase().includes(q) || r.deptNm.toLowerCase().includes(q),
);
}, [keyword, reviewers]);
const columns = useMemo(
() => [
columnHelper.accessor("name", { header: "성명" }),
columnHelper.accessor("deptNm", { header: "부서" }),
columnHelper.display({
id: "actions",
header: "선택",
cell: ({ row }) => (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
onSelect(row.original);
onOpenChange(false);
}}
>
선택
</Button>
),
}),
],
[onOpenChange, onSelect],
);
const table = useReactTable({
data: filtered,
columns,
getCoreRowModel: getCoreRowModel(),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>검토인력 선택</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<Input
placeholder="성명·부서 검색"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
<DataTable table={table} variant="line" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
닫기
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
export { AprvrPickerDialog };
@@ -0,0 +1,2 @@
export { ElaprTmpltFormDialog } from "./ui/elapr-tmplt-form-dialog";
export type { ElaprTmpltFormValues } from "./ui/elapr-tmplt-form-dialog";
@@ -0,0 +1,127 @@
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
Button,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Textarea,
} from "@common/components";
import { CodeSelect, UP_CD } from "@/entities/code";
import type { ElaprTmplt } from "@/entities/elapr";
import { generateClientId } from "@/shared/lib";
export interface ElaprTmpltFormValues {
elaprTmpltId: string;
elaprTmpltNm: string;
docSeComCd: string;
elaprTmpltExpln: string;
}
interface ElaprTmpltFormDialogProps {
mode: "create" | "edit";
open: boolean;
onOpenChange: (open: boolean) => void;
initial?: ElaprTmplt | null;
isSubmitting?: boolean;
onSubmit: (values: ElaprTmpltFormValues) => void;
}
function ElaprTmpltFormDialog({
mode,
open,
onOpenChange,
initial,
isSubmitting,
onSubmit,
}: ElaprTmpltFormDialogProps) {
const { register, handleSubmit, reset, setValue, watch } = useForm<ElaprTmpltFormValues>({
defaultValues: {
elaprTmpltId: "",
elaprTmpltNm: "",
docSeComCd: "",
elaprTmpltExpln: "",
},
});
const docSeComCd = watch("docSeComCd");
useEffect(() => {
if (!open) return;
if (mode === "edit" && initial) {
reset({
elaprTmpltId: initial.elaprTmpltId,
elaprTmpltNm: initial.elaprTmpltNm,
docSeComCd: initial.docSeComCd ?? "",
elaprTmpltExpln: initial.elaprTmpltExpln ?? "",
});
return;
}
reset({
// TBD: elaprTmpltId 발급 정책 — 현재 클라이언트 임시 ID
elaprTmpltId: generateClientId("TMPL"),
elaprTmpltNm: "",
docSeComCd: "",
elaprTmpltExpln: "",
});
}, [open, mode, initial, reset]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{mode === "create" ? "결재서식 등록" : "결재서식 수정"}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-2">
<Label htmlFor="elaprTmpltId">전자결재서식아이디</Label>
<Input
id="elaprTmpltId"
readOnly
{...register("elaprTmpltId", { required: true })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="elaprTmpltNm">결재서식명</Label>
<Input
id="elaprTmpltNm"
{...register("elaprTmpltNm", { required: true })}
/>
</div>
<div className="space-y-2">
<Label>문서구분</Label>
<CodeSelect
upCd={UP_CD.DOC_SE}
value={docSeComCd}
onValueChange={(v) => setValue("docSeComCd", v)}
placeholder="문서구분 선택"
/>
</div>
<div className="space-y-2">
<Label htmlFor="elaprTmpltExpln">서식 설명</Label>
<Textarea id="elaprTmpltExpln" rows={3} {...register("elaprTmpltExpln")} />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
취소
</Button>
<Button type="submit" disabled={isSubmitting}>
{mode === "create" ? "등록" : "저장"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export { ElaprTmpltFormDialog };