Compare commits

...

3 Commits

Author SHA1 Message Date
zio
fe6757f00a docs(web): fix stale backend-not-implemented comments (GAP hygiene item)
auction/visitor/marketing sample data + cms.css headers now state the
real wiring status (live API primary, samples are degraded fallback
only; G-04 SMTP follow-up noted). Mark hygiene item resolved in
GAP_AUDIT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:48:03 +09:00
zio
465ebd8e19 feat(web): apply KxDataTable to holiday/program/ruleset admin (screens 5-7)
Replace hand-rolled zebra tables with KxDataTable (sort/filter/CSV,
per-column control) on HolidayAdminPage, ProgramAdminPage and
RulesetVersionsPage. Manage columns excluded from CSV/sort/search;
empty states delegated to the table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:47:37 +09:00
zio
9144186e8a fix(web): drop unused EmptyState import in TenantAdminPage (build break)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 23:47:23 +09:00
9 changed files with 134 additions and 160 deletions

View File

@ -81,12 +81,12 @@
### 데이터 이슈 (코드 정상 · 담당=kintex-testdata-dev) ### 데이터 이슈 (코드 정상 · 담당=kintex-testdata-dev)
- sample-import 12개 화면(analytics·dashboard·ops·floorplan·gallery·marketing×2·schedule·visitor×3)은 **조건부 degraded 폴백**(NETWORK/NOT_FOUND/NOT_IMPLEMENTED 시에만 sample). 실 API가 primary. 빈 화면으로 보이면 코드가 아니라 **행사스코프 시드 미정렬** → testdata 담당. - sample-import 12개 화면(analytics·dashboard·ops·floorplan·gallery·marketing×2·schedule·visitor×3)은 **조건부 degraded 폴백**(NETWORK/NOT_FOUND/NOT_IMPLEMENTED 시에만 sample). 실 API가 primary. 빈 화면으로 보이면 코드가 아니라 **행사스코프 시드 미정렬** → testdata 담당.
### 문서 위생 — STALE 주석 (기능 영향 없음 · 담당=frontend-dev 청소) ### 문서 위생 — STALE 주석 (기능 영향 없음 · 담당=frontend-dev 청소) — ✅해소(2026-07-13)
- `screens/auction/sampleAuction.ts:3` "M15 역경매 엔진 미구현" → 실구현됨 - ~~`screens/auction/sampleAuction.ts:3` "M15 역경매 엔진 미구현"~~ → 실구현 반영 주석 갱신
- `screens/visitor/sampleVisitor.ts:3` "M10/M11 백엔드 미구현" → 실구현됨 - ~~`screens/visitor/sampleVisitor.ts:3` "M10/M11 백엔드 미구현"~~ → 실구현 반영 주석 갱신
- `screens/marketing/sampleMarketing.ts:3` "M12 백엔드 미구현" → 실구현됨 - ~~`screens/marketing/sampleMarketing.ts:3` "M12 백엔드 미구현"~~ → 실구현 반영 주석 갱신(G-04 실발송 후속 명시)
- `screens/cms/cms.css:4` "M17 백엔드 미구현 → 전 화면 로컬 샘플" → cmsApi 실배선됨 - ~~`screens/cms/cms.css:4` "M17 백엔드 미구현 → 전 화면 로컬 샘플"~~ → cmsApi 실배선 반영 주석 갱신
주석만 갱신(오해 유발). 재감사 오탐 방지 위해 정리 권장. 4건 모두 "실구현 완료 + 샘플은 강등 폴백" 문구로 정정. 재감사 오탐 방지.
--- ---
@ -101,7 +101,7 @@
| 5 | G-06/G-07 | 회의 STT·통합검색 자연어 AI | kintex-ai-dev | | 5 | G-06/G-07 | 회의 STT·통합검색 자연어 AI | kintex-ai-dev |
| 6 | G-05/G-09 | 서류 HWP/PDF 생성·렌더 이력 영속(파일/렌더 큐) | kintex-backend-dev | | 6 | G-05/G-09 | 서류 HWP/PDF 생성·렌더 이력 영속(파일/렌더 큐) | kintex-backend-dev |
| 7 | G-08 | 마이페이지 프로필 PUT·알림설정 API | kintex-backend-dev + frontend-dev | | 7 | G-08 | 마이페이지 프로필 PUT·알림설정 API | kintex-backend-dev + frontend-dev |
| 8 | (위생) | STALE "미구현" 주석 4건 정리 | kintex-frontend-dev | | 8 | ~~(위생)~~ ✅ | STALE "미구현" 주석 4건 정리 — 2026-07-13 해소 | kintex-frontend-dev |
| — | (데이터) | 행사스코프 시드 정렬(빈 화면 폴백 해소) | kintex-testdata-dev | | — | (데이터) | 행사스코프 시드 정렬(빈 화면 폴백 해소) | kintex-testdata-dev |
--- ---

View File

@ -7,11 +7,12 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconPlus } from '../../components/ui/icons'; import { IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { holidayApi, type HolidayCreateBody } from './holidayApi'; import { holidayApi, type Holiday, type HolidayCreateBody } from './holidayApi';
import './admin-modules.css'; import './admin-modules.css';
/** 데이터 미존재 시 폴백 연도 목록(V41 시드 범위). */ /** 데이터 미존재 시 폴백 연도 목록(V41 시드 범위). */
@ -72,6 +73,37 @@ export function HolidayAdminPage() {
const count = useMemo(() => list.filter((h) => h.isHoliday).length, [list]); const count = useMemo(() => list.filter((h) => h.isHoliday).length, [list]);
const columns = useMemo<KxColumn<Holiday>[]>(
() => [
{ key: 'ymd', header: t('holiday.date'), value: (h) => h.ymd,
render: (h) => <span className="kx-adm-mono" style={weekdayIndex(h.ymd) === 0 ? { color: 'var(--color-error)' } : undefined}>{h.ymd}</span> },
{ key: 'weekday', header: t('holiday.weekday'), sortable: false, value: (h) => t(`common.weekday.${weekdayIndex(h.ymd)}`),
render: (h) => <span style={weekdayIndex(h.ymd) === 0 ? { color: 'var(--color-error)' } : undefined}>{t(`common.weekday.${weekdayIndex(h.ymd)}`)}</span> },
{ key: 'name', header: t('holiday.name'), value: (h) => h.name,
render: (h) => <span className="kx-adm-tbl__strong" style={{ color: 'var(--color-error)' }}>{h.name}</span> },
{ key: 'type', header: t('holiday.type'), value: (h) => (h.isSubstitute ? t('holiday.substitute') : t('holiday.legal')),
render: (h) => h.isSubstitute
? <span className="kx-adm-pill kx-adm-pill--neutral">{t('holiday.substitute')}</span>
: <span className="kx-adm-muted">{t('holiday.legal')}</span> },
{ key: 'status', header: t('holiday.status'), value: (h) => (h.isHoliday ? t('holiday.on') : t('holiday.off')),
render: (h) => <span className={`kx-adm-pill kx-adm-pill--${h.isHoliday ? 'success' : 'neutral'}`}>{h.isHoliday ? t('holiday.on') : t('holiday.off')}</span> },
{ key: 'manage', header: t('holiday.manage'), csv: false, sortable: false, searchable: false,
thClassName: 'kx-adm-col-manage', tdClassName: 'kx-adm-col-manage',
render: (h) => (
<div className="kx-adm-tbl__actions">
<Button variant="ghost" disabled={toggleM.isPending} onClick={() => toggleM.mutate({ ymd: h.ymd, isHoliday: !h.isHoliday })}>
{h.isHoliday ? t('holiday.setOff') : t('holiday.setOn')}
</Button>
<Button variant="ghost" className="kx-adm-del" disabled={delM.isPending}
onClick={() => { if (window.confirm(t('holiday.deleteConfirm', { name: h.name }))) delM.mutate(h.ymd); }}>
{t('holiday.delete')}
</Button>
</div>
) },
],
[t, toggleM, delM],
);
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
@ -106,83 +138,23 @@ export function HolidayAdminPage() {
)} )}
{!listQ.isLoading && !listQ.isError && ( {!listQ.isLoading && !listQ.isError && (
list.length === 0 ? ( <section className="kx-card">
<EmptyState title={t('holiday.emptyTitle')} description={t('holiday.emptyDesc')} /> <div className="kx-adm-codes__head">
) : ( <h2>{t('holiday.listOf', { y: year })}</h2>
<section className="kx-card"> <p className="kx-adm-muted">{t('holiday.count', { n: count })}</p>
<div className="kx-adm-codes__head"> </div>
<h2>{t('holiday.listOf', { y: year })}</h2> <div className="kx-adm-codes__tbl">
<p className="kx-adm-muted">{t('holiday.count', { n: count })}</p> <KxDataTable
</div> columns={columns}
<div className="kx-table-scroll"> rows={list}
<table className="kx-table kx-table--zebra kx-adm-tbl"> rowKey={(h) => h.ymd}
<thead> csvFileName={`holidays_${year}`}
<tr> ariaLabel={t('holiday.listOf', { y: year })}
<th>{t('holiday.date')}</th> emptyTitle={t('holiday.emptyTitle')}
<th>{t('holiday.weekday')}</th> emptyDescription={t('holiday.emptyDesc')}
<th>{t('holiday.name')}</th> />
<th>{t('holiday.type')}</th> </div>
<th>{t('holiday.status')}</th> </section>
<th style={{ textAlign: 'right' }}>{t('holiday.manage')}</th>
</tr>
</thead>
<tbody>
{list.map((h) => {
const dow = weekdayIndex(h.ymd);
const isSun = dow === 0;
return (
<tr key={h.ymd}>
<td className="kx-adm-mono" style={isSun ? { color: 'var(--color-error)' } : undefined}>
{h.ymd}
</td>
<td style={isSun ? { color: 'var(--color-error)' } : undefined}>
{t(`common.weekday.${dow}`)}
</td>
<td className="kx-adm-tbl__strong" style={{ color: 'var(--color-error)' }}>
{h.name}
</td>
<td>
{h.isSubstitute ? (
<span className="kx-adm-pill kx-adm-pill--neutral">{t('holiday.substitute')}</span>
) : (
<span className="kx-adm-muted">{t('holiday.legal')}</span>
)}
</td>
<td>
<span className={`kx-adm-pill kx-adm-pill--${h.isHoliday ? 'success' : 'neutral'}`}>
{h.isHoliday ? t('holiday.on') : t('holiday.off')}
</span>
</td>
<td>
<div className="kx-adm-tbl__actions">
<Button
variant="ghost"
disabled={toggleM.isPending}
onClick={() => toggleM.mutate({ ymd: h.ymd, isHoliday: !h.isHoliday })}
>
{h.isHoliday ? t('holiday.setOff') : t('holiday.setOn')}
</Button>
<Button
variant="ghost"
className="kx-adm-del"
disabled={delM.isPending}
onClick={() => {
if (window.confirm(t('holiday.deleteConfirm', { name: h.name })))
delM.mutate(h.ymd);
}}
>
{t('holiday.delete')}
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)
)} )}
{adding && ( {adding && (

View File

@ -2,11 +2,12 @@
* W12 () . * W12 () .
* 정본: GET/POST /api/admin/programs · DELETE /{id} (ProgramController). / ( ). * 정본: GET/POST /api/admin/programs · DELETE /{id} (ProgramController). / ( ).
*/ */
import { useState } from 'react'; import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconPlus, IconSearch } from '../../components/ui/icons'; import { IconPlus, IconSearch } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';
import { programApi, PROGRAM_TYPES, type Program, type ProgramSaveBody } from './systemBasicsApi'; import { programApi, PROGRAM_TYPES, type Program, type ProgramSaveBody } from './systemBasicsApi';
@ -38,6 +39,31 @@ export function ProgramAdminPage() {
onError: (e) => show(errMessage(e)), onError: (e) => show(errMessage(e)),
}); });
const columns = useMemo<KxColumn<Program>[]>(
() => [
{ key: 'name', header: t('program.colName'), value: (r) => r.programName },
{ key: 'type', header: t('program.colType'), value: (r) => r.programType,
render: (r) => <span className="kx-adm-pill kx-adm-pill--neutral">{r.programType}</span> },
{ key: 'url', header: t('program.colUrl'), value: (r) => r.programUrl ?? '',
render: (r) => <span className="kx-adm-mono">{r.programUrl ?? '-'}</span> },
{ key: 'category', header: t('program.colCategory'), value: (r) => r.category ?? '', render: (r) => r.category ?? '-' },
{ key: 'use', header: t('program.colUse'), value: (r) => (r.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use')),
render: (r) => (r.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use')) },
{ key: 'manage', header: t('sysadmin.actions'), csv: false, sortable: false, searchable: false,
thClassName: 'kx-adm-col-manage', tdClassName: 'kx-adm-col-manage',
render: (r) => (
<div className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setEditing(r)}>{t('sysadmin.edit')}</Button>
<Button variant="ghost" className="kx-adm-del" disabled={delM.isPending}
onClick={() => { if (window.confirm(t('program.deleteConfirm', { name: r.programName }))) delM.mutate(r.id); }}>
{t('sysadmin.delete')}
</Button>
</div>
) },
],
[t, delM],
);
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
@ -66,42 +92,16 @@ export function ProgramAdminPage() {
{!q.isLoading && !q.isError && ( {!q.isLoading && !q.isError && (
<section className="kx-card kx-adm-table-card"> <section className="kx-card kx-adm-table-card">
{rows.length === 0 ? ( <KxDataTable
<EmptyState title={t('program.emptyTitle')} description={t('program.emptyDesc')} /> columns={columns}
) : ( rows={rows}
<div className="kx-table-scroll"> rowKey={(r) => r.id}
<table className="kx-table kx-table--zebra"> searchable={false}
<thead> csvFileName="programs"
<tr> ariaLabel={t('program.title')}
<th>{t('program.colName')}</th> emptyTitle={t('program.emptyTitle')}
<th>{t('program.colType')}</th> emptyDescription={t('program.emptyDesc')}
<th>{t('program.colUrl')}</th> />
<th>{t('program.colCategory')}</th>
<th>{t('program.colUse')}</th>
<th aria-label={t('sysadmin.actions')} />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.programName}</td>
<td><span className="kx-adm-pill kx-adm-pill--neutral">{r.programType}</span></td>
<td className="kx-adm-mono">{r.programUrl ?? '-'}</td>
<td>{r.category ?? '-'}</td>
<td>{r.useYn === 'N' ? t('sysadmin.noUse') : t('sysadmin.use')}</td>
<td className="kx-adm-tbl__actions">
<Button variant="ghost" onClick={() => setEditing(r)}>{t('sysadmin.edit')}</Button>
<Button variant="ghost" className="kx-adm-del" disabled={delM.isPending}
onClick={() => { if (window.confirm(t('program.deleteConfirm', { name: r.programName }))) delM.mutate(r.id); }}>
{t('sysadmin.delete')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section> </section>
)} )}

View File

@ -3,11 +3,12 @@
* 원천: API( ) GET /api/admin/rulesets( ) · GET /api/admin/rulesets/{name}( ). * 원천: API( ) GET /api/admin/rulesets( ) · GET /api/admin/rulesets/{name}( ).
* classpath (resources/rulesets/*.json) ( = ). * classpath (resources/rulesets/*.json) ( = ).
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { rulesetApi, type RulesetRule } from './adminModulesApi'; import { rulesetApi, type RulesetRule } from './adminModulesApi';
import './admin-modules.css'; import './admin-modules.css';
@ -41,6 +42,29 @@ export function RulesetVersionsPage() {
const rules: RulesetRule[] = detailQ.data?.rules ?? []; const rules: RulesetRule[] = detailQ.data?.rules ?? [];
const ruleColumns = useMemo<KxColumn<RulesetRule>[]>(
() => [
{ key: 'name', header: t('ruleset.colRuleName'), value: (r) => `${r.label ?? r.code} ${r.code}`,
render: (r) => (
<>
<span className="kx-adm-rulename">{r.label ?? r.code}</span>
<span className="kx-adm-rulecode">{r.code}</span>
</>
) },
{ key: 'value', header: t('ruleset.colValue'), value: (r) => formatValue(t, r),
render: (r) => <span className="kx-adm-mono">{formatValue(t, r)}</span> },
{ key: 'logic', header: t('ruleset.colLogic'), value: (r) => formatLogic(t, r),
render: (r) => <span className="kx-adm-muted">{formatLogic(t, r)}</span> },
{ key: 'severity', header: t('ruleset.colSeverity'), value: (r) => (r.severity === 'block' ? t('ruleset.block') : t('ruleset.warn')),
render: (r) => (
<span className={`kx-adm-pill kx-adm-pill--${r.severity === 'block' ? 'error' : 'warn'}`}>
{r.severity === 'block' ? t('ruleset.block') : t('ruleset.warn')}
</span>
) },
],
[t],
);
return ( return (
<div className="kx-page kx-adm"> <div className="kx-page kx-adm">
<header className="kx-adm__head"> <header className="kx-adm__head">
@ -121,36 +145,14 @@ export function RulesetVersionsPage() {
)} )}
{rules.length > 0 && ( {rules.length > 0 && (
<div className="kx-table-scroll"> <div className="kx-adm-rules-dt">
<table className="kx-table kx-table--zebra kx-adm-rules"> <KxDataTable
<thead> columns={ruleColumns}
<tr> rows={rules}
<th>{t('ruleset.colRuleName')}</th> rowKey={(r) => r.code}
<th>{t('ruleset.colValue')}</th> csvFileName={`ruleset_${activeName ?? 'rules'}`}
<th>{t('ruleset.colLogic')}</th> ariaLabel={t('ruleset.ruleList')}
<th>{t('ruleset.colSeverity')}</th> />
</tr>
</thead>
<tbody>
{rules.map((r) => (
<tr key={r.code}>
<td>
<span className="kx-adm-rulename">{r.label ?? r.code}</span>
<span className="kx-adm-rulecode">{r.code}</span>
</td>
<td className="kx-adm-mono">{formatValue(t, r)}</td>
<td className="kx-adm-muted">{formatLogic(t, r)}</td>
<td>
<span
className={`kx-adm-pill kx-adm-pill--${r.severity === 'block' ? 'error' : 'warn'}`}
>
{r.severity === 'block' ? t('ruleset.block') : t('ruleset.warn')}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
)} )}

View File

@ -10,7 +10,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next'; import type { TFunction } from 'i18next';
import { Button } from '../../components/ui/Button'; import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { ErrorState, Skeleton } from '../../components/ui/States';
import { KxDataTable, type KxColumn } from '../../components/KxDataTable'; import { KxDataTable, type KxColumn } from '../../components/KxDataTable';
import { IconCheck, IconPlus } from '../../components/ui/icons'; import { IconCheck, IconPlus } from '../../components/ui/icons';
import { errMessage, useToast } from '../work/workShared'; import { errMessage, useToast } from '../work/workShared';

View File

@ -1,6 +1,6 @@
/* /*
* M15 / . * M15 / .
* M15 API . * M15 (auctionApi ). / API .
* fetch , "샘플 데이터" . * fetch , "샘플 데이터" .
* / . * / .
* (UI ): * (UI ):

View File

@ -1,7 +1,7 @@
/* /*
* M17 CMS 화면(SCR-35 게시 워크플로 · SCR-36 마이크로사이트 편집기 · SCR-37 다국어 관리) 공유 스타일. * M17 CMS 화면(SCR-35 게시 워크플로 · SCR-36 마이크로사이트 편집기 · SCR-37 다국어 관리) 공유 스타일.
* design.md §1 토큰(tokens.css) 참조. shared.css 프리미티브(kx-page·kx-card·kx-seg·kx-select·kx-kpi·kx-barlist) 위에 얹는다. * design.md §1 토큰(tokens.css) 참조. shared.css 프리미티브(kx-page·kx-card·kx-seg·kx-select·kx-kpi·kx-barlist) 위에 얹는다.
* M17 백엔드 미구현 화면 로컬 샘플 데이터 데모. 존재하지 않는 API 호출 없음. * M17 백엔드 실구현 완료(cmsApi 실배선). 화면은 정상 경로에서 API 소비하며, 로컬 샘플은 강등 폴백 데모용이다.
*/ */
@import '../shared.css'; @import '../shared.css';

View File

@ -1,6 +1,6 @@
/* /*
* SCR-33 EDM· · SCR-34 (M12). * SCR-33 EDM· · SCR-34 (M12).
* M12 API , ("샘플 데이터" ). * M12 (marketingApi ). API , . ( SMTP G-04.)
* M10 (N2). * M10 (N2).
*/ */

View File

@ -1,6 +1,6 @@
/* /*
* SCR-30 · SCR-32 (M10/M11). * SCR-30 · SCR-32 (M10/M11).
* M10/M11 API ("샘플 데이터" ). * M10/M11 (visitorApi ). API , NETWORK/NOT_FOUND/NOT_IMPLEMENTED ("오프라인 — 샘플" ).
* (PII ): ·· /. * (PII ): ·· /.
* PII API (N2/R10). * PII API (N2/R10).
*/ */