등록 페이지
import React from 'react';
import { ChevronRight, ChevronDown, X, Plus } from 'lucide-react';
interface RegulationItem {
id: string;
content: string;
level: number;
children: RegulationItem[];
}
type RegulationAction =
| { type: 'ADD_ITEM'; parentId: string | null; level: number; index?: number }
| { type: 'DELETE_ITEM'; id: string }
| { type: 'UPDATE_CONTENT'; id: string; content: string }
| { type: 'INDENT_ITEM'; id: string }
| { type: 'OUTDENT_ITEM'; id: string };
const findItemAndPrevSibling = (
items: RegulationItem[],
targetId: string,
parent: RegulationItem | null = null
): [RegulationItem | null, RegulationItem | null, RegulationItem | null] => {
for (let i = 0; i < items.length; i++) {
if (items[i].id === targetId) {
return [items[i], i > 0 ? items[i-1] : null, parent];
}
const [found, prevSibling, foundParent] = findItemAndPrevSibling(items[i].children || [], targetId, items[i]);
if (found) {
return [found, prevSibling, foundParent];
}
}
return [null, null, null];
};
const getLevelUnit = (level: number): string => {
switch(level) {
case 0:
return "장";
case 1:
return "조";
case 2:
return "항";
default:
return "항";
}
};
const RegulationEditor: React.FC = () => {
const [regulations, dispatch] = React.useReducer((state: RegulationItem[] = [], action: RegulationAction): RegulationItem[] => {
switch (action.type) {
case 'ADD_ITEM': {
const newItem: RegulationItem = {
id: crypto.randomUUID(),
content: '',
level: action.level,
children: [],
};
if (!action.parentId) {
if (action.index !== undefined) {
const newState = [...state];
newState.splice(action.index + 1, 0, newItem);
return newState;
}
return [...state, newItem];
}
const addToChildren = (items: RegulationItem[]): RegulationItem[] => {
return items.map(item => {
if (item.id === action.parentId) {
return { ...item, children: [...(item.children || []), newItem] };
}
return { ...item, children: addToChildren(item.children || []) };
});
};
return addToChildren(state);
}
case 'DELETE_ITEM': {
const deleteItem = (items: RegulationItem[]): RegulationItem[] => {
return items.filter(item => item.id !== action.id)
.map(item => ({
...item,
children: deleteItem(item.children || [])
}));
};
return deleteItem(state);
}
case 'UPDATE_CONTENT': {
const updateContent = (items: RegulationItem[]): RegulationItem[] => {
return items.map(item => {
if (item.id === action.id) {
return { ...item, content: action.content };
}
return { ...item, children: updateContent(item.children || []) };
});
};
return updateContent(state);
}
case 'INDENT_ITEM': {
const [targetItem, prevSibling, parent] = findItemAndPrevSibling(state, action.id);
if (!targetItem || !prevSibling || targetItem.level >= 2) return state;
const removeItem = (items: RegulationItem[]): RegulationItem[] => {
return items.filter(item => item.id !== action.id)
.map(item => ({
...item,
children: removeItem(item.children || [])
}));
};
const addToSibling = (items: RegulationItem[]): RegulationItem[] => {
return items.map(item => {
if (item.id === prevSibling.id) {
return {
...item,
children: [...(item.children || []), { ...targetItem, level: item.level + 1 }]
};
}
return {
...item,
children: addToSibling(item.children || [])
};
});
};
return addToSibling(removeItem(state));
}
case 'OUTDENT_ITEM': {
const [targetItem, _, parent] = findItemAndPrevSibling(state, action.id);
if (!targetItem || !parent) return state;
const removeItem = (items: RegulationItem[]): RegulationItem[] => {
return items.filter(item => item.id !== action.id)
.map(item => ({
...item,
children: removeItem(item.children || [])
}));
};
const addAfterParent = (items: RegulationItem[]): RegulationItem[] => {
const result: RegulationItem[] = [];
let added = false;
for (const item of items) {
result.push(item);
if (item.id === parent.id) {
result.push({ ...targetItem, level: item.level });
added = true;
}
}
if (!added) {
return items.map(item => ({
...item,
children: addAfterParent(item.children || [])
}));
}
return result;
};
return addAfterParent(removeItem(state));
}
default:
return state;
}
}, []);
const findItemIndex = (items: RegulationItem[], targetId: string): number => {
for (let i = 0; i < items.length; i++) {
if (items[i].id === targetId) return i;
const index = findItemIndex(items[i].children || [], targetId);
if (index !== -1) return index;
}
return -1;
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, item: RegulationItem) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
const index = findItemIndex(regulations, item.id);
if (item.children?.length > 0) {
if (item.level < 2) {
dispatch({
type: 'ADD_ITEM',
parentId: item.id,
level: item.level + 1
});
}
} else {
dispatch({
type: 'ADD_ITEM',
parentId: null,
level: item.level,
index: index
});
}
} else if (e.key === 'Tab' && item.level < 2) {
e.preventDefault();
dispatch({ type: 'INDENT_ITEM', id: item.id });
} else if (e.key === 'Backspace' && e.currentTarget.value === '') {
e.preventDefault();
const [_, __, parent] = findItemAndPrevSibling(regulations, item.id);
if (parent) {
dispatch({ type: 'OUTDENT_ITEM', id: item.id });
} else {
dispatch({ type: 'DELETE_ITEM', id: item.id });
}
}
};
const RegulationItemComponent: React.FC<{
item: RegulationItem;
index: number;
}> = React.memo(({ item, index }) => {
const [isExpanded, setIsExpanded] = React.useState(true);
const hasChildren = item.children?.length > 0;
const inputRef = React.useRef<HTMLInputElement>(null);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
dispatch({
type: 'UPDATE_CONTENT',
id: item.id,
content: e.target.value
});
};
return (
<div className="relative">
<div className="flex items-center gap-2 mb-2">
<div
className="flex items-center w-full"
style={{ marginLeft: `${item.level * 2}rem` }}
>
<button
type="button"
className={`p-1 ${hasChildren ? 'cursor-pointer' : 'opacity-0'}`}
onClick={() => hasChildren && setIsExpanded(!isExpanded)}
>
{hasChildren ? (
isExpanded ?
<ChevronDown className="w-4 h-4 text-gray-500" /> :
<ChevronRight className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500 opacity-0" />
)}
</button>
<span className="min-w-[4rem] text-sm text-gray-600">
{`${index + 1}${getLevelUnit(item.level)}`}
</span>
<input
ref={inputRef}
type="text"
value={item.content}
onChange={handleChange}
onKeyDown={(e) => handleKeyDown(e, item)}
className="flex-1 px-2 py-1 bg-white border border-gray-300 text-gray-700 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder={`내용을 입력하세요 (${getLevelUnit(item.level)})`}
/>
<button
type="button"
onClick={() => dispatch({ type: 'DELETE_ITEM', id: item.id })}
className="p-1 text-gray-500 hover:text-red-500"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className={`${isExpanded ? 'block' : 'hidden'}`}>
{item.children?.map((child, i) => (
<RegulationItemComponent
key={child.id}
item={child}
index={i}
/>
))}
</div>
</div>
);
});
RegulationItemComponent.displayName = 'RegulationItemComponent';
return (
<div className="p-4 max-w-4xl mx-auto bg-white">
<div className="space-y-2">
{regulations?.map((item, index) => (
<RegulationItemComponent
key={item.id}
item={item}
index={index}
/>
))}
</div>
<div className="mt-4">
<button
type="button"
onClick={() => dispatch({
type: 'ADD_ITEM',
parentId: null,
level: 0
})}
className="p-2 bg-white text-gray-600 border border-gray-300 rounded-full hover:bg-gray-50"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
);
};
export default RegulationEditor;
조회 페이지
import React from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
interface RegulationItem {
id: string;
content: string;
level: number;
children: RegulationItem[];
}
const getLevelUnit = (level: number): string => {
switch(level) {
case 0:
return "장";
case 1:
return "조";
case 2:
return "항";
default:
return "항";
}
};
const RegulationViewer: React.FC<{ data: RegulationItem[] }> = ({ data }) => {
const RegulationItemComponent: React.FC<{
item: RegulationItem;
index: number;
}> = React.memo(({ item, index }) => {
const [isExpanded, setIsExpanded] = React.useState(true);
const hasChildren = item.children?.length > 0;
return (
<div className="relative">
<div
className="flex items-start gap-2"
style={{ marginLeft: `${item.level * 1.5}rem` }}
>
<button
type="button"
className={`p-1 mt-0.5 ${hasChildren ? 'cursor-pointer hover:bg-gray-50 rounded' : 'invisible'}`}
onClick={() => hasChildren && setIsExpanded(!isExpanded)}
>
{hasChildren ? (
isExpanded ?
<ChevronDown className="w-4 h-4 text-gray-400" /> :
<ChevronRight className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<div className="flex-1 py-1">
<div className="flex items-baseline gap-2">
<span className="text-sm text-gray-900 font-medium">
{`제${index + 1}${getLevelUnit(item.level)}`}
</span>
{item.content && (
<span className="text-sm text-gray-600">
{item.content}
</span>
)}
</div>
</div>
</div>
<div className={`${isExpanded ? 'block' : 'hidden'}`}>
{item.children?.map((child, i) => (
<RegulationItemComponent
key={child.id}
item={child}
index={i}
/>
))}
</div>
</div>
);
});
RegulationItemComponent.displayName = 'RegulationItemComponent';
if (!data || data.length === 0) {
return (
<div className="p-4 text-center text-gray-500">
등록된 규정이 없습니다.
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white p-6 rounded-lg">
<div className="space-y-1">
{data.map((item, index) => (
<RegulationItemComponent
key={item.id}
item={item}
index={index}
/>
))}
</div>
</div>
<div className="mt-4 text-right text-sm text-gray-500">
* 클릭하여 세부 내용을 확인할 수 있습니다.
</div>
</div>
);
};
// 사용 예시를 위한 샘플 데이터
const sampleData: RegulationItem[] = [
{
id: '1',
content: '총칙',
level: 0,
children: [
{
id: '1-1',
content: '목적',
level: 1,
children: [
{
id: '1-1-1',
content: '이 규정은 회사의 운영에 관한 기본적인 사항을 정함을 목적으로 한다.',
level: 2,
children: []
}
]
},
{
id: '1-2',
content: '적용범위',
level: 1,
children: [
{
id: '1-2-1',
content: '이 규정은 회사의 모든 임직원에게 적용된다.',
level: 2,
children: []
}
]
}
]
},
{
id: '2',
content: '조직',
level: 0,
children: [
{
id: '2-1',
content: '조직구성',
level: 1,
children: []
}
]
}
];
// 사용 예시
const RegulationViewerExample: React.FC = () => {
return <RegulationViewer data={sampleData} />;
};
export default RegulationViewerExample;