[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 @@
export { ElaprAplnGrid } from "./ui/elapr-apln-grid";
@@ -0,0 +1,148 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { arrayMove } from "@dnd-kit/sortable";
import type { ElaprApln } from "@/entities/elapr";
import { generateClientId } from "@/shared/lib";
import type { ElaprAplnRow } from "../model/types";
function toRow(item: ElaprApln): ElaprAplnRow {
return { ...item, isNew: false, isDirty: false };
}
interface UseAplnRowsOptions {
elaprTmpltId: string | null;
serverRows: ElaprApln[] | undefined;
}
export function useAplnRows({ elaprTmpltId, serverRows }: UseAplnRowsOptions) {
const [rows, setRows] = useState<ElaprAplnRow[]>([]);
const [editingRowId, setEditingRowId] = useState<string | null>(null);
useEffect(() => {
if (!elaprTmpltId) {
setRows([]);
setEditingRowId(null);
return;
}
setRows((serverRows ?? []).map(toRow));
setEditingRowId(null);
}, [elaprTmpltId, serverRows]);
const isEditingLocked = editingRowId != null;
const addRow = useCallback(() => {
if (!elaprTmpltId || isEditingLocked) return;
const tempId = generateClientId("APLN");
const nextSeq = rows.length + 1;
setRows((prev) => [
...prev,
{
aplnId: tempId,
elaprTmpltId,
atrzSeqNo: nextSeq,
atrzSeComCd: "",
aprvrUuid: "",
aprvrNm: "",
isNew: true,
isDirty: true,
},
]);
setEditingRowId(tempId);
}, [elaprTmpltId, isEditingLocked, rows.length]);
const startEdit = useCallback(
(aplnId: string) => {
if (isEditingLocked) return;
setEditingRowId(aplnId);
setRows((prev) =>
prev.map((row) =>
row.aplnId === aplnId
? { ...row, snapshot: { ...row } }
: row,
),
);
},
[isEditingLocked],
);
const updateRow = useCallback(
(aplnId: string, patch: Partial<ElaprAplnRow>) => {
if (editingRowId !== aplnId) return;
setRows((prev) =>
prev.map((row) =>
row.aplnId === aplnId
? { ...row, ...patch, isDirty: true }
: row,
),
);
},
[editingRowId],
);
const cancelEdit = useCallback(() => {
if (!editingRowId) return;
setRows((prev) => {
const target = prev.find((r) => r.aplnId === editingRowId);
if (target?.isNew) {
return prev.filter((r) => r.aplnId !== editingRowId);
}
return prev.map((row) => {
if (row.aplnId !== editingRowId) return row;
if (row.snapshot) {
const { snapshot: _s, isNew: _n, isDirty: _d, ...restored } = row.snapshot;
return { ...restored, isNew: false, isDirty: false };
}
return { ...row, isDirty: false };
});
});
setEditingRowId(null);
}, [editingRowId]);
const clearEditing = useCallback(() => {
setEditingRowId(null);
}, []);
const removeRowLocal = useCallback((aplnId: string) => {
setRows((prev) => prev.filter((r) => r.aplnId !== aplnId));
setEditingRowId((id) => (id === aplnId ? null : id));
}, []);
const reorderRows = useCallback(
(activeId: string, overId: string) => {
if (isEditingLocked) return;
setRows((prev) => {
const oldIndex = prev.findIndex((r) => r.aplnId === activeId);
const newIndex = prev.findIndex((r) => r.aplnId === overId);
if (oldIndex < 0 || newIndex < 0) return prev;
const moved = arrayMove(prev, oldIndex, newIndex).map((row, idx) => ({
...row,
atrzSeqNo: idx + 1,
isDirty: true,
}));
return moved;
});
},
[isEditingLocked],
);
return {
rows,
setRows,
editingRowId,
isEditingLocked,
addRow,
startEdit,
updateRow,
cancelEdit,
clearEditing,
removeRowLocal,
reorderRows,
};
}
@@ -0,0 +1,16 @@
import {
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates } from "@dnd-kit/sortable";
export function useDndSensors() {
return useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
}
@@ -0,0 +1,26 @@
import type { ElaprAplnRow } from "../model/types";
export function validateAplnRow(row: ElaprAplnRow): string | null {
if (row.atrzSeqNo == null || Number.isNaN(row.atrzSeqNo)) {
return "결재순서번호를 입력해 주세요.";
}
if (!row.atrzSeComCd?.trim()) {
return "결재구분을 선택해 주세요.";
}
if (!row.aprvrUuid?.trim()) {
return "결재자를 선택해 주세요.";
}
return null;
}
export function validateUniqueSeqNo(rows: ElaprAplnRow[]): string | null {
const seqSet = new Set<number>();
for (const row of rows) {
if (row.atrzSeqNo == null) continue;
if (seqSet.has(row.atrzSeqNo)) {
return "동일 서식 내 결재순서번호가 중복됩니다.";
}
seqSet.add(row.atrzSeqNo);
}
return null;
}
@@ -0,0 +1,11 @@
import type { ElaprApln } from "@/entities/elapr";
/** 그리드 로컬 행 — API 응답 + UI 보조 필드 */
export interface ElaprAplnRow extends ElaprApln {
/** 결재자 표시명 — 응답 DTO에 없음, 선택 시 로컬 유지 */
aprvrNm?: string;
isNew?: boolean;
isDirty?: boolean;
/** 수정 취소용 스냅샷 */
snapshot?: ElaprAplnRow;
}
@@ -0,0 +1,278 @@
"use client";
import { useState } from "react";
import { closestCenter, DndContext, type DragEndEvent } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import {
Button,
Table,
TableHead,
TableHeader,
TableRow,
} from "@common/components";
import {
useCreateElaprApln,
useDeleteElaprApln,
useElaprAplnPage,
useUpdateElaprApln,
} from "@/entities/elapr";
import { AprvrPickerDialog, type ReviewerOption } from "@/features/aprvr-picker";
import { useAplnRows } from "../lib/use-apln-rows";
import { useDndSensors } from "../lib/use-dnd-sensors";
import { validateAplnRow, validateUniqueSeqNo } from "../lib/validators";
import type { ElaprAplnRow } from "../model/types";
import { SortableAplnRow } from "./sortable-apln-row";
interface ElaprAplnGridProps {
elaprTmpltId: string | null;
}
function ElaprAplnGrid({ elaprTmpltId }: ElaprAplnGridProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [pickingRowId, setPickingRowId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState<string | null>(null);
const aplnQuery = useElaprAplnPage({
elaprTmpltId: elaprTmpltId ?? undefined,
page: 0,
size: 200,
sort: "atrzSeqNo,asc",
});
const createMutation = useCreateElaprApln();
const updateMutation = useUpdateElaprApln();
const deleteMutation = useDeleteElaprApln();
const {
rows,
editingRowId,
isEditingLocked,
addRow,
startEdit,
updateRow,
cancelEdit,
clearEditing,
removeRowLocal,
reorderRows,
} = useAplnRows({
elaprTmpltId,
serverRows: aplnQuery.data?.content,
});
const sensors = useDndSensors();
const isDisabled = !elaprTmpltId;
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
reorderRows(String(active.id), String(over.id));
};
const handleRowSave = async (row: ElaprAplnRow) => {
const fieldError = validateAplnRow(row);
if (fieldError) {
setStatusMessage(fieldError);
return;
}
const dupError = validateUniqueSeqNo(
rows.map((r) => (r.aplnId === row.aplnId ? row : r)),
);
if (dupError) {
setStatusMessage(dupError);
return;
}
if (!elaprTmpltId) return;
if (row.isNew) {
await createMutation.mutateAsync({
aplnId: row.aplnId,
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
});
} else {
await updateMutation.mutateAsync({
aplnId: row.aplnId,
body: {
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
},
});
}
clearEditing();
setStatusMessage(null);
await aplnQuery.refetch();
};
const handleRowDelete = async (row: ElaprAplnRow) => {
if (!window.confirm("선택한 결재선을 삭제하시겠습니까?")) return;
if (row.isNew) {
removeRowLocal(row.aplnId);
return;
}
if (!elaprTmpltId) return;
await deleteMutation.mutateAsync({ aplnId: row.aplnId, elaprTmpltId });
await aplnQuery.refetch();
};
const handleBulkSave = async () => {
if (!elaprTmpltId || isEditingLocked) return;
const dupError = validateUniqueSeqNo(rows);
if (dupError) {
setStatusMessage(dupError);
return;
}
const dirtyRows = rows.filter((r) => r.isDirty || r.isNew);
for (const row of dirtyRows) {
const fieldError = validateAplnRow(row);
if (fieldError) {
setStatusMessage(fieldError);
return;
}
if (row.isNew) {
await createMutation.mutateAsync({
aplnId: row.aplnId,
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
});
} else {
await updateMutation.mutateAsync({
aplnId: row.aplnId,
body: {
elaprTmpltId,
aprvrUuid: row.aprvrUuid,
atrzSeComCd: row.atrzSeComCd,
atrzSeqNo: row.atrzSeqNo,
},
});
}
}
setStatusMessage("결재선이 저장되었습니다.");
await aplnQuery.refetch();
};
const handleReviewerSelect = (reviewer: ReviewerOption) => {
if (!pickingRowId) return;
updateRow(pickingRowId, {
aprvrUuid: reviewer.uuid,
aprvrNm: reviewer.name,
});
setPickingRowId(null);
};
return (
<section
className={`rounded-lg border border-border bg-background p-4 ${isDisabled ? "pointer-events-none opacity-50" : ""}`}
>
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<h3 className="text-lg font-semibold">결재선 관리</h3>
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-muted-foreground">
{elaprTmpltId
? `선택된 서식: ${elaprTmpltId}`
: "서식을 선택하면 결재선을 관리할 수 있습니다."}
</span>
<Button
type="button"
variant="outline"
disabled={isDisabled || isEditingLocked}
onClick={addRow}
>
행 추가
</Button>
<Button
type="button"
disabled={isDisabled || isEditingLocked}
onClick={handleBulkSave}
>
결재선 저장
</Button>
</div>
</div>
{statusMessage && (
<p className="mb-3 text-sm text-primary" role="status">
{statusMessage}
</p>
)}
{aplnQuery.isPending && elaprTmpltId ? (
<p className="py-8 text-center text-sm text-muted-foreground">로딩 중…</p>
) : !elaprTmpltId ? (
<p className="py-8 text-center text-sm text-muted-foreground">
상단 목록에서 결재서식을 선택해 주세요.
</p>
) : rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
등록된 결재선이 없습니다. [행 추가]로 결재자를 등록하세요.
</p>
) : (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<Table variant="line" className="w-full">
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>결재선아이디</TableHead>
<TableHead>결재순서</TableHead>
<TableHead>결재구분</TableHead>
<TableHead>결재자</TableHead>
<TableHead>등록일시</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<tbody>
<SortableContext
items={rows.map((r) => r.aplnId)}
strategy={verticalListSortingStrategy}
>
{rows.map((row) => (
<SortableAplnRow
key={row.aplnId}
row={row}
isEditing={editingRowId === row.aplnId}
isLocked={isEditingLocked}
dragDisabled={isEditingLocked}
onStartEdit={() => startEdit(row.aplnId)}
onSave={() => handleRowSave(row)}
onCancel={cancelEdit}
onDelete={() => handleRowDelete(row)}
onChange={(patch) => updateRow(row.aplnId, patch)}
onPickAprvr={() => {
setPickingRowId(row.aplnId);
setPickerOpen(true);
}}
/>
))}
</SortableContext>
</tbody>
</Table>
</DndContext>
)}
<AprvrPickerDialog
open={pickerOpen}
onOpenChange={setPickerOpen}
onSelect={handleReviewerSelect}
/>
</section>
);
}
export { ElaprAplnGrid };
@@ -0,0 +1,165 @@
"use client";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
Button,
Input,
TableCell,
TableRow,
} from "@common/components";
import { CodeSelect, findCodeLabel, UP_CD, useCodeList } from "@/entities/code";
import type { ElaprAplnRow } from "../model/types";
interface SortableAplnRowProps {
row: ElaprAplnRow;
isEditing: boolean;
isLocked: boolean;
dragDisabled: boolean;
onStartEdit: () => void;
onSave: () => void;
onCancel: () => void;
onDelete: () => void;
onChange: (patch: Partial<ElaprAplnRow>) => void;
onPickAprvr: () => void;
}
function SortableAplnRow({
row,
isEditing,
isLocked,
dragDisabled,
onStartEdit,
onSave,
onCancel,
onDelete,
onChange,
onPickAprvr,
}: SortableAplnRowProps) {
const { data: atrzSeCodes } = useCodeList(UP_CD.ATRZ_SE);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: row.aplnId, disabled: dragDisabled });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : 1,
};
const rowDisabled = isLocked && !isEditing;
const atrzSeLabel =
row.atrzSeComNm ?? findCodeLabel(atrzSeCodes, row.atrzSeComCd);
return (
<TableRow ref={setNodeRef} style={style} className={isEditing ? "bg-accent/30" : undefined}>
<TableCell align="center" className="w-10">
<button
type="button"
className="cursor-grab disabled:cursor-not-allowed disabled:opacity-40"
disabled={dragDisabled}
{...attributes}
{...listeners}
aria-label="순서 변경"
>
<span className="select-none text-muted-foreground" aria-hidden>
⋮⋮
</span>
</button>
</TableCell>
<TableCell align="left">{row.aplnId}</TableCell>
<TableCell align="center">
{isEditing ? (
<Input
type="number"
className="w-20"
min={1}
value={row.atrzSeqNo ?? ""}
onChange={(e) =>
onChange({ atrzSeqNo: Number.parseInt(e.target.value, 10) || undefined })
}
/>
) : (
(row.atrzSeqNo ?? "—")
)}
</TableCell>
<TableCell align="center">
{isEditing ? (
<CodeSelect
upCd={UP_CD.ATRZ_SE}
className="min-w-[120px]"
value={row.atrzSeComCd ?? ""}
onValueChange={(v) =>
onChange({
atrzSeComCd: v,
atrzSeComNm: findCodeLabel(atrzSeCodes, v),
})
}
placeholder="결재구분"
/>
) : (
atrzSeLabel || "—"
)}
</TableCell>
<TableCell align="left">
<div className="flex items-center gap-2">
<span className="min-w-[80px] text-sm">
{row.aprvrNm || row.aprvrUuid || "—"}
</span>
{isEditing && (
<Button type="button" size="sm" variant="outline" onClick={onPickAprvr}>
선택
</Button>
)}
</div>
</TableCell>
<TableCell align="center">{row.isNew ? "—" : (row.regDt ?? "—")}</TableCell>
<TableCell align="center">
<div className="flex justify-center gap-2">
{isEditing ? (
<>
<Button type="button" size="sm" onClick={onSave}>
저장
</Button>
<Button type="button" size="sm" variant="outline" onClick={onCancel}>
취소
</Button>
</>
) : (
<>
<Button
type="button"
size="sm"
variant="outline"
disabled={rowDisabled}
onClick={onStartEdit}
>
수정
</Button>
<Button
type="button"
size="sm"
variant="outline"
color="red"
disabled={rowDisabled}
onClick={onDelete}
>
삭제
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
);
}
export { SortableAplnRow };
@@ -0,0 +1 @@
export { ElaprTmpltList } from "./ui/elapr-tmplt-list";
@@ -0,0 +1,57 @@
"use client";
import { createColumnHelper } from "@tanstack/react-table";
import { Button } from "@common/components";
import type { ElaprTmplt } from "@/entities/elapr";
const columnHelper = createColumnHelper<ElaprTmplt>();
interface CreateTmpltColumnsOptions {
onEdit: (row: ElaprTmplt) => void;
onDelete: (row: ElaprTmplt) => void;
}
export function createTmpltColumns({ onEdit, onDelete }: CreateTmpltColumnsOptions) {
return [
columnHelper.accessor("elaprTmpltId", {
header: "전자결재서식아이디",
meta: { align: "left" },
}),
columnHelper.accessor("elaprTmpltNm", {
header: "결재서식명",
meta: { align: "left" },
}),
columnHelper.accessor("docSeComNm", {
header: "문서구분",
cell: ({ getValue, row }) => getValue() ?? row.original.docSeComCd ?? "—",
}),
columnHelper.accessor("regDt", {
header: "등록일시",
cell: ({ getValue }) => getValue() ?? "—",
}),
columnHelper.display({
id: "actions",
header: "관리",
cell: ({ row }) => (
<div
className="flex justify-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Button type="button" size="sm" variant="outline" onClick={() => onEdit(row.original)}>
수정
</Button>
<Button
type="button"
size="sm"
variant="outline"
color="red"
onClick={() => onDelete(row.original)}
>
삭제
</Button>
</div>
),
}),
];
}
@@ -0,0 +1,143 @@
"use client";
import { useMemo, useState } from "react";
import { getCoreRowModel, type RowSelectionState } from "@tanstack/react-table";
import { Button } from "@common/components";
import {
useCreateElaprTmplt,
useDeleteElaprTmplt,
useElaprTmpltPage,
useUpdateElaprTmplt,
type ElaprTmplt,
} from "@/entities/elapr";
import {
ElaprTmpltFormDialog,
type ElaprTmpltFormValues,
} from "@/features/elapr-tmplt-dialog";
import { useDataTable } from "@/shared/lib";
import { createTmpltColumns } from "../model/columns";
interface ElaprTmpltListProps {
selectedTmpltId: string | null;
onSelectTmplt: (tmplt: ElaprTmplt | null) => void;
}
function ElaprTmpltList({ selectedTmpltId, onSelectTmplt }: ElaprTmpltListProps) {
const [dialogMode, setDialogMode] = useState<"create" | "edit" | null>(null);
const [editingTmplt, setEditingTmplt] = useState<ElaprTmplt | null>(null);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const tmpltQuery = useElaprTmpltPage({
page: 0,
size: 50,
sort: "regDt,desc",
});
const createMutation = useCreateElaprTmplt();
const updateMutation = useUpdateElaprTmplt();
const deleteMutation = useDeleteElaprTmplt();
const columns = useMemo(
() =>
createTmpltColumns({
onEdit: (row) => {
setEditingTmplt(row);
setDialogMode("edit");
},
onDelete: async (row) => {
if (!window.confirm("선택한 결재서식을 삭제하시겠습니까?")) return;
await deleteMutation.mutateAsync(row.elaprTmpltId);
if (selectedTmpltId === row.elaprTmpltId) {
onSelectTmplt(null);
}
},
}),
[deleteMutation, onSelectTmplt, selectedTmpltId],
);
const { tableComponent } = useDataTable({
query: tmpltQuery,
tableOptions: {
columns,
state: { rowSelection },
enableRowSelection: true,
enableMultiRowSelection: false,
getRowId: (row) => row.elaprTmpltId,
onRowSelectionChange: (updater) => {
const next =
typeof updater === "function" ? updater(rowSelection) : updater;
setRowSelection(next);
const selectedId = Object.keys(next)[0];
if (!selectedId) {
onSelectTmplt(null);
return;
}
const selected = tmpltQuery.data?.content.find(
(t) => t.elaprTmpltId === selectedId,
);
onSelectTmplt(selected ?? null);
},
getCoreRowModel: getCoreRowModel(),
},
dataTableOptions: {
variant: "line",
onRowClick: (row) => {
setRowSelection({ [row.elaprTmpltId]: true });
onSelectTmplt(row);
},
},
});
const handleFormSubmit = async (values: ElaprTmpltFormValues) => {
if (dialogMode === "create") {
await createMutation.mutateAsync({
elaprTmpltId: values.elaprTmpltId,
elaprTmpltNm: values.elaprTmpltNm,
docSeComCd: values.docSeComCd || undefined,
elaprTmpltExpln: values.elaprTmpltExpln || undefined,
});
} else if (dialogMode === "edit" && editingTmplt) {
await updateMutation.mutateAsync({
elaprTmpltId: editingTmplt.elaprTmpltId,
body: {
elaprTmpltNm: values.elaprTmpltNm,
docSeComCd: values.docSeComCd || undefined,
elaprTmpltExpln: values.elaprTmpltExpln || undefined,
},
});
}
setDialogMode(null);
setEditingTmplt(null);
};
return (
<section className="rounded-lg border border-border bg-background p-4">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">전자결재선 관리 목록</h3>
<Button type="button" onClick={() => setDialogMode("create")}>
서식 등록
</Button>
</div>
{tableComponent}
{dialogMode && (
<ElaprTmpltFormDialog
mode={dialogMode}
open={Boolean(dialogMode)}
onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
setEditingTmplt(null);
}
}}
initial={dialogMode === "edit" ? editingTmplt : null}
isSubmitting={createMutation.isPending || updateMutation.isPending}
onSubmit={handleFormSubmit}
/>
)}
</section>
);
}
export { ElaprTmpltList };