[as] feat: 결재선 관리 페이지 구현
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user