화면은 세 부분으로 나뉜다.
| 영역 | 역할 |
|---|---|
| 상단 메뉴바 | 1단 메뉴(그룹). 대시보드 · 업무 · 샘플처럼 큰 분류를 고른다. 지금 선택된 그룹은 진하게 표시된다. 오른쪽의 메뉴 검색칸으로 그룹을 거치지 않고 화면을 바로 찾아 열 수도 있다(U2). |
| 좌측 메뉴 | 2단 메뉴(화면). 상단에서 고른 그룹에 속한 화면 목록. 클릭하면 작업 탭으로 열린다. 이미 열려 있는 화면은 파란색으로 표시된다. |
| 작업 영역 | 열린 화면들이 탭으로 쌓이는 곳(MDI). 탭 전환·닫기·화면분할이 여기서 이뤄진다. |
로그인 직후에는 샘플 › 가상 그리드 화면이 자동으로 열려 있다(상단 메뉴도 샘플로 맞춰진다) — 빈 작업 영역 대신 바로 볼 것을 주기 위해서다. 이 탭을 닫으면 그대로 닫힌 채 남고, 다시 로그인하면 또 열린다.
gridtest)나 그룹 이름으로도 걸린다 —
어느 그룹에 있는지 몰라도 이름 몇 글자면 찾을 수 있다.
↓ ↑ 로 고르고 Enter 로 열 수 있으며,
Esc 로 목록을 닫는다. 좁은 화면에서는 검색칸이 햄버거 메뉴 안(U6)으로 들어간다.좌측 메뉴 맨 위의 [테마]에서 고른다(MOCA 헤더의 테마 선택 대응 — 여기 헤더는 48px 이라 모바일에서 자리가 없어 메뉴 위로 옮겼다). 고르면 즉시 바뀌고 새로고침해도 유지된다.
| 선택 | 동작 |
|---|---|
| 🖥️ 시스템(기본) | OS 의 라이트/다크 설정을 따라간다. OS 설정을 바꾸면 화면도 따라 바뀐다. |
| ☀️ 라이트 | Warm Paper — OS 가 다크여도 라이트로 고정. |
| 🌙 다크 | Technical Dark(MOCA demo 기본) — OS 가 라이트여도 다크로 고정. |
MOCA 의 고대비는 아직 없다 — 팔레트가 라이트·다크 두 벌뿐이라, 고대비를 넣으려면 컴포넌트마다 세 번째 토큰 세트를 더해야 한다.
가로 768px 이하에서는 MOCA 데모와 같은 규칙으로 화면이 바뀐다(MOCA mobile.css 이식).
폭에만 반응하므로 PC 브라우저 창을 좁혀도 똑같이 동작한다.
| 바뀌는 것 | 동작 |
|---|---|
| 메뉴 | 상단 1단 메뉴가 사라지고 헤더 왼쪽에 햄버거 버튼이 생긴다. 누르면 좌측 메뉴가 화면 위로 미끄러져 나오며(드로어), 그 안에 1단(그룹)과 2단(화면)이 함께 들어 있다 — MOCA 의 좌측 메뉴 트리와 같은 모양이 된다. |
| 메뉴 검색 | 상단 메뉴바에 있던 검색칸이 드로어 맨 위로 들어간다(테마 선택과 같은 자리). 좁은 폭에서는 헤더에 햄버거·로고·로그아웃까지 있어 자리가 없기 때문이고, 동작은 데스크톱과 같다 — 고르면 화면이 열리면서 드로어가 닫힌다(U2). |
| 드로어 닫기 | 화면을 하나 고르면 자동으로 닫힌다. 어두운 배경을 누르거나 ESC 로도 닫힌다. 그룹만 바꾸는 것은 닫지 않는다 — 그룹을 고른 뒤 그 안의 화면을 이어서 고르기 때문. |
| 작업 탭 | 탭바가 통째로 사라진다. 170px 짜리 탭은 두어 개만 열려도 좁은 폭을 다 먹는다. 화면 전환은 메뉴로 한다 — 이미 열려 있는 화면을 메뉴에서 고르면 새로 열리지 않고 그 화면이 앞으로 나온다. 분할·전체닫기 버튼도 함께 사라진다. |
| 화면분할 | 분할해 둔 채로 창을 좁히면 좌우가 아니라 위아래로 나뉜다. 패널 폭 조절 바는 사라진다. |
| 화면 골격 | 레이아웃의 칸이 방향과 무관하게 세로로 쌓인다(D7). |
| 그리드 스크롤 | 표 위를 손가락으로 밀면 스크롤된다(U4). 폭이 아니라 입력 방식(손가락 여부)으로 켜지므로, 폭이 넓은 태블릿에서도 동작한다. |
| 달력 | 입력칸 옆에 붙는 작은 팝업 대신 화면 가운데 큰 달력으로 열린다(뒤 배경은 어두워진다). 날짜 칸이 손가락 크기로 커지고, 기간 달력은 시작·종료 두 판이 세로로 쌓인다. 일시(시각까지) 타입의 시·분 고르는 칸도 줄 폭을 나눠 크게 열린다(초는 고르지 않는다). |
| 기타 | 사용자 이름과 메뉴 접기 버튼은 표시하지 않는다. 화면 높이는 svh 기준이라 모바일 브라우저 주소창에 아래가 잘리지 않는다. |
화면 닫기 — 탭바가 없어도 닫을 수 있다. 좌측 메뉴에서 열려 있는 화면 오른쪽 끝의 × 를 누르면 확인을 한 번 물은 뒤 그 화면이 닫히고 강조 배경도 함께 사라진다(U2). MOCA 데모는 모바일에서 닫을 수단이 없는데, 그건 불편해서 React 판에 더한 것이다.
cd C:\_teammoca_projects\eclipse\worksapce\moca_react
npm install # 처음 받았을 때만
npm run dev # → http://localhost:5173
| 명령 | 설명 |
|---|---|
npm run dev | Vite 개발 서버 (HMR) |
npm run build | 프로덕션 빌드 |
npm run lint | oxlint |
npm run apidoc | docs/catalog.js → docs/moca-react-api.html 재생성 |
MOCA2026PORTABLE 에서
powershell -ExecutionPolicy Bypass -File tools\mvn.ps1 spring-boot:run 실행 후
React 앱에서 moca / moca 로 로그인한다.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 …)
src/index.js 한 곳으로만 노출한다.
데모 앱도 사용자와 같은 방식(import { MocaReactGrid } from '@teammoca/react-grid')으로 쓴다.src/screens/이름.jsx (+ 필요 시 같은 이름의 .css).
화면 루트는 부모(MDI 패널)를 채우게 만든다. 그리드가 주인 화면이면 height 를 주지 않는다(부모 100% 반응형).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: [...] } 를 추가한다.src/App.jsx 맨 위의 INITIAL_SCREEN
한 줄만 고친다. 레지스트리의 그룹 key · 화면 key 로 가리키며, null 이면 빈 화면으로 시작한다.
const INITIAL_SCREEN = { group: 'sample', screen: 'gridtest' }
여는 시점은 auth.state 가 'in' 이 되는 순간뿐이다 — 탭이 비었는지로 판단하면
사용자가 닫을 때마다 되살아나 싸우게 된다.여러 화면이 같이 쓰는 조각(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만 행 대용량).데이터는 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)
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 })
row.SALE_AMT).list1 언랩은 tran 이 대신한다 —
화면 코드에 없다(MOCA 엔진이 하던 일과 같다).selectTdemoSaleDtlList + 콤보 2종),
대시보드 차트 3종(selectDashSalesMonthly 등), 메뉴 트리(selectDemoMenuDemoList),
사용자 목록(selectDemoUserList),
메뉴관리(조회·콤보 + tran.save 로 등록/수정/삭제 — MOCA TPL019 와 같은 서비스).전체 API 는 API 문서의 tran 절.
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 // 자체 기능: 찾기 · 행높이 셀렉트
/>
height 는 생략이 기본(부모 100% 반응형) — MDI 탭 안에서는 항상 생략.rowStatus={false} rowNum={false} delCheck={false}.data 참조가 바뀌면(재조회) 선택·찾기 캐시가 초기화된다 — MOCA drawGrid 와 동일.rowHeight 기본 26px). 가변 행높이는 미지원.width 를 준 컬럼은 그 폭 고정, 안 준 컬럼이 남는 폭을 나눠 갖는다.
그리드가 넓어지면(팝업이 열리며 커질 때 · 레이아웃 setRatio · MDI 분할 ·
창 크기) 폭 미지정 컬럼이 늘어난 폭을 다시 흡수해 오른쪽에 빈 띠가 남지 않는다.
좁아질 때는 줄이지 않고 가로 스크롤이 생기며(컬럼이 최소폭으로 붕괴하지 않게),
사용자가 헤더 경계를 드래그해 정한 폭은 이후 재배분에서 제외된다.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' }
− 클릭 → 모든 자손이 숨지만 자손의 접힘 상태는 보존된다 —
다시 펼치면 이전 모습 그대로 복원(MOCA 는 하위 부모를 강제로 접는다 — 의도된 개선).
data 가 바뀌면 전체 펼침으로 초기화.data 기준 절대 번호를 유지한다.src/screens/TreeGrid.jsx).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 = 없음)
/>
data 가 바뀔 때마다 전부 재계산된다.
연산은 sum/count/avg/min/max (avg 는 전체 합÷전체 건수 — 평균의 평균 함정 회피, 빈 값은 안 센다).merge: true 컬럼은 연속 같은 값이 병합 표시 — 값은 병합 구간의 첫 표시 행에
그려져 스크롤 중에도 항상 보인다. 집계행이 병합 런을 끊는다. 트리와 집계는 동시 사용 불가.src/screens/SaleAgg.jsx — 합계 아래/위 두 그리드 비교).<MocaReactGrid columns={columns} data={rows} sortable filterable />
// 컬럼별로 끄고 켜기 — 컬럼 값이 그리드 값보다 우선
const columns = [
{ key: 'EMP_NO', title: '사번', filterable: false }, // 값이 다 달라 필터가 무의미
{ key: 'DEPT_NM', title: '부서' }, // 그리드 설정 그대로 둘 다 켜짐
{ key: 'MEMO', title: '비고', sortable: false },
]
data 는 건드리지 않는다. 그리드는 그 위에 "보기"를 얹을 뿐이라
화면이 들고 있는 배열의 순서·내용이 바뀌지 않는다. 정렬·필터가 걸린 상태에서도
onRowSelected(row, idx) 의 idx 는 원본 data 기준이라
data[idx] 가 그대로 맞는다."3,000,000" 처럼 콤마를 찍어 넣으면 자릿수가 달라지는 순간 틀린다
("900,000" > "1,000,000"). 값은 숫자로 두고 format 으로 표시만 가공한다.data 에서 만든다 — 다른 컬럼 필터를 걸어도 후보 목록이 줄지 않는다(MOCA 동일).
고유값이 filterDistinctLimit(기본 1000)을 넘으면 목록 대신 안내를 띄운다.data 참조가 바뀌면(재조회) 정렬·필터·선택·체크가 모두 초기화된다(MOCA filterRemoveAll 동일).src/screens/GridTest.jsx — 10만 행에 정렬·필터를 켜 두었다).props 전체 목록·설명은 API 문서의 MocaReactGrid 절.
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') // 이미 열린 탭 활성화 (새 탭은 자동 활성화)
onTabClose(key) 로 요청만 오고 실제 제거는 부모가 한다.content 는 탭이 닫힐 때까지 마운트 유지(숨김 = display:none) — 화면 상태가 보존된다.resize 이벤트가 발화된다 — 숨김 중 크기가 바뀐 화면(차트 등)은 resize 를 들으면 자동 대응.pinned: true 탭은 닫기 버튼이 없고 전체닫기에서도 제외.activate(key) 가 그대로 맡으므로,
셸이 메뉴에서 activate 만 불러 주면 탭바 없이도 오갈 수 있다.전체 API 는 API 문서의 MocaReactMdi 절.
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 고정 칸과 짝 |
mobileHeights 배열.mrl-fill 표식 — 모바일은 칸을 내용 높이로 푸는데, 내용이 전부 absolute 이거나
캔버스처럼 스스로 높이가 없는 박스는 0 으로 주저앉아 화면 아래가 빈 채로 남는다.
그 박스에 이 클래스를 붙이면 그 칸이 남은 높이를 채운다. 그리드가 든 칸은 자동이라 필요 없다.
<div className="fab-playground mrl-fill">…</div>wireframeButton={false} 로 숨길 수 있다(운영 화면).src/screens/LayoutTest.jsx — setRatio 버튼·Alt+L).전체 API 는 API 문서의 MocaReactLayout 절.
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} /> // 부모 기준 + 색 지정
'plus'(내장 SVG·색 추종) · 이미지 경로(iconMode="mask" 면
currentColor 채움) · 글리프 텍스트(이모지) · ReactNode(React 판 확장).draggable={false} 로 끔.absolute — fixed 대신 부모(positioned ancestor) 기준 절대배치. 레이아웃/컨테이너 안 버튼용.
그 부모는 스스로 높이를 가져야 한다(min-height 등) — 모바일에서는 레이아웃 칸이
내용 높이로 줄어드는데(U6) 상자 안이 전부 absolute 면 높이에 기여하는 내용이 없어 0 이 되고,
그러면 드래그의 상하 이동 여지도 0 이 되어 좌우로만 움직인다. 부모가 버튼보다 작으면
그 축은 화면 기준으로 클램프해 최소한 움직이게는 되어 있지만, 애초에 높이를 주는 것이 맞다.src/screens/FabTest.jsx).전체 API 는 API 문서의 MocaReactFloatingButton 절.
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자리 | 일자 격자 + 시·분·초 → [확인] |
displayFormat 의 # 마스크가 만든다.
setValue 는 타입 자릿수로 맞춘다(넘치면 자르고, 모자라면 월·일 01 / 시각 00).defaultValue="오늘" · "7일전" ·
"3개월전" · "당월" 등(MOCA 와 같은 문법).dateMin/dateMax 밖은 칸·이동버튼·[오늘]이 잠기고
직접 타이핑도 확정 시 거부된다. 연/연월 타입은 "그 값이 덮는 기간이 범위와 겹치는가"로 판정한다
(min 이 03-15 면 연월 2026-03 은 선택 가능).getValue() / setValue(v)(이벤트 미발화) /
setReadOnly(b) / clear() / focus().
날짜 계산은 mdate(MOCA moca.$g.date 대응):
mdate.addDay(v, -7) · getLastDayOfMonth 등.overflow:auto 인 스크롤 영역
안에서도 잘리지 않고, 아래 공간이 없으면 위로 뜬다.src/screens/CalendarTest.jsx).전체 API 는 API 문서의 MocaReactInputCalendar 절.
한 컴포넌트가 두 칸을 관리한다 — 입력칸 두 개(시작·종료)와 팝업의 달력 두 판(좌 = 시작, 우 = 종료). 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' }
onTermSelected 는 [확인] 에서만 발화하고, [취소] 는 팝업을 열 때의 기간으로 되돌린다.
기간은 두 번 골라야 완성되므로, 고를 때마다 확정 이벤트를 쏘면 반쪽 기간으로 조회가 돈다.from>to 로 반대쪽이 따라 바뀐 경우 그 판도 함께 움직인다.
반대로 달만 넘겨 보는 것은 다른 판에 영향이 없다.오늘 · 전일 ·
금주(일~토) · 전주 · 당월(1일~말일) · 전월 ·
당분기 · 전분기 · 당년 · 전년 ·
N일전/개월전/년전(그만큼 전 ~ 오늘). MOCA 와 같은 문구다.maxTermByDay/Month/Year. 넘기면 선택이 거부된다(양 끝 포함:
31이면 8/1~8/31 허용, 9/1 은 거부).getTerm() · getValue()(=from, MOCA 규약) ·
setFrom/setTo/setMultiCalendar(이벤트 미발화) · setReadOnly · clear.src/screens/TermTest.jsx).전체 API 는 API 문서의 MocaReactMultiCalendar 절.
팝업이 아니라 화면에 늘 펼쳐진 달력이다 — 고른 날짜를 달력 위에서 눈으로 확인하는 것이 목적(예약일·근태·마감일 화면). 제목을 누르면 한 단계 위로 올라가고(일→월→연), 위 뷰에서 칸을 고르면 내려온다 — 먼 날짜로 빠르게 이동하는 통로다.
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))
dayInfo({ holi, off, badge, class })만 넘기면
컴포넌트가 그린다. 달을 옮기면 onViewChange 가 발화하므로 그 자리에서 그 달을
다시 조회한다. 첫 조회는 발화하지 않으니 화면이 getViewYm() 으로 직접 한다(MOCA 규약).weekendOff 로 주말을 자동 비영업일 처리하되, dayInfo 의
off:false 가 그보다 우선한다("쉬는 토요일만 예외" 지정 가능).legend 는 지금 달력에 실제로 있는 표시만 설명한다(없는 규칙을 찾게 만들지
않으려는 MOCA 규약). 견본이 실제 칸과 같은 모양이라 빗금이 무슨 뜻인지 화면 안에서 알 수 있다.readOnly 는 선택만 잠근다 — 달 이동·둘러보기는 그대로.dateMin/dateMax, 구간이 다른 값에서 정해지면
setDateRange(min, max)(예약 시작이 "오늘", 입고일 하한이 "발주일"인 경우).readOnly, 화면 코드에서 옮기기만 하려면
ref today()(이동 전용)를 쓴다.src/screens/CalendarInlineTest.jsx).전체 API 는 API 문서의 MocaReactCalendar 절.
앞의 셋이 날짜를 고르는 컴포넌트라면 이쪽은 일정을 보여주는 컴포넌트다
(별도 패키지 @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} />
start~end 를 각 일자로 펼치고 그 날이 단일/시작/중간/종료
중 무엇인지(pos)에 따라 바 모양이 달라진다(시작·단일: 마커+제목 / 종료: 우측정렬 /
중간: 색만). 완료는 배경 없이 취소선.getDayItems(ymd) 가 화면 순서 그대로
돌려주고, 아이템에 담아 보낸 data(원본 행)도 함께 온다. 공휴일·날씨는
getDayInfo(ymd).{ changed, delId } 를 돌려주고 화면이 그때만 다시 그린다 — 그냥 닫으면 아무 일도 없다.src/screens/ScheduleTest.jsx, 팝업은 pop/PopDaySchedule.jsx).전체 API 는 API 문서의 MocaReactScheduleCalendar 절.
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())
\n)만 반영한다.
강조가 필요하면 msgbox.alert(<>성공 <b>12건</b></>) 처럼 노드를 넘긴다
(MOCA 는 innerHTML 이었지만 서버 문장을 그대로 HTML 로 넣던 통로를 막았다 — 의도된 차이).if (!v) return msgbox.requiredFail(...) 패턴이 그대로 동작한다. 문구는
"{label}은(는) 필수입력항목입니다.".showProgress(msg, delay)는 delay(기본 300ms) 안에 작업이
끝나면 아예 뜨지 않는다(짧은 작업의 깜빡임 방지). 단계 표시는
setProgressMsg, 닫기는 통신과 함께 쓸 때 finally 에서
hideProgress().src/screens/MsgBoxTest.jsx).전체 API 는 API 문서의 msgbox 절.
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>
)
}
close(결과)는 확정(콜백 발화 ·
Promise 결과), close()·제목바 ×·ESC 는
취소(콜백 미발화 · Promise undefined). MOCA 규약 그대로이므로
호출측은 취소를 따로 처리하지 않아도 된다.data·결과만 소유하므로 섞이지 않는다.width 생략 시 560px, height 생략 시 내용 높이
(최대 90vh). 목록 팝업은 대개 width 만 준다.auto(남는 공간) 칸이 0 이 되어 목록이 보이지 않는다. 호출측에서
height 를 주거나, 팝업 화면 루트에 minHeight 를 준다
(데모 팝업 화면들이 이 방식 — height:'100%' 와 함께 두면 호출측이
height 를 줬을 때 그 크기를 채우고, 안 줬을 때도 목록이 나온다).modal: false 면 뒤 화면을 계속 쓸 수 있는 보조창이 된다.
제목바를 잡아 옮길 수 있다(draggable, 기본 켜짐).× 는 팝업 셸이 준다.useSelect) — 무거운 조회 팝업은
lazy() 로 넘기면 처음 열 때 내려받는다("화면 로딩중…" 표시).
데모의 사용자 검색 팝업이 이 방식이다.popup.closeAll()(App.jsx 로그아웃에 적용).
통신 실패로 접어야 할 때는 popup.close().src/screens/PopupTest.jsx,
팝업 화면은 src/screens/pop/).전체 API 는 API 문서의 popup 절.
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))
chart.resize().
레이아웃 setRatio·MDI 분할·창 크기·모바일 세로스택 전환을 화면 코드 없이 추종한다(MOCA 동일).theme="auto"(기본)면 OS 다크에서 echarts 내장 dark 테마(MOCA 앱 기조 대응).
축·그리드 등 option 안의 색은 화면이 팔레트를 분기해 준다(데모 참고).merge 로 부분 갱신. 데이터가 크면
시리즈만 바꾸는 merge 가 유리하다.src/screens/ChartTest.jsx — MOCA 대시보드 위젯 3종 이식).전체 API 는 API 문서의 MocaReactEchart 절.
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} />
nm 만 보이면 검색도
nm 으로만 된다 — 코드·분류로도 걸리게 하려면 displayFormat 으로 문구에 넣는다.onChange 가 오지 않는다. 목록 이동은
↓ ↑, 닫기는 Esc,
▼ 는 필터를 풀고 전체 목록을 연다.changed:false) — 메뉴 검색처럼 "값"이 아니라
"행위"가 필요한 화면 때문.fixed)으로 뜬다 — 레이아웃 칸(overflow:auto)이나 헤더 안에서
잘리지 않고, 아래 자리가 모자라면 위로 뒤집힌다(MOCA 동일 방식).emptyText 안내를 보여준다.ref 로 getValue·getLabel·setValue·clear·focus·open·close·getList.src/screens/SearchComboTest.jsx).전체 API 는 API 문서의 MocaReactSearchCombo 절.
| 결정 | 이유 |
|---|---|
| 가상 스크롤 = 전용 스크롤바 방식 (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.
MOCA 원본의 catalog.js / moca-api.html / moca-guide.html 체계를 그대로 가져왔다. 패키지 공개 API 나 화면 등록 규약, 사용자 UX 를 만들거나 고치면 같은 커밋에서 함께 갱신한다:
| 대상 | 파일 | 성격 |
|---|---|---|
| Catalog | docs/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 | 작업 절차·내부 규칙 — 새 패키지를 만들면 스킬도 같은 꼴로 추가 |
| README | packages/*/README.md | 패키지 단독 배포용 props 문서 |
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)의 링크도 함께 고친다.
localhost:5173 접속, 스크린샷·DOM 실측.http://localhost:8080/system/demo/index.html (원본 소스 MOCA2026PORTABLE).