MOCA React 사용자·개발자 가이드

v2.31 · 2026-08-22 — MOCA 프레임워크의 React 구현체. API 낱낱의 목록은 moca-react-api.html(카탈로그 생성 문서)를 본다. 이 가이드는 "어떻게 쓰고, 어떻게 만드는가"를 설명한다.
차례

U1. 화면 구성 — 상단 메뉴 · 좌측 메뉴 · 작업 영역

화면은 세 부분으로 나뉜다.

영역역할
상단 메뉴바1단 메뉴(그룹). 대시보드 · 업무 · 샘플처럼 큰 분류를 고른다. 지금 선택된 그룹은 진하게 표시된다. 오른쪽의 메뉴 검색칸으로 그룹을 거치지 않고 화면을 바로 찾아 열 수도 있다(U2).
좌측 메뉴2단 메뉴(화면). 상단에서 고른 그룹에 속한 화면 목록. 클릭하면 작업 탭으로 열린다. 이미 열려 있는 화면은 파란색으로 표시된다.
작업 영역열린 화면들이 탭으로 쌓이는 곳(MDI). 탭 전환·닫기·화면분할이 여기서 이뤄진다.

로그인 직후에는 샘플 › 가상 그리드 화면이 자동으로 열려 있다(상단 메뉴도 샘플로 맞춰진다) — 빈 작업 영역 대신 바로 볼 것을 주기 위해서다. 이 탭을 닫으면 그대로 닫힌 채 남고, 다시 로그인하면 또 열린다.

U2. 메뉴 사용법 — 화면 열기와 접기/펼치기

U3. 작업 탭(MDI) — 전환 · 닫기 · 화면분할 · 드래그 이동

U4. 그리드 — 스크롤 · 행 선택 · 찾기 · 툴바

U5. 테마

좌측 메뉴 맨 위의 [테마]에서 고른다(MOCA 헤더의 테마 선택 대응 — 여기 헤더는 48px 이라 모바일에서 자리가 없어 메뉴 위로 옮겼다). 고르면 즉시 바뀌고 새로고침해도 유지된다.

선택동작
🖥️ 시스템(기본)OS 의 라이트/다크 설정을 따라간다. OS 설정을 바꾸면 화면도 따라 바뀐다.
☀️ 라이트Warm Paper — OS 가 다크여도 라이트로 고정.
🌙 다크Technical Dark(MOCA demo 기본) — OS 가 라이트여도 다크로 고정.

MOCA 의 고대비는 아직 없다 — 팔레트가 라이트·다크 두 벌뿐이라, 고대비를 넣으려면 컴포넌트마다 세 번째 토큰 세트를 더해야 한다.

U6. 모바일(좁은 화면) — 햄버거 메뉴 · 탭바 없음

가로 768px 이하에서는 MOCA 데모와 같은 규칙으로 화면이 바뀐다(MOCA mobile.css 이식). 폭에만 반응하므로 PC 브라우저 창을 좁혀도 똑같이 동작한다.

바뀌는 것동작
메뉴상단 1단 메뉴가 사라지고 헤더 왼쪽에 햄버거 버튼이 생긴다. 누르면 좌측 메뉴가 화면 위로 미끄러져 나오며(드로어), 그 안에 1단(그룹)과 2단(화면)이 함께 들어 있다 — MOCA 의 좌측 메뉴 트리와 같은 모양이 된다.
메뉴 검색상단 메뉴바에 있던 검색칸이 드로어 맨 위로 들어간다(테마 선택과 같은 자리). 좁은 폭에서는 헤더에 햄버거·로고·로그아웃까지 있어 자리가 없기 때문이고, 동작은 데스크톱과 같다 — 고르면 화면이 열리면서 드로어가 닫힌다(U2).
드로어 닫기화면을 하나 고르면 자동으로 닫힌다. 어두운 배경을 누르거나 ESC 로도 닫힌다. 그룹만 바꾸는 것은 닫지 않는다 — 그룹을 고른 뒤 그 안의 화면을 이어서 고르기 때문.
작업 탭탭바가 통째로 사라진다. 170px 짜리 탭은 두어 개만 열려도 좁은 폭을 다 먹는다. 화면 전환은 메뉴로 한다 — 이미 열려 있는 화면을 메뉴에서 고르면 새로 열리지 않고 그 화면이 앞으로 나온다. 분할·전체닫기 버튼도 함께 사라진다.
화면분할분할해 둔 채로 창을 좁히면 좌우가 아니라 위아래로 나뉜다. 패널 폭 조절 바는 사라진다.
화면 골격레이아웃의 칸이 방향과 무관하게 세로로 쌓인다(D7).
그리드 스크롤표 위를 손가락으로 밀면 스크롤된다(U4). 폭이 아니라 입력 방식(손가락 여부)으로 켜지므로, 폭이 넓은 태블릿에서도 동작한다.
달력입력칸 옆에 붙는 작은 팝업 대신 화면 가운데 큰 달력으로 열린다(뒤 배경은 어두워진다). 날짜 칸이 손가락 크기로 커지고, 기간 달력은 시작·종료 두 판이 세로로 쌓인다. 일시(시각까지) 타입의 시·분 고르는 칸도 줄 폭을 나눠 크게 열린다(초는 고르지 않는다).
기타사용자 이름과 메뉴 접기 버튼은 표시하지 않는다. 화면 높이는 svh 기준이라 모바일 브라우저 주소창에 아래가 잘리지 않는다.

화면 닫기 — 탭바가 없어도 닫을 수 있다. 좌측 메뉴에서 열려 있는 화면 오른쪽 끝의 × 를 누르면 확인을 한 번 물은 뒤 그 화면이 닫히고 강조 배경도 함께 사라진다(U2). MOCA 데모는 모바일에서 닫을 수단이 없는데, 그건 불편해서 React 판에 더한 것이다.

D1. 빠른 시작

cd C:\_teammoca_projects\eclipse\worksapce\moca_react
npm install        # 처음 받았을 때만
npm run dev        # → http://localhost:5173
명령설명
npm run devVite 개발 서버 (HMR)
npm run build프로덕션 빌드
npm run lintoxlint
npm run apidocdocs/catalog.js → docs/moca-react-api.html 재생성
데이터를 보려면 MOCA 서버도 함께 띄운다 — 화면들이 MOCA 서버(8080)의 공용 쿼리 API 로 실 데이터를 조회한다(D4). MOCA2026PORTABLE 에서 powershell -ExecutionPolicy Bypass -File tools\mvn.ps1 spring-boot:run 실행 후 React 앱에서 moca / moca 로 로그인한다.

D2. 프로젝트 구조와 폴더별 책임

moca_react/
├─ src/                       ← 데모 앱 (Vite + React 19) — 배포 안 됨
│  ├─ App.jsx                ←   셸: 상단 1단 메뉴 + 좌측 2단 메뉴 + MocaReactMdi
│  └─ screens/               ←   화면들 + registry.jsx(메뉴 트리 = 화면 등록처)
├─ packages/                  ← ★ 배포 단위 (npm workspaces 링크 — 수정 즉시 반영)
│  ├─ moca-react-grid/        ←   @teammoca/react-grid
│  ├─ moca-react-mdi/         ←   @teammoca/react-mdi
│  └─ …                      ←   layout · popup · msgbox · 달력 · 차트 · 검색콤보 …
├─ docs/                      ← catalog.js(API 단일 선언부) · moca-react-api.html(생성) · 이 가이드
├─ vite.config.js             ← /common → MOCA 서버(8080) 프록시 (개발용, D4)
├─ tools/apidoc-gen.cjs       ← API 문서 생성기
└─ .claude/skills/            ← 컴포넌트·화면 작업용 스킬 (grid · mdi · layout · screen …)
패키지 공개 API 는 각 패키지 src/index.js 한 곳으로만 노출한다. 데모 앱도 사용자와 같은 방식(import { MocaReactGrid } from '@teammoca/react-grid')으로 쓴다.

D3. 새 화면 만들기

frame 은 없다 — MOCA 의 frame(별도 소스·스코프 화면 영역)이 하던 일은 React 에선 컴포넌트가 그대로 한다: 재사용 조각 = 일반 컴포넌트, 스코프 격리 = 인스턴스별 state/ref(구조적 해결), 부모↔자식 통신 = props/콜백, src 지연 로드 = lazy, 오류 격리 = ErrorBoundary(셸이 탭마다 자동).
  1. 화면 컴포넌트 작성 — src/screens/이름.jsx (+ 필요 시 같은 이름의 .css). 화면 루트는 부모(MDI 패널)를 채우게 만든다. 그리드가 주인 화면이면 height 를 주지 않는다(부모 100% 반응형).
  2. 레지스트리 등록 — src/screens/registry.jsx 에 lazy 선언 한 줄 + 그룹 children 등록 한 줄. 등록만 하면 좌측 메뉴에 자동으로 나타나고 MDI 탭으로 열린다. App.jsx 는 건드리지 않는다.
    const UserList = lazy(() => import('./UserList'))   // 탭을 처음 열 때 청크 로드
    { key: 'userlist', title: '사원 목록', icon: '👥', component: UserList }
    화면은 전부 lazy 로딩이다(MOCA frame 의 화면 지연 로드 대응) — 빌드 시 화면별 청크로 잘리고, 그 화면만 쓰는 라이브러리(차트의 echarts 등)도 함께 분리된다. 새 1단 그룹이 필요하면 menus 배열에 { key, title, icon, children: [...] } 를 추가한다.
  3. (선택) 로그인 직후 열 화면 바꾸기 — src/App.jsx 맨 위의 INITIAL_SCREEN 한 줄만 고친다. 레지스트리의 그룹 key · 화면 key 로 가리키며, null 이면 빈 화면으로 시작한다.
    const INITIAL_SCREEN = { group: 'sample', screen: 'gridtest' }
    여는 시점은 auth.state 가 'in' 이 되는 순간뿐이다 — 탭이 비었는지로 판단하면 사용자가 닫을 때마다 되살아나 싸우게 된다.
  4. 검증 — dev 서버에서 메뉴 클릭 → 탭 열림 → 닫기/재오픈 확인. (D15)

공통 조각(com/)과 menuInfo — MOCA frame/COM_* 대응

여러 화면이 같이 쓰는 조각(MOCA 의 /ui/com/COM_TITLE.html 류)은 src/screens/com/ 폴더의 일반 컴포넌트로 만든다. 부모와의 통신은 $p.getParent() 대신 props/콜백 — 부모가 필요한 통로만 내려준다.

// 셸이 모든 화면에 menuInfo 를 자동 주입한다 (MOCA frame 의 getParameter() 대응)
function SaleAgg({ menuInfo }) {
  return (
    <MocaReactLayout layout="vfit:auto">
      <ComTitle menu={menuInfo} onRefresh={fnSearch} />  {/* [ID]·아이콘·제목·브레드크럼 */}
      ...
    </MocaReactLayout>
  )
}

이 골격은 모든 화면에 예외 없이 적용한다 — 루트 div(패딩) → MocaReactLayout → ComTitle + 내용. ComTitle 이 빠지면 그 화면만 제목·경로가 없고, MocaReactLayout 이 빠지면 골격 미리보기 FAB(Alt+L)도 붙지 않는다. ComTitle 을 넣으면 칸이 하나 늘므로 layout 토큰도 함께 고친다(vfit:auto → vfit:fit:auto) — 토큰 개수와 자식 개수는 항상 같아야 한다.

브레드크럼은 상위 경로만 보여준다(🏠 › 샘플). 마지막 칸은 현재 화면이고 그 이름은 바로 옆 제목에 이미 적혀 있어서, 두 번 쓰면 좁은 폭에서 자리만 먹는다. menuInfo.path 에는 셸이 [그룹, 화면] 을 넣고 ComTitle 이 마지막 하나를 뺀다 — 직접 넘길 때도 "현재 화면까지 포함한 전체 경로"를 주면 된다.

화면 렌더링 오류는 셸의 ErrorBoundary 가 탭 단위로 격리한다 — 한 화면이 죽어도 그 탭에만 오류 안내가 뜨고 셸·다른 탭은 산다(MOCA frame 의 오류 경계 대응). 화면 코드 0줄.

key 는 전역 유일해야 한다 — 같은 key 의 탭은 중복으로 열리지 않고 활성화만 된다. 기존 예: UserList.jsx(검색+그리드), GridTest.jsx(10만 행 대용량).

D4. 서버 연동 — 실 데이터 조회·저장 (tran)

데이터는 MOCA 서버(Spring Boot 8080)의 공용 쿼리 API 를 그대로 쓴다 — 별도 백엔드를 만들지 않는다. 화면은 SQL 을 모르고 queryId 만 알며, SQL 은 서버 매퍼 (MOCA2026PORTABLE/src/main/resources/mapper/MocaMapper.xml)에 있다. MOCA 와 같은 계약이다.

React(5173) ──/common/*── Vite 프록시 ──> MOCA Spring Boot(8080) ──> SQLite (data/moca.db)
프록시가 핵심 — 브라우저에 전부 5173 동일 출처로 보이게 해야 세션(JSESSIONID)· CSRF 쿠키가 동작한다(교차 출처로 직접 호출하면 막힌다). 설정은 vite.config.js. 운영은 dist 를 같은 WAS 에 올려 자연히 동일 출처가 되므로 프록시는 개발용이다.

서버 기동과 로그인

# MOCA2026PORTABLE 에서
powershell -ExecutionPolicy Bypass -File tools\mvn.ps1 spring-boot:run   # → 8080, demo DB(SQLite) 준비

서버는 기본 차단(default deny) 정책이라 로그인 세션이 없으면 조회도 거부한다(401). 셸이 부팅 시 tran.sessionCheck() 로 확인해 미로그인이면 ComLogin 화면만 보여주고, 통신 중 401 을 받으면 다시 로그인 화면으로 돌아간다. 데모 계정은 moca / moca.

화면에서 데이터 쓰기

import { useSelect, tran } from '@teammoca/react-tran'

function SaleAgg({ menuInfo }) {
  const [cond, setCond] = useState({ REGION_NM: '' })

  // 선언적 조회 — cond 가 바뀌면 자동 재조회(SQL 의 <if> 절이 조건을 받는다)
  const { rows, loading, error, reload } = useSelect('selectTdemoSaleDtlList', cond)

  return <MocaReactGrid columns={cols} data={rows} subtotal="REGION_NM" />
}

// 저장은 명령형 — 행상태(C/U/D)를 서버가 분기 실행한다(그리드 행상태와 같은 규약)
await tran.save({ insertQueryId: 'insertDemoUser', updateQueryId: 'updateDemoUser', list: rows })

전체 API 는 API 문서의 tran 절.

D5. MocaReactGrid 사용

import { MocaReactGrid } from '@teammoca/react-grid'

<MocaReactGrid
  label="사원 목록"                 // 지정하면 툴바(제목+찾기·전체화면) 표시 — 건수는 푸터
  columns={[{ key: 'name', title: '이름', width: 120 }, { key: 'dept', title: '부서' }]}
  data={rows}                        // 셀 값은 row[key]
  onRowSelected={(row, idx) => ...}  // idx 는 data 기준 절대 인덱스
  search rowHeightSelect             // 자체 기능: 찾기 · 행높이 셀렉트
/>

트리 컬럼 (cellType: 'tree')

MOCA data-m-celltype="tree" 의 React 판. 별도 트리 데이터 구조가 필요 없다 — SQL 에서 ORDER BY PATH 한 줄로 정렬해 내려준 평면 목록에 깊이 컬럼(1부터)만 있으면 그 컬럼이 트리로 그려진다. 부모/리프는 "다음 행의 깊이"로 판정한다(다음 행이 더 깊으면 부모).

const columns = [
  // MOCA 속성 대응: cellType=data-m-celltype · levelKey=data-m-levelid
  //                labelKey=data-m-labelid · treeKey=data-m-treeid · title=data-m-name
  { key: 'TREE', title: '메뉴', cellType: 'tree',
    levelKey: 'DEPTH', labelKey: 'MENU_NM', treeKey: 'MENU_ID', width: 240 },
  { key: 'MENU_ID', title: '메뉴ID', width: 80 },
]
// data 는 PATH 정렬 평면 배열:
// { MENU_NM:'시스템관리', DEPTH:1, PATH:'0001' }
// { MENU_NM:'메뉴관리',   DEPTH:2, PATH:'0001-0005' }
// { MENU_NM:'메뉴등록',   DEPTH:3, PATH:'0001-0005-0012' }

소계/합계 (subtotal · total · calc) 와 컬럼 병합 (merge)

MOCA data-m-calc/subtotal/total/colmerge 의 React 판 (TPL018 판매실적 대응). 그룹 컬럼으로 정렬된 평면 데이터(SQL ORDER BY 지역, 분류)면 소계/합계가 그대로 나온다.

const comma = (v) => Number(v).toLocaleString()
const columns = [
  { key: 'REGION_NM', title: '지역', merge: true, align: 'center' },  // 세로 병합
  { key: 'PROD_NM',   title: '상품명' },
  { key: 'SALE_QTY',  title: '수량',     calc: 'sum', align: 'right', format: comma },
  { key: 'SALE_AMT',  title: '판매금액', calc: 'sum', align: 'right', format: comma },
]
<MocaReactGrid columns={columns} data={rows}
  subtotal="REGION_NM"     // 지역이 바뀔 때마다 "서울 소계" 행 삽입
  total="bottom"           // 합계행 맨 아래 고정 (top = 위 고정, false = 없음)
/>

정렬 · 필터 (MOCA doSort/doFilter 대응)

<MocaReactGrid columns={columns} data={rows} sortable filterable />

// 컬럼별로 끄고 켜기 — 컬럼 값이 그리드 값보다 우선
const columns = [
  { key: 'EMP_NO', title: '사번', filterable: false },   // 값이 다 달라 필터가 무의미
  { key: 'DEPT_NM', title: '부서' },                     // 그리드 설정 그대로 둘 다 켜짐
  { key: 'MEMO', title: '비고', sortable: false },
]

props 전체 목록·설명은 API 문서의 MocaReactGrid 절.

D6. MocaReactMdi 사용

import { MocaReactMdi } from '@teammoca/react-mdi'

const [tabs, setTabs] = useState([])   // 탭 목록은 부모가 소유
const mdiRef = useRef(null)

<MocaReactMdi
  ref={mdiRef}
  tabs={tabs}
  onTabClose={(key) => setTabs((ts) => ts.filter((t) => t.key !== key))}
  emptyHint="좌측 메뉴에서 화면을 선택하면 탭으로 열립니다"
/>

mdiRef.current?.activate('userlist')   // 이미 열린 탭 활성화 (새 탭은 자동 활성화)

전체 API 는 API 문서의 MocaReactMdi 절.

D7. MocaReactLayout — 화면 골격 (분할·반응형)

MOCA layout 컴포넌트의 React 판 — 모든 화면은 MocaReactLayout 으로 시작하고, layout 속성 하나로 분할이 끝난다. 방향(v=세로 스택 / h=가로 분할) 뒤에 토큰을 자식 개수만큼 : 로 나열한다(개수 제한 없음, 생략 시 균등, 통짜는 v1).

import { MocaReactLayout } from '@teammoca/react-layout'

// 가장 흔한 골격 — 검색영역(fit) + 본문(auto). 본문은 h3:7 중첩(목록:상세)
<MocaReactLayout layout="vfit:auto">
  <div>검색영역</div>
  <MocaReactLayout layout="h3:7" ref={bodyRef}>
    <MocaReactGrid ... />
    <MocaReactGrid ... />
  </MocaReactLayout>
</MocaReactLayout>

bodyRef.current?.setRatio('h0:10')   // 런타임 변경 — 좌측 접기(0 = 칸 접힘)
토큰의미전형적 용도
숫자상대 비율(flex-grow) — 합이 10일 필요 없다(2:8 = 1:4). 0 = 칸 접힘h3:7 목록:상세
숫자px고정 크기(주축 — h는 폭, v는 높이)h240px:auto 사이드바 고정
fit내용 크기만큼(hug)vfit:auto 검색영역+본문 · vfit:auto:fit 팝업 골격
auto남은 공간 전부(내부적으로 grow 1)px/fit 고정 칸과 짝

전체 API 는 API 문서의 MocaReactLayout 절.

D8. MocaReactFloatingButton — 떠 있는 액션 버튼(FAB)

MOCA floatingButton 의 React 판 — 화면 위에 떠 있는 원형/알약형 액션 버튼. 레이아웃의 골격 미리보기 버튼(D6)도 이 컴포넌트로 만들어져 있다.

import { MocaReactFloatingButton } from '@teammoca/react-floating-button'

<MocaReactFloatingButton onClick={fnNew} title="신규" />               // plus 원형(우하단 fixed)
<MocaReactFloatingButton label="저장" icon="💾" onClick={fnSave} />    // 알약형
<MocaReactFloatingButton absolute position="rt" icon="✏️"
  bgColor="#b4493e" onClick={fnEdit} />                                // 부모 기준 + 색 지정

전체 API 는 API 문서의 MocaReactFloatingButton 절.

D9. 달력 4종 — 날짜 · 기간 · 붙박이 · 일정

MOCA inputCalendar(데모 TPL042)의 React 판 — 입력칸 + 달력 팝업. dateType 하나가 "무엇을 고르는 달력인가"를 정하면 값 자릿수 · 팝업 모드 · 기본 표시형식이 전부 거기서 파생된다.

import { MocaReactInputCalendar, mdate } from '@teammoca/react-input-calendar'

<MocaReactInputCalendar displayFormat="####-##-##" defaultValue="오늘"
  onDateSelected={(v) => fnSearch(v)} />      {/* v = '20260815' — 숫자문자열 */}

<MocaReactInputCalendar dateType="yyyyMM" defaultValue="오늘" />   {/* 값 6자리, 월 목록 */}
<MocaReactInputCalendar dateType="yyyy" defaultValue="3년전" />    {/* 값 4자리, 연 목록 */}
<MocaReactInputCalendar dateType="yyyyMMdd hh:mm:ss" defaultValue="오늘" />
dateType고르는 것값팝업
yyyy연4자리연 목록(연대 10년)
yyyyMM연월6자리월 목록(12개월)
yyyyMMdd (기본)일자8자리일자 격자
yyyyMMddHHmm일시(분)12자리일자 격자 + 시·분 → [확인]
yyyyMMdd hh:mm:ss일시(초)14자리일자 격자 + 시·분·초 → [확인]

전체 API 는 API 문서의 MocaReactInputCalendar 절.

기간 선택 — MocaReactMultiCalendar (MOCA inputMultiCalendar · TPL043)

한 컴포넌트가 두 칸을 관리한다 — 입력칸 두 개(시작·종료)와 팝업의 달력 두 판(좌 = 시작, 우 = 종료). dateType 계약은 단일 달력과 같고(같은 코어를 공유한다), 기간이라서 더해지는 것은 셋이다: 빠른선택 · 최대 기간 · from>to 보정.

import { MocaReactMultiCalendar, mdate } from '@teammoca/react-input-calendar'

<MocaReactMultiCalendar displayFormat="####-##-##" defaultValue="당월"
  onTermSelected={({ from, to }) => fnSearch(from, to)} />

<MocaReactMultiCalendar defaultValue="금주" maxTermByDay={31}
  quickItems="오늘,금주,당월,전월,당분기,당년" />      {/* 팝업 왼쪽 버튼 목록 */}

<MocaReactMultiCalendar dateType="yyyyMM" defaultValue="당년" />   {/* 연월 기간 */}

term.current.setMultiCalendar({ from: '20260801', to: '20260831' })
term.current.getTerm()   // { from: '20260801', to: '20260831' }

전체 API 는 API 문서의 MocaReactMultiCalendar 절.

붙박이 달력 — MocaReactCalendar (MOCA calendar · TPL044)

팝업이 아니라 화면에 늘 펼쳐진 달력이다 — 고른 날짜를 달력 위에서 눈으로 확인하는 것이 목적(예약일·근태·마감일 화면). 제목을 누르면 한 단계 위로 올라가고(일→월→연), 위 뷰에서 칸을 고르면 내려온다 — 먼 날짜로 빠르게 이동하는 통로다.

import { MocaReactCalendar, mdate } from '@teammoca/react-input-calendar'

// 공휴일·휴무는 컴포넌트가 모른다 — 화면이 조회해 dayInfo 로 넘긴다
const [dayInfo, setDayInfo] = useState({})

<MocaReactCalendar ref={cal} dateType="yyyyMMdd"
  dayInfo={dayInfo}      // { '20260815': { holi: '광복절', off: true, badge: '휴' } }
  weekendOff legend
  onSelect={(v) => console.log(v, cal.current.getDayInfo(v))}
  onViewChange={(ym, view) => { if (view === 'day') loadHoli(ym) }} />

// 예약 가능일처럼 구간이 런타임에 정해지는 화면
cal.current.setDateRange(mdate.getToday(), mdate.addDay(mdate.getToday(), 30))

전체 API 는 API 문서의 MocaReactCalendar 절.

일정 달력 — MocaReactScheduleCalendar (MOCA scheduleCalendar · TPL041)

앞의 셋이 날짜를 고르는 컴포넌트라면 이쪽은 일정을 보여주는 컴포넌트다 (별도 패키지 @teammoca/react-schedule-calendar). 화면이 하는 일은 셋뿐이다 — ① 월이 바뀌면 그 달 조회 ② 아이템으로 매핑해 넘기기 ③ 일자 클릭 시 getDayItems. 연속일정 전개·정렬·바 그리기·공휴일/날씨 표기·좌우 스와이프는 컴포넌트가 한다.

import { MocaReactScheduleCalendar } from '@teammoca/react-schedule-calendar'

<MocaReactScheduleCalendar ref={scc}
  schedules={schedules}   // [{ start, end, title, ico, done, crucial, bg, fg, mark, time, seq, data }]
  dayInfo={dayInfo}       // { '20260815': { holi: '광복절', wthr: '☀️', badges: ['휴'] } }
  hideOutside todayFlag menuButton
  onMonthChange={(ym) => loadMonth(ym)}       // 첫 조회는 화면이 getYm() 으로 직접
  onDayClick={(day) => setPicked(scc.current.getDayItems(day.ymd))}
  dayRenderer={(day) => day.dow === 0 ? <span>휴일</span> : null} />

전체 API 는 API 문서의 MocaReactScheduleCalendar 절.

D10. 메시지박스 — alert · error · confirm · 진행 모달

MOCA moca.$g.alert/error/confirm(데모 TPL023)의 React 판. MOCA 처럼 화면 어디서나 명령형으로 부르고, React 답게 await 로 답을 받을 수도 있다. 실제 렌더는 앱 루트에 한 번 놓는 <MocaReactMsgHost /> 가 맡는다.

import { msgbox, MocaReactMsgHost } from '@teammoca/react-msgbox'

<MocaReactMsgHost />                      {/* 앱 루트에 한 번 — 이미 App.jsx 에 있다 */}

msgbox.alert('저장되었습니다.')                             // 알림(! 아이콘)
msgbox.error('삭제할 수 없는 데이터입니다.')                  // 오류(danger + ✕)
msgbox.confirm('저장하시겠습니까?', () => save())            // MOCA 호환 콜백

if (await msgbox.confirm('정말 삭제하시겠습니까?')) remove()   // 답을 그 자리에서 받는다
const mode = await msgbox.confirm('어디에 추가할까요?', { radios: [
  { value: 'child',   label: '하위 메뉴로 추가', desc: '아래 단계(자식)로 생성됩니다.' },
  { value: 'sibling', label: '같은 레벨로 추가', desc: '동일 단계(형제)로 생성됩니다.' },
] })                                                       // 선택값 또는 null(취소)

if (!name) return msgbox.requiredFail('사용자명', () => ref.current.focus())

전체 API 는 API 문서의 msgbox 절.

D11. 팝업 — 화면을 모달로 띄우고 결과 받기

MOCA $p.openPop / $p.getParameter / $p.close(데모 TPL037)의 React 판. 기억할 것은 팝업 3동사뿐이다 — 열기·파라미터·닫기. MOCA 는 팝업 화면을 url 로 지정했지만 React 는 컴포넌트를 그대로 넘긴다 (화면이 곧 컴포넌트이므로 경로 문자열을 거칠 이유가 없고, lazy() 도 그대로 쓸 수 있다). 실제 렌더는 앱 루트에 한 번 놓는 <MocaReactPopupHost /> 가 맡는다.

import { popup } from '@teammoca/react-popup'
import PopItemSearch from './pop/PopItemSearch'

// ① await 로 받기 — 권장. 답에 따라 다음 줄이 갈린다
const row = await popup.open(PopItemSearch, { title: '품목 검색', width: 640, data: { keyword } })
if (row) setItem(row)                       // 취소면 undefined

// ② 콜백 — MOCA openPop 호환. 취소면 콜백이 아예 불리지 않는다
popup.open(PopItemSearch, { title: '품목 검색', callback: (row) => setItem(row) })

팝업 화면은 업무 화면과 똑같이 만든다(레이아웃·그리드 그대로). 다른 점은 두 가지뿐 — 파라미터를 props.data 로 받고, 결과를 props.close() 로 돌려준다.

function PopItemSearch({ data, close }) {
  const [kw, setKw] = useState(data?.keyword ?? '')      // ← MOCA getParameter()

  return (
    <MocaReactLayout layout="vfit:auto:fit">            {/* 검색 · 목록 · 버튼 */}
      ...
      <button onClick={() => close(sel)}>선택</button>   {/* 확정 — 값을 돌려준다 */}
      <button onClick={() => close()}>닫기</button>      {/* 취소 — 값이 없다 */}
    </MocaReactLayout>
  )
}

전체 API 는 API 문서의 popup 절.

D12. MocaReactEchart — 차트 (Apache ECharts 래퍼)

MOCA data-m-type="echart"(plugins/echart.js)의 React 판. 외부 솔루션 연동 규칙은 MOCA 와 동일 — 화면은 option 만 넘기고, option 의 문법은 ECharts 제품 API 그대로다. 래퍼는 내용을 해석하지 않고 인스턴스 관리 (생성·리사이즈·해제)만 담당한다.

import { MocaReactEchart } from '@teammoca/react-echart'

const option = {
  xAxis: { type: 'category', data: ['1월', '2월', '3월'] },
  yAxis: { type: 'value' },
  series: [{ type: 'bar', data: [120, 200, 150] }],
}
<MocaReactEchart option={option} />    // 부모(레이아웃 pane)를 100% 채운다

// 제품 API(dispatchAction·on 등)가 필요하면 ref 로 실제 인스턴스를 받는다
chartRef.current?.getChart((c) => c.on('click', fn))

전체 API 는 API 문서의 MocaReactEchart 절.

D13. MocaReactSearchCombo — 검색형 콤보

MOCA data-m-type="searchCombo"(searchCombo.js)의 React 판. 항목이 수십·수백 건이라 select 로는 훑기 힘든 자리에 쓴다 — 입력칸에 몇 글자 치면 그 글자를 포함한 항목만 남는다.

import { MocaReactSearchCombo } from '@teammoca/react-search-combo'

// 값 고르기 — 제어 컴포넌트(값의 주인은 화면)
<MocaReactSearchCombo list={areas} value={area} onChange={(v) => setArea(v)} />

// 코드까지 검색되게
<MocaReactSearchCombo list={codes} displayFormat="[value] · [label]" width={220} />

// 검색창 용법 — 고르면 입력칸이 비워진다
<MocaReactSearchCombo list={menuList} clearOnSelect placeholder="메뉴 검색" onChange={openScreen} />

전체 API 는 API 문서의 MocaReactSearchCombo 절.

D14. 아키텍처 노트 — 왜 이렇게 만들었나

결정이유
가상 스크롤 = 전용 스크롤바 방식
(MOCA .moca_scrollY_type1 이식)
네이티브 세로 스크롤은 컴포지터가 메인 스레드보다 먼저 픽셀을 밀어 빠른 스크롤 시 빈 화면이 노출된다(flushSync 로도 못 막음). 콘텐츠를 overflow-y: hidden 으로 고정하고 전용 스크롤바가 "어느 행부터 그릴지"만 바꾸면 플래시가 구조적으로 불가능하다. 이 구조를 네이티브 스크롤로 바꾸지 말 것.
스크롤·드래그 리렌더는 flushSync React 스케줄러가 리렌더를 다음 매크로태스크로 미루면 한 프레임 늦게 그려진다. 이벤트 안에서 동기 렌더로 페인트 전에 끝낸다. 제거 금지.
MDI 콘텐츠는 탭별 고정 홀더 + createPortal 콘텐츠를 패널 JSX 안에 렌더하면 패널 이동 = 트리 위치 변경 = 리마운트라 화면 상태가 날아간다. 홀더 div 를 한 번만 만들어 portal 로 렌더하고, 홀더 DOM 만 appendChild 로 패널 간 이동(MOCA 의 DOM 이동과 동일 효과). 이 구조를 바꾸지 말 것.
컬럼 폭은 최초 실측 후 전부 px 고정리사이징이 단순 state 변경이 된다.
가상화 라이브러리 미사용고정 행높이면 직접 구현 ~30줄로 충분, 의존성 0. 가변 행높이 요구가 생기면 그때 검토.
테마는 <html data-theme> 에 해석된 값만 박는다 저장값은 system|light|dark 지만 DOM 에는 light|dark 만 넣는다(src/theme.js) — CSS 가 "지금 어떤 팔레트인가" 한 가지만 보면 되기 때문. 적용은 페인트 전(index.html 인라인 스크립트)이라 라이트로 그려졌다 뒤집히는 번쩍임이 없다. 패키지 CSS 는 다크 토큰을 두 벌 쓴다: @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) … } 와 :root[data-theme="dark"] …. 앞은 data-theme 이 없는 외부 앱이 종전대로 OS 를 따르게 하고, 뒤는 호스트의 선택이 이기게 한다. 두 블록의 값은 같아야 한다 — 한쪽만 고치면 조용히 어긋난다.

상세 이력과 다음 작업 후보는 WORKLOG.md.

D15. 문서·스킬 관리 체계 (skill · catalog · api · guide)

MOCA 원본의 catalog.js / moca-api.html / moca-guide.html 체계를 그대로 가져왔다. 패키지 공개 API 나 화면 등록 규약, 사용자 UX 를 만들거나 고치면 같은 커밋에서 함께 갱신한다:

대상파일성격
Catalogdocs/catalog.js공개 API 단일 선언부 — 규칙은 파일 헤더 주석(공개 API 만 · 한 항목 = API 하나 · '예)' 코드 분리)
API 문서docs/moca-react-api.html전량 생성(npm run apidoc) — 손으로 고치지 않는다
가이드docs/moca-react-guide.html (이 문서)손으로 쓴다 — 사용자 UX 나 개발 절차가 바뀌면 해당 절을 고치고 상단 version/date 메타를 올린다
Skill.claude/skills/*/SKILL.md작업 절차·내부 규칙 — 새 패키지를 만들면 스킬도 같은 꼴로 추가
READMEpackages/*/README.md패키지 단독 배포용 props 문서
API 문서는 카탈로그에서 전량 생성되므로 HTML 을 직접 고치면 다음 생성 때 날아간다. 반대로 이 가이드는 손으로 쓰는 문서다 — 생성기를 만들어 덮어쓰지 말 것.

문서는 앱과 함께 배포된다

vite build 가 docs/*.html 을 dist/docs/ 로 함께 복사한다 (vite.config.js 의 copyDocs 플러그인). 그래서 npm run deploy 한 번이면 앱과 문서가 같이 올라가고, 운영에서는 아래 주소로 열린다 — MOCA 가 문서를 엔진 옆 (/vendor/moca/docs/)에 두는 것과 같은 자리다.

문서주소
사용자·개발자 가이드/system/react/docs/moca-react-guide.html
API 문서/system/react/docs/moca-react-api.html

팀모카 홈페이지(teammoca.co.kr)의 MOCA React 섹션과 문서 섹션이 이 주소로 링크를 건다. 문서 파일명을 바꾸면 홈페이지(moca2026ai 의 system/homepage/index.html)의 링크도 함께 고친다.

D16. 검증 방법