등록 페이지

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;