feat: add play apps and layout tools

This commit is contained in:
2026-05-25 17:29:21 +09:00
parent f59522ffc4
commit 51e0099bea
46 changed files with 37152 additions and 119 deletions

View File

@@ -1,97 +1,613 @@
.test-play-app {
--test-play-text: #2f3a47;
--test-play-text-strong: #202b37;
--test-play-text-muted: #697586;
--test-play-border: #e2e5ea;
--test-play-border-strong: #d2d8e0;
--test-play-surface: rgba(255, 255, 255, 0.92);
--test-play-surface-strong: rgba(252, 252, 253, 0.97);
--test-play-shadow: rgba(77, 88, 102, 0.1);
--test-play-accent: #1677ff;
--test-play-accent-strong: #0958d9;
--test-play-accent-soft: #e8f3ff;
--test-play-danger: #c97e89;
--test-play-danger-strong: #b46572;
--test-play-danger-soft: #fff0f2;
--test-play-warning: #c7ab67;
--test-play-warning-strong: #aa8740;
--test-play-warning-soft: #f8f1de;
--test-play-active-soft: #eef4ff;
--test-play-header-start: #fbfcfd;
--test-play-header-end: #f1f4f8;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
height: 100%;
min-height: 100%;
overflow: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 32px;
min-height: 0;
max-height: 100%;
overflow: hidden;
padding: 8px;
background:
radial-gradient(circle at top left, rgba(255, 211, 105, 0.24), transparent 28%),
radial-gradient(circle at top left, rgba(255, 211, 105, 0.18), transparent 28%),
linear-gradient(160deg, #f4efe2 0%, #fcfaf4 48%, #eef3f8 100%);
color: var(--test-play-text);
}
.test-play-app__hero {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(280px, 0.9fr);
align-items: start;
gap: 24px;
margin-bottom: 24px;
.test-play-app__filters,
.test-play-app__grid-panel {
min-height: 0;
border: 1px solid var(--test-play-border);
border-radius: 14px;
background: var(--test-play-surface);
box-shadow: 0 12px 28px var(--test-play-shadow);
}
.test-play-app__hero-copy,
.test-play-app__spotlight,
.test-play-app__feature-card {
border-radius: 28px;
border: 1px solid rgba(34, 49, 63, 0.08);
box-shadow: 0 22px 60px rgba(63, 79, 92, 0.08);
}
.test-play-app__hero-copy {
padding: 32px;
background: rgba(255, 252, 244, 0.82);
backdrop-filter: blur(14px);
}
.test-play-app__hero-copy .ant-typography h2 {
margin-top: 14px;
margin-bottom: 14px;
font-size: clamp(2rem, 3vw, 3.2rem);
line-height: 1.04;
}
.test-play-app__hero-copy .ant-typography {
max-width: 640px;
}
.test-play-app__spotlight {
background: linear-gradient(180deg, #1e2b31 0%, #263b45 100%);
}
.test-play-app__spotlight .ant-card-body {
.test-play-app__filters {
display: flex;
flex: 0 0 auto;
flex-direction: column;
gap: 10px;
padding: 12px;
}
.test-play-app__heading strong {
display: block;
font-size: 15px;
font-weight: 700;
color: var(--test-play-text-strong);
}
.test-play-app__filters-main {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.test-play-app__search-input {
flex: 1;
min-width: 0;
}
.test-play-app__search-input .ant-input {
height: 38px;
}
.test-play-app :where(.ant-input, .ant-select-selector) {
border-color: var(--test-play-border-strong) !important;
background: rgba(255, 255, 255, 0.98) !important;
color: var(--test-play-text) !important;
box-shadow: none !important;
}
.test-play-app :where(.ant-input:hover, .ant-select:hover .ant-select-selector) {
border-color: #b7c0cb !important;
}
.test-play-app :where(.ant-input:focus, .ant-input-focused, .ant-select-focused .ant-select-selector) {
border-color: var(--test-play-accent) !important;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.14) !important;
}
.test-play-app__icon-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 6px;
}
.test-play-app__icon-button.ant-btn {
width: 38px;
min-width: 38px;
height: 38px;
padding: 0;
border-radius: 12px;
border-color: #d6dbe3;
background: #ffffff;
color: #3f4b59;
box-shadow: 0 6px 14px rgba(77, 88, 102, 0.08);
}
.test-play-app__icon-button.ant-btn:hover,
.test-play-app__icon-button.ant-btn:focus-visible {
border-color: #8cb8ff;
background: #ffffff;
color: #1f2937;
box-shadow:
0 0 0 2px rgba(22, 119, 255, 0.14),
0 8px 18px rgba(77, 88, 102, 0.1);
}
.test-play-app__icon-button.ant-btn.ant-btn-primary,
.test-play-app__icon-button--primary.ant-btn {
border-color: var(--test-play-accent);
background: var(--test-play-accent);
color: #ffffff;
box-shadow: 0 8px 18px rgba(22, 119, 255, 0.22);
}
.test-play-app__icon-button.ant-btn.ant-btn-primary:hover,
.test-play-app__icon-button.ant-btn.ant-btn-primary:focus-visible,
.test-play-app__icon-button--primary.ant-btn:hover,
.test-play-app__icon-button--primary.ant-btn:focus-visible {
border-color: var(--test-play-accent-strong);
background: var(--test-play-accent-strong);
color: #ffffff;
box-shadow:
0 0 0 2px rgba(22, 119, 255, 0.18),
0 10px 22px rgba(22, 119, 255, 0.24);
}
.test-play-app__icon-button--danger.ant-btn {
border-color: #e3b7c3;
background: linear-gradient(180deg, #fff9fb 0%, #fbe8ee 100%);
color: var(--test-play-danger-strong);
box-shadow:
0 8px 18px rgba(201, 126, 137, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.54);
}
.test-play-app__icon-button--danger.ant-btn:hover,
.test-play-app__icon-button--danger.ant-btn:focus-visible {
border-color: var(--test-play-danger);
background: linear-gradient(180deg, #fff7fa 0%, #f7dfe8 100%);
color: var(--test-play-danger-strong);
box-shadow:
0 0 0 2px rgba(201, 134, 156, 0.18),
0 10px 20px rgba(201, 126, 137, 0.18);
}
.test-play-app__icon-button.ant-btn:disabled,
.test-play-app__icon-button.ant-btn.ant-btn-disabled,
.test-play-app__icon-button.ant-btn[disabled] {
border-color: #e2e6ec !important;
background: #f6f7f9 !important;
color: #9aa3af !important;
box-shadow: none !important;
opacity: 1;
}
.test-play-app__filters-detail {
display: grid;
min-height: 0;
color: #f5f6f8;
}
.test-play-app__spotlight .ant-typography,
.test-play-app__spotlight .ant-typography-copy,
.test-play-app__spotlight .ant-typography-secondary {
color: inherit;
.test-play-app__filters-toggle.ant-btn {
flex: 0 0 auto;
}
.test-play-app__feature-card {
.test-play-app__filters-extra-shell {
display: grid;
grid-template-rows: 0fr;
overflow: hidden;
transition: grid-template-rows 0.28s ease;
}
.test-play-app__filters-detail--expanded .test-play-app__filters-extra-shell {
grid-template-rows: 1fr;
}
.test-play-app__filters-extra {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
min-height: 0;
overflow: hidden;
opacity: 0;
transform: translateY(-6px);
transition:
opacity 0.22s ease,
transform 0.28s ease,
padding-bottom 0.28s ease;
}
.test-play-app__filters-detail--expanded .test-play-app__filters-extra {
padding-top: 10px;
border-top: 1px solid #eceff3;
padding-bottom: 4px;
opacity: 1;
transform: translateY(0);
}
.test-play-app__filter-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.test-play-app__filter-field > span {
font-size: 12px;
color: var(--test-play-text-muted);
}
.test-play-app__grid-panel {
display: flex;
flex: 1;
flex-direction: column;
overflow: hidden;
}
.test-play-app__grid-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 12px;
border-bottom: 1px solid #e8ebf0;
background: linear-gradient(180deg, #fcfcfd 0%, #f5f7fa 100%);
}
.test-play-app__grid-meta-inline {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
color: var(--test-play-text-muted);
font-size: 12px;
white-space: nowrap;
}
.test-play-app__grid-meta-inline-range,
.test-play-app__grid-meta-inline-page {
white-space: nowrap;
}
.test-play-app__grid-meta-inline-divider {
color: #aab3be;
}
.test-play-app__grid-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
gap: 8px;
margin-left: auto;
}
.test-play-app__grid-surface {
flex: 1;
min-height: 0;
overflow: hidden;
}
.test-play-app__grid-surface .ant-spin-nested-loading,
.test-play-app__grid-surface .ant-spin-container {
height: 100%;
background: rgba(255, 255, 255, 0.84);
}
.test-play-app__feature-label {
display: inline-block;
margin-bottom: 8px;
font-size: 0.78rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #7d5b1f;
.test-play-app__grid-surface .ag-root-wrapper {
height: 100%;
border: 0;
border-radius: 0;
}
.test-play-app__grid-surface .ag-header {
border-bottom: 1px solid #dfe5ec;
background: linear-gradient(180deg, var(--test-play-header-start) 0%, var(--test-play-header-end) 100%);
}
.test-play-app__grid-surface .ag-header-cell,
.test-play-app__grid-surface .ag-header-group-cell {
color: #4a5563;
font-weight: 700;
background: transparent;
box-shadow: inset -1px 0 0 rgba(202, 208, 217, 0.28);
}
.test-play-app__grid-surface .ag-row {
transition: background-color 0.18s ease;
}
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--odd {
--test-play-row-bg: #ffffff;
}
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--even {
--test-play-row-bg: #fafbfc;
}
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--dirty {
--test-play-row-bg: var(--test-play-warning-soft);
}
.test-play-app__grid-surface .ag-row.test-play-app__grid-row--active {
--test-play-row-bg: var(--test-play-active-soft);
}
.test-play-app__grid-cell.ag-cell {
display: flex;
align-items: center;
background: var(--test-play-row-bg, #fff);
color: var(--test-play-text);
box-shadow: inset 0 -1px 0 rgba(226, 231, 238, 0.9);
}
.test-play-app__grid-cell--edited.ag-cell {
color: #80551d;
font-weight: 700;
background: linear-gradient(180deg, #fbf3dd 0%, #f6ebcf 100%);
box-shadow:
inset 3px 0 0 var(--test-play-warning),
inset 0 -1px 0 rgba(214, 176, 109, 0.42);
}
.test-play-app__grid-cell--active.ag-cell {
box-shadow:
inset 0 0 0 1px rgba(22, 119, 255, 0.2),
inset 0 -1px 0 rgba(226, 231, 238, 0.9);
}
.test-play-app__editor-shell {
position: fixed;
inset: 0;
z-index: 1200;
display: flex;
justify-content: flex-end;
pointer-events: none;
overflow: hidden;
}
.test-play-app__editor-shell--open {
pointer-events: auto;
}
.test-play-app__editor-backdrop {
position: absolute;
inset: 0;
border: 0;
background: rgba(44, 63, 79, 0);
transition: background-color 0.28s ease;
cursor: pointer;
}
.test-play-app__editor-shell--open .test-play-app__editor-backdrop {
background: rgba(56, 78, 97, 0.24);
}
.test-play-app__editor-panel {
position: relative;
display: flex;
flex-direction: column;
width: 100vw;
height: 100dvh;
min-height: 100dvh;
background:
radial-gradient(circle at top left, rgba(255, 211, 105, 0.12), transparent 24%),
linear-gradient(160deg, #f8f5ec 0%, #fcfbf7 48%, #f1f4f8 100%);
transform: translate3d(100%, 0, 0);
transition: transform 0.32s cubic-bezier(0.22, 1, 0.36, 1);
box-shadow: -16px 0 44px rgba(86, 110, 125, 0.16);
overflow: hidden;
}
.test-play-app__editor-shell--open .test-play-app__editor-panel {
transform: translate3d(0, 0, 0);
}
.test-play-app__editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: max(18px, env(safe-area-inset-top, 0px)) 24px 18px;
border-bottom: 1px solid rgba(225, 229, 235, 0.92);
background: rgba(252, 251, 248, 0.94);
backdrop-filter: blur(18px);
}
.test-play-app__editor-heading {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.test-play-app__editor-heading strong {
overflow: hidden;
color: var(--test-play-text-strong);
font-size: 24px;
line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
}
.test-play-app__editor-heading span {
color: var(--test-play-text-muted);
font-size: 13px;
}
.test-play-app__editor-actions {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px;
border: 1px solid rgba(220, 225, 232, 0.92);
border-radius: 16px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 8px 18px rgba(102, 112, 122, 0.08);
}
.test-play-app__editor-actions .ant-tooltip-disabled-compatible-wrapper {
display: inline-flex;
}
.test-play-app__editor-close.ant-btn {
flex: 0 0 auto;
}
.test-play-app__editor-delete.ant-btn,
.test-play-app__editor-close.ant-btn,
.test-play-app__editor-apply.ant-btn {
flex: 0 0 auto;
}
.test-play-app__editor-body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 24px;
}
.test-play-app__editor-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.test-play-app__editor-card,
.test-play-app__editor-form {
border: 1px solid rgba(223, 227, 233, 0.95);
border-radius: 20px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 18px 36px rgba(102, 112, 122, 0.08);
}
.test-play-app__editor-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px;
}
.test-play-app__editor-card-label {
color: var(--test-play-text-muted);
font-size: 12px;
}
.test-play-app__editor-card strong {
color: var(--test-play-text-strong);
font-size: 18px;
}
.test-play-app__editor-form {
padding: 24px;
}
.test-play-app__editor-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.test-play-app__editor-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.test-play-app__editor-field > span {
color: #5e7287;
font-size: 12px;
font-weight: 600;
}
.test-play-app__editor-field .ant-input,
.test-play-app__editor-field .ant-select-selector {
min-height: 42px;
border-radius: 12px !important;
}
.test-play-app__editor-field--edited .ant-input,
.test-play-app__editor-field--edited .ant-select-selector {
border-color: var(--test-play-warning) !important;
background: #faf5e8 !important;
box-shadow: 0 0 0 1px rgba(214, 176, 109, 0.12);
}
.test-play-app__editor-note {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
min-height: 100%;
padding: 18px;
border: 1px dashed #d9dde4;
border-radius: 16px;
background: linear-gradient(180deg, #fffdf8 0%, #f6f2e8 100%);
color: #6a7280;
}
.test-play-app__editor-note strong {
color: #404956;
font-size: 14px;
}
@media (max-width: 1080px) {
.test-play-app__filters-extra {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.test-play-app__editor-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.test-play-app {
min-height: 100dvh;
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
height: 100%;
min-height: 0;
padding: 8px 8px calc(8px + env(safe-area-inset-bottom, 0px));
}
.test-play-app__hero {
.test-play-app__filters-extra {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.test-play-app__grid-toolbar {
justify-content: flex-end;
}
.test-play-app__grid-actions {
margin-left: 0;
}
.test-play-app__editor-header,
.test-play-app__editor-body,
.test-play-app__editor-form {
padding-left: 16px;
padding-right: 16px;
}
.test-play-app__editor-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 640px) {
.test-play-app__filters-main {
flex-wrap: nowrap;
}
.test-play-app__filters-extra {
grid-template-columns: minmax(0, 1fr);
}
.test-play-app__hero-copy {
padding: 24px;
.test-play-app__grid-toolbar {
gap: 10px;
}
.test-play-app__spotlight .ant-card-body {
min-height: 180px;
.test-play-app__grid-meta-inline {
flex: 1 1 auto;
min-width: 0;
}
.test-play-app__grid-actions {
justify-content: flex-end;
}
.test-play-app__editor-heading strong {
font-size: 20px;
white-space: normal;
}
.test-play-app__editor-heading span {
font-size: 12px;
}
.test-play-app__editor-summary {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -1,60 +1,678 @@
import { Button, Card, Col, Input, Row, Space, Tag, Typography } from 'antd';
import { CheckOutlined, CloseOutlined, DeleteOutlined, DownOutlined, ReloadOutlined, SaveOutlined, SearchOutlined, UpOutlined } from '@ant-design/icons';
import { Button, Input, Popconfirm, Select, Spin, Tooltip, message } from 'antd';
import type { BodyScrollEndEvent, ColDef, GridReadyEvent, RowDoubleClickedEvent, ValueFormatterParams } from 'ag-grid-community';
import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community';
import { AgGridReact } from 'ag-grid-react';
import { useEffect, useRef, useState } from 'react';
import {
deleteTestAppMaintenanceRequest,
fetchTestAppMaintenanceRequests,
saveTestAppMaintenanceRequests,
type TestAppMaintenanceRequestFilters,
type TestAppMaintenanceRequestRow,
} from './testPlayAppApi';
import './TestPlayAppView.css';
const { Paragraph, Text, Title } = Typography;
import 'ag-grid-community/styles/ag-grid.css';
import 'ag-grid-community/styles/ag-theme-quartz.css';
const featureCards = [
{
title: 'Isolated Entry',
description: '기존 Layout Editor 상태와 분리된 전용 play 앱 진입점입니다.',
},
{
title: 'Scoped Styling',
description: '이 화면은 `TestPlayAppView.css`만 사용하도록 분리해 독립 스타일 실험이 가능합니다.',
},
{
title: 'Next Extension',
description: '이후 라우트, 상태, API 연결을 현재 앱과 분리된 구조로 계속 확장할 수 있습니다.',
},
];
ModuleRegistry.registerModules([AllCommunityModule]);
const PAGE_SIZE = 100;
const STATUS_OPTIONS = ['전체', '접수', '배정완료', '조치중', '부품대기', '완료'];
const PRIORITY_OPTIONS = ['전체', '긴급', '높음', '보통', '낮음'];
const LINE_OPTIONS = ['전체', 'PKG', 'MFG', 'UTL', 'QC'];
const EDITABLE_PRIORITY_OPTIONS: TestAppMaintenanceRequestRow['priority'][] = ['긴급', '높음', '보통', '낮음'];
const EDITABLE_STATUS_OPTIONS: TestAppMaintenanceRequestRow['status'][] = ['접수', '배정완료', '조치중', '부품대기', '완료'];
const EDITABLE_FIELDS = ['priority', 'status', 'assigneeName'] as const;
type MaintenancePriority = TestAppMaintenanceRequestRow['priority'];
type MaintenanceFilterState = TestAppMaintenanceRequestFilters;
type MaintenanceRequestRow = TestAppMaintenanceRequestRow;
type EditableField = (typeof EDITABLE_FIELDS)[number];
type EditedFieldEntry = Partial<Record<EditableField, true>>;
type EditedFieldMap = Record<number, EditedFieldEntry>;
const INITIAL_FILTERS: MaintenanceFilterState = {
keyword: '',
lineCode: '전체',
priority: '전체',
status: '전체',
requestedFrom: '',
requestedTo: '',
};
function priorityRank(priority: MaintenancePriority) {
if (priority === '긴급') {
return 4;
}
if (priority === '높음') {
return 3;
}
if (priority === '보통') {
return 2;
}
return 1;
}
function formatDateTime(params: ValueFormatterParams<MaintenanceRequestRow>) {
return params.value ? String(params.value).replace('T', ' ').slice(0, 16) : '-';
}
function resolveEditedFieldEntry(row: MaintenanceRequestRow, baseline?: MaintenanceRequestRow) {
const editedEntry: EditedFieldEntry = {};
if (!baseline) {
return editedEntry;
}
for (const field of EDITABLE_FIELDS) {
if (row[field] !== baseline[field]) {
editedEntry[field] = true;
}
}
return editedEntry;
}
export function TestPlayAppView() {
const [messageApi, contextHolder] = message.useMessage();
const gridApiRef = useRef<GridReadyEvent<MaintenanceRequestRow>['api'] | null>(null);
const baselineRowsRef = useRef<Record<number, MaintenanceRequestRow>>({});
const [filters, setFilters] = useState<MaintenanceFilterState>(INITIAL_FILTERS);
const [draftFilters, setDraftFilters] = useState<MaintenanceFilterState>(INITIAL_FILTERS);
const [rows, setRows] = useState<MaintenanceRequestRow[]>([]);
const [isExpanded, setIsExpanded] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [page, setPage] = useState(1);
const [total, setTotal] = useState(0);
const [hasNext, setHasNext] = useState(false);
const [editedFieldMap, setEditedFieldMap] = useState<EditedFieldMap>({});
const [editingRowId, setEditingRowId] = useState<number | null>(null);
const loadingNextPageRef = useRef(false);
const editingRow = editingRowId === null ? null : rows.find((row) => row.id === editingRowId) ?? null;
const loadedEnd = rows.length;
const loadedRangeLabel = loadedEnd > 0 ? `1-${loadedEnd.toLocaleString()} / ${total.toLocaleString()}` : `0 / ${total.toLocaleString()}`;
const pageLabel = `${page}p`;
const updateRowDraft = (rowId: number, patch: Partial<Pick<MaintenanceRequestRow, EditableField>>) => {
const currentRow = rows.find((row) => row.id === rowId);
if (!currentRow) {
return;
}
const nextRow = {
...currentRow,
...patch,
};
const editedEntry = resolveEditedFieldEntry(nextRow, baselineRowsRef.current[rowId]);
const isDirty = Object.keys(editedEntry).length > 0;
setRows((previousRows) =>
previousRows.map((row) =>
row.id === rowId
? {
...nextRow,
isDirty,
}
: row,
),
);
setEditedFieldMap((previousMap) => {
const nextMap = { ...previousMap };
if (isDirty) {
nextMap[rowId] = editedEntry;
} else {
delete nextMap[rowId];
}
return nextMap;
});
};
const loadRows = async (nextPage: number, nextFilters: MaintenanceFilterState, append = false) => {
setIsLoading(true);
if (!append) {
setEditingRowId(null);
}
try {
const response = await fetchTestAppMaintenanceRequests(nextPage, PAGE_SIZE, nextFilters);
const nextRows = response.items.map((item) => ({ ...item, isDirty: false }));
if (append) {
for (const row of nextRows) {
baselineRowsRef.current[row.id] = { ...row, isDirty: false };
}
} else {
baselineRowsRef.current = Object.fromEntries(nextRows.map((row) => [row.id, { ...row, isDirty: false }]));
setEditedFieldMap({});
}
setRows((previousRows) => (append ? [...previousRows, ...nextRows] : nextRows));
setTotal(response.total);
setPage(response.page);
setHasNext(response.hasNext);
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '점검 요청 데이터를 불러오지 못했습니다.');
} finally {
setIsLoading(false);
loadingNextPageRef.current = false;
}
};
useEffect(() => {
void loadRows(1, INITIAL_FILTERS);
}, []);
useEffect(() => {
if (editingRowId === null) {
return;
}
if (!editingRow) {
setEditingRowId(null);
return;
}
const html = document.documentElement;
const body = document.body;
const previousHtmlOverflow = html.style.overflow;
const previousBodyOverflow = body.style.overflow;
const previousBodyPaddingRight = body.style.paddingRight;
const scrollbarWidth = Math.max(0, window.innerWidth - html.clientWidth);
html.style.overflow = 'hidden';
body.style.overflow = 'hidden';
if (scrollbarWidth > 0) {
body.style.paddingRight = `${scrollbarWidth}px`;
}
return () => {
html.style.overflow = previousHtmlOverflow;
body.style.overflow = previousBodyOverflow;
body.style.paddingRight = previousBodyPaddingRight;
};
}, [editingRow, editingRowId]);
useEffect(() => {
if (editingRowId === null) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setEditingRowId(null);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [editingRowId]);
const handleSearch = () => {
const nextFilters = {
...draftFilters,
keyword: draftFilters.keyword.trim(),
};
setFilters(nextFilters);
void loadRows(1, nextFilters);
};
const handleReset = () => {
setDraftFilters(INITIAL_FILTERS);
setFilters(INITIAL_FILTERS);
void loadRows(1, INITIAL_FILTERS);
};
const handleSave = async () => {
const dirtyRows = rows.filter((row) => row.isDirty);
if (!dirtyRows.length) {
messageApi.info('저장할 변경 행이 없습니다.');
return;
}
setIsSaving(true);
try {
await saveTestAppMaintenanceRequests(dirtyRows);
await loadRows(1, filters);
messageApi.success('저장을 완료했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsSaving(false);
}
};
const handleDelete = async () => {
if (!editingRow) {
return;
}
setIsDeleting(true);
try {
await deleteTestAppMaintenanceRequest(editingRow.id);
setEditingRowId(null);
await loadRows(1, filters);
messageApi.success('요청을 삭제했습니다.');
} catch (error) {
messageApi.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
} finally {
setIsDeleting(false);
}
};
const handleBodyScrollEnd = (event: BodyScrollEndEvent<MaintenanceRequestRow>) => {
if (event.direction !== 'vertical' || loadingNextPageRef.current || isLoading || !hasNext) {
return;
}
const api = gridApiRef.current;
if (!api) {
return;
}
const range = api.getVerticalPixelRange();
const container = document.querySelector('.test-play-app__grid-surface .ag-body-viewport') as HTMLElement | null;
const remaining = container ? container.scrollHeight - range.bottom : Number.MAX_SAFE_INTEGER;
if (remaining > 80) {
return;
}
loadingNextPageRef.current = true;
void loadRows(page + 1, filters, true);
};
const columnDefs: ColDef<MaintenanceRequestRow>[] = [
{ field: 'requestNo', headerName: '작업번호', minWidth: 132, maxWidth: 148, pinned: 'left' },
{ field: 'lineCode', headerName: '라인', minWidth: 110, maxWidth: 124 },
{ field: 'equipmentName', headerName: '설비명', minWidth: 190, flex: 1.2 },
{ field: 'issueType', headerName: '이상유형', minWidth: 144, flex: 1 },
{
field: 'priority',
headerName: '우선순위',
minWidth: 108,
maxWidth: 118,
comparator: (left: MaintenancePriority, right: MaintenancePriority) => priorityRank(left) - priorityRank(right),
},
{ field: 'requesterName', headerName: '요청자', minWidth: 112, maxWidth: 128 },
{ field: 'assigneeName', headerName: '담당자', minWidth: 112, maxWidth: 128 },
{ field: 'status', headerName: '상태', minWidth: 120, maxWidth: 132 },
{ field: 'requestedAt', headerName: '요청시각', minWidth: 168, valueFormatter: formatDateTime },
{ field: 'lastActionAt', headerName: '최근조치시각', minWidth: 176, valueFormatter: formatDateTime },
];
for (const column of columnDefs) {
column.cellClass = (params) => {
const classNames = ['test-play-app__grid-cell'];
const rowId = params.data?.id ?? null;
const fieldName = params.colDef.field as EditableField | undefined;
if (rowId !== null && fieldName && editedFieldMap[rowId]?.[fieldName]) {
classNames.push('test-play-app__grid-cell--edited');
}
if (rowId !== null && editingRowId === rowId) {
classNames.push('test-play-app__grid-cell--active');
}
return classNames;
};
}
return (
<div className="test-play-app">
<section className="test-play-app__hero">
<div className="test-play-app__hero-copy">
<Tag color="gold">Apps / Test</Tag>
<Title level={2}> test </Title>
<Paragraph>
Play <Text strong>Apps</Text> . layout editor와
, , .
</Paragraph>
<Space wrap>
<Button type="primary">Primary Action</Button>
<Button ghost>Secondary</Button>
</Space>
</div>
<>
{contextHolder}
<div className="test-play-app">
<section className="test-play-app__filters">
<div className="test-play-app__heading">
<strong> </strong>
</div>
<Card className="test-play-app__spotlight" bordered={false}>
<Text type="secondary">test app shell</Text>
<Title level={4}> index CSS CSS </Title>
<Paragraph>
React , CSS로 .
</Paragraph>
<Input placeholder="다음 단계에서 앱 전용 입력/상태를 붙일 수 있습니다." />
</Card>
</section>
<div className="test-play-app__filters-main">
<Input
className="test-play-app__search-input"
value={draftFilters.keyword}
placeholder="설비명 · 작업번호 · 요청자 검색"
onChange={(event) => {
setDraftFilters((previous) => ({
...previous,
keyword: event.target.value,
}));
}}
aria-label="검색어"
onPressEnter={handleSearch}
/>
<Row gutter={[20, 20]}>
{featureCards.map((card) => (
<Col key={card.title} xs={24} md={8}>
<Card className="test-play-app__feature-card" bordered={false}>
<Text className="test-play-app__feature-label">{card.title}</Text>
<Paragraph>{card.description}</Paragraph>
</Card>
</Col>
))}
</Row>
</div>
<div className="test-play-app__icon-actions">
<Tooltip title="조회">
<Button className="test-play-app__icon-button test-play-app__icon-button--primary" aria-label="조회" icon={<SearchOutlined />} type="primary" onClick={handleSearch} />
</Tooltip>
<Tooltip title={isExpanded ? '추가 필터 접기' : '추가 필터 펼치기'}>
<Button
type="default"
className="test-play-app__icon-button test-play-app__filters-toggle"
aria-label={isExpanded ? '추가 필터 접기' : '추가 필터 펼치기'}
aria-expanded={isExpanded}
icon={isExpanded ? <UpOutlined /> : <DownOutlined />}
onClick={() => {
setIsExpanded((previous) => !previous);
}}
/>
</Tooltip>
</div>
</div>
<div className={`test-play-app__filters-detail ${isExpanded ? 'test-play-app__filters-detail--expanded' : ''}`}>
<div className="test-play-app__filters-extra-shell">
<div className="test-play-app__filters-extra">
<label className="test-play-app__filter-field">
<span></span>
<Select
value={draftFilters.lineCode}
options={LINE_OPTIONS.map((value) => ({ value, label: value }))}
onChange={(value) => {
setDraftFilters((previous) => ({
...previous,
lineCode: value,
}));
}}
/>
</label>
<label className="test-play-app__filter-field">
<span></span>
<Select
value={draftFilters.priority}
options={PRIORITY_OPTIONS.map((value) => ({ value, label: value }))}
onChange={(value) => {
setDraftFilters((previous) => ({
...previous,
priority: value,
}));
}}
/>
</label>
<label className="test-play-app__filter-field">
<span></span>
<Select
value={draftFilters.status}
options={STATUS_OPTIONS.map((value) => ({ value, label: value }))}
onChange={(value) => {
setDraftFilters((previous) => ({
...previous,
status: value,
}));
}}
/>
</label>
<label className="test-play-app__filter-field">
<span> </span>
<Input
type="date"
value={draftFilters.requestedFrom}
onChange={(event) => {
setDraftFilters((previous) => ({
...previous,
requestedFrom: event.target.value,
}));
}}
/>
</label>
<label className="test-play-app__filter-field">
<span> </span>
<Input
type="date"
value={draftFilters.requestedTo}
onChange={(event) => {
setDraftFilters((previous) => ({
...previous,
requestedTo: event.target.value,
}));
}}
/>
</label>
</div>
</div>
</div>
</section>
<section className="test-play-app__grid-panel">
<div className="test-play-app__grid-toolbar">
<div className="test-play-app__grid-meta-inline" aria-label="페이지 정보">
<span className="test-play-app__grid-meta-inline-range">{loadedRangeLabel}</span>
<span className="test-play-app__grid-meta-inline-divider" aria-hidden="true">
·
</span>
<span className="test-play-app__grid-meta-inline-page">{pageLabel}</span>
</div>
<div className="test-play-app__grid-actions">
<Tooltip title="검색조건 초기화">
<Button className="test-play-app__icon-button" aria-label="검색조건 초기화" icon={<ReloadOutlined />} onClick={handleReset} />
</Tooltip>
<Tooltip title="저장">
<Button
className="test-play-app__icon-button test-play-app__icon-button--primary"
aria-label="저장"
icon={<SaveOutlined />}
type="primary"
loading={isSaving}
onClick={() => void handleSave()}
/>
</Tooltip>
</div>
</div>
<div className="test-play-app__grid-surface ag-theme-quartz">
<Spin spinning={isLoading}>
<AgGridReact<MaintenanceRequestRow>
rowData={rows}
columnDefs={columnDefs}
defaultColDef={{
sortable: true,
resizable: true,
editable: false,
filter: false,
}}
getRowId={(params) => String(params.data.id)}
rowHeight={42}
headerHeight={42}
suppressMovableColumns
animateRows={false}
onGridReady={(event) => {
gridApiRef.current = event.api;
event.api.sizeColumnsToFit();
}}
onGridSizeChanged={() => {
gridApiRef.current?.sizeColumnsToFit();
}}
onBodyScrollEnd={handleBodyScrollEnd}
onRowDoubleClicked={(event: RowDoubleClickedEvent<MaintenanceRequestRow>) => {
if (!event.data) {
return;
}
setEditingRowId(event.data.id);
}}
rowClassRules={{
'test-play-app__grid-row--odd': (params) => ((params.node.rowIndex ?? 0) + 1) % 2 === 1,
'test-play-app__grid-row--even': (params) => ((params.node.rowIndex ?? 0) + 1) % 2 === 0,
'test-play-app__grid-row--dirty': (params) => Boolean(params.data?.isDirty),
'test-play-app__grid-row--active': (params) => params.data?.id === editingRowId,
}}
/>
</Spin>
</div>
</section>
</div>
<div className={`test-play-app__editor-shell ${editingRow ? 'test-play-app__editor-shell--open' : ''}`} aria-hidden={editingRow ? 'false' : 'true'}>
<button
type="button"
className="test-play-app__editor-backdrop"
aria-label="편집 패널 닫기"
onClick={() => {
setEditingRowId(null);
}}
/>
<section className="test-play-app__editor-panel" aria-label="점검 요청 편집 패널">
<header className="test-play-app__editor-header">
<div className="test-play-app__editor-heading">
<strong>{editingRow?.equipmentName ?? '점검 요청 편집'}</strong>
<span>{editingRow ? `${editingRow.requestNo} · ${editingRow.lineCode} · ${editingRow.issueType}` : '행 더블클릭으로 편집 패널을 엽니다.'}</span>
</div>
<div className="test-play-app__editor-actions">
<Popconfirm
title="현재 요청을 삭제할까요?"
description="삭제 후에는 목록에서 제거되고 새로고침 후에도 복구되지 않습니다."
okText="삭제"
cancelText="취소"
okButtonProps={{ danger: true, loading: isDeleting }}
onConfirm={() => void handleDelete()}
disabled={!editingRow}
>
<Tooltip title="삭제">
<Button
danger
type="default"
className="test-play-app__icon-button test-play-app__icon-button--danger test-play-app__editor-delete"
aria-label="삭제"
icon={<DeleteOutlined />}
loading={isDeleting}
disabled={!editingRow}
/>
</Tooltip>
</Popconfirm>
<Tooltip title="적용 후 닫기">
<Button
className="test-play-app__icon-button test-play-app__icon-button--primary test-play-app__editor-apply"
aria-label="적용 후 닫기"
icon={<CheckOutlined />}
type="primary"
disabled={!editingRow}
onClick={() => {
if (!editingRow) {
return;
}
setEditingRowId(null);
}}
/>
</Tooltip>
<Tooltip title="닫기">
<Button
type="default"
className="test-play-app__icon-button test-play-app__editor-close"
aria-label="닫기"
icon={<CloseOutlined />}
onClick={() => {
setEditingRowId(null);
}}
/>
</Tooltip>
</div>
</header>
<div className="test-play-app__editor-body">
<section className="test-play-app__editor-summary">
<div className="test-play-app__editor-card">
<span className="test-play-app__editor-card-label"></span>
<strong>{editingRow?.requestNo ?? '-'}</strong>
</div>
<div className="test-play-app__editor-card">
<span className="test-play-app__editor-card-label"></span>
<strong>{editingRow?.requesterName ?? '-'}</strong>
</div>
<div className="test-play-app__editor-card">
<span className="test-play-app__editor-card-label"></span>
<strong>{editingRow?.requestedAt ? editingRow.requestedAt.replace('T', ' ').slice(0, 16) : '-'}</strong>
</div>
<div className="test-play-app__editor-card">
<span className="test-play-app__editor-card-label"></span>
<strong>{editingRow?.lastActionAt ? editingRow.lastActionAt.replace('T', ' ').slice(0, 16) : '-'}</strong>
</div>
</section>
<section className="test-play-app__editor-form">
<div className="test-play-app__editor-form-grid">
<label className="test-play-app__editor-field">
<span></span>
<Input value={editingRow?.equipmentName ?? ''} readOnly />
</label>
<label className="test-play-app__editor-field">
<span></span>
<Input value={editingRow?.issueType ?? ''} readOnly />
</label>
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.priority ? 'test-play-app__editor-field--edited' : ''}`}>
<span></span>
<Select
value={editingRow?.priority}
options={EDITABLE_PRIORITY_OPTIONS.map((value) => ({ value, label: value }))}
disabled={!editingRow}
onChange={(value) => {
if (!editingRow) {
return;
}
updateRowDraft(editingRow.id, { priority: value });
}}
/>
</label>
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.status ? 'test-play-app__editor-field--edited' : ''}`}>
<span></span>
<Select
value={editingRow?.status}
options={EDITABLE_STATUS_OPTIONS.map((value) => ({ value, label: value }))}
disabled={!editingRow}
onChange={(value) => {
if (!editingRow) {
return;
}
updateRowDraft(editingRow.id, { status: value });
}}
/>
</label>
<label className={`test-play-app__editor-field ${editingRow && editedFieldMap[editingRow.id]?.assigneeName ? 'test-play-app__editor-field--edited' : ''}`}>
<span></span>
<Input
value={editingRow?.assigneeName ?? ''}
placeholder="담당자를 입력하세요."
disabled={!editingRow}
onChange={(event) => {
if (!editingRow) {
return;
}
updateRowDraft(editingRow.id, { assigneeName: event.target.value });
}}
/>
</label>
<div className="test-play-app__editor-note">
<strong> </strong>
<span> , , . .</span>
</div>
</div>
</section>
</div>
</section>
</div>
</>
);
}

View File

@@ -0,0 +1,260 @@
import { appendClientIdHeader } from '../../../../app/main/clientIdentity';
import { getRegisteredAccessToken } from '../../../../app/main/tokenAccess';
const WORK_SERVER_TIMEOUT_MS = 10000;
export type TestAppMaintenanceRequestRow = {
id: number;
requestNo: string;
lineCode: string;
equipmentName: string;
issueType: string;
priority: '긴급' | '높음' | '보통' | '낮음';
requesterName: string;
assigneeName: string;
status: '접수' | '배정완료' | '조치중' | '부품대기' | '완료';
requestedAt: string;
lastActionAt: string;
isDirty?: boolean;
};
export type TestAppMaintenanceRequestFilters = {
keyword: string;
lineCode: string;
priority: string;
status: string;
requestedFrom: string;
requestedTo: string;
};
export type TestAppMaintenanceRequestListResponse = {
ok: true;
items: TestAppMaintenanceRequestRow[];
page: number;
pageSize: number;
total: number;
hasNext: boolean;
filters: {
keyword: string;
lineCode: string;
priority: string;
status: string;
requestedFrom: string | null;
requestedTo: string | null;
};
};
export type TestAppLegacyStatus = '대기' | '진행' | '완료' | '점검필요';
export type TestAppGridRow = {
id: number;
pressureWindow: string;
status: TestAppLegacyStatus;
measuredValue: number;
createdAt: string;
updatedAt: string;
isDirty?: boolean;
};
export type TestAppFilters = {
pressureWindow: string;
status: string;
minMeasuredValue: string;
maxMeasuredValue: string;
dateFrom: string;
dateTo: string;
};
export type TestAppListResponse = {
ok: true;
items: TestAppGridRow[];
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
filters: {
pressureWindow: string;
status: string;
minMeasuredValue: number | null;
maxMeasuredValue: number | null;
dateFrom: string | null;
dateTo: string | null;
};
};
function resolveWorkServerBaseUrl() {
if (import.meta.env.VITE_WORK_SERVER_URL) {
return import.meta.env.VITE_WORK_SERVER_URL;
}
return '/api';
}
const WORK_SERVER_BASE_URL = resolveWorkServerBaseUrl();
async function request<T>(path: string, init?: RequestInit) {
const headers = appendClientIdHeader(init?.headers);
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), WORK_SERVER_TIMEOUT_MS);
if (init?.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
const token = getRegisteredAccessToken();
if (token && !headers.has('X-Access-Token')) {
headers.set('X-Access-Token', token);
}
try {
const response = await fetch(`${WORK_SERVER_BASE_URL}${path}`, {
...init,
headers,
signal: controller.signal,
cache: init?.cache ?? 'no-store',
});
if (!response.ok) {
const text = await response.text();
try {
const payload = JSON.parse(text) as { message?: string };
throw new Error(payload.message || 'Test App 요청에 실패했습니다.');
} catch {
throw new Error(text || 'Test App 요청에 실패했습니다.');
}
}
return response.json() as Promise<T>;
} finally {
window.clearTimeout(timeoutId);
}
}
function toLegacyRow(item: TestAppMaintenanceRequestRow): TestAppGridRow {
return {
pressureWindow: `${item.equipmentName} / ${item.requestNo}`,
status:
item.status === '접수'
? '대기'
: item.status === '배정완료'
? '점검필요'
: item.status === '조치중'
? '진행'
: '완료',
measuredValue:
item.priority === '긴급'
? 95
: item.priority === '높음'
? 78
: item.priority === '보통'
? 54
: 28,
createdAt: item.requestedAt,
updatedAt: item.lastActionAt,
id: item.id,
};
}
export async function fetchTestAppMaintenanceRequests(
page: number,
pageSize: number,
filters: TestAppMaintenanceRequestFilters,
) {
const params = new URLSearchParams({
page: String(page),
pageSize: String(pageSize),
});
if (filters.keyword.trim()) {
params.set('keyword', filters.keyword.trim());
}
if (filters.lineCode && filters.lineCode !== '전체') {
params.set('lineCode', filters.lineCode);
}
if (filters.priority && filters.priority !== '전체') {
params.set('priority', filters.priority);
}
if (filters.status && filters.status !== '전체') {
params.set('status', filters.status);
}
if (filters.requestedFrom) {
params.set('requestedFrom', filters.requestedFrom);
}
if (filters.requestedTo) {
params.set('requestedTo', filters.requestedTo);
}
return request<TestAppMaintenanceRequestListResponse>(`/test-app/maintenance-requests?${params.toString()}`);
}
export async function saveTestAppMaintenanceRequests(items: TestAppMaintenanceRequestRow[]) {
return request<{ ok: true; count: number; items: TestAppMaintenanceRequestRow[] }>('/test-app/maintenance-requests', {
method: 'PUT',
body: JSON.stringify({
items: items.map((item) => ({
id: item.id,
priority: item.priority,
status: item.status,
assigneeName: item.assigneeName,
})),
}),
});
}
export async function deleteTestAppMaintenanceRequest(id: number) {
return request<{ ok: true; deletedId: number; item: TestAppMaintenanceRequestRow }>(`/test-app/maintenance-requests/${id}`, {
method: 'DELETE',
});
}
export async function fetchTestAppMeasurements(page: number, pageSize: number, filters: TestAppFilters) {
const response = await fetchTestAppMaintenanceRequests(page, pageSize, {
keyword: filters.pressureWindow,
lineCode: '전체',
priority: '전체',
status: filters.status,
requestedFrom: filters.dateFrom,
requestedTo: filters.dateTo,
});
return {
ok: true,
items: response.items.map(toLegacyRow),
pagination: {
page: response.page,
pageSize: response.pageSize,
total: response.total,
totalPages: Math.max(1, Math.ceil(response.total / response.pageSize)),
},
filters: {
pressureWindow: response.filters.keyword,
status: response.filters.status,
minMeasuredValue: null,
maxMeasuredValue: null,
dateFrom: response.filters.requestedFrom,
dateTo: response.filters.requestedTo,
},
} satisfies TestAppListResponse;
}
export async function saveTestAppMeasurements(items: TestAppGridRow[]) {
return request<{ ok: true; count: number; items: TestAppMaintenanceRequestRow[] }>('/test-app/maintenance-requests', {
method: 'PUT',
body: JSON.stringify({
items: items.map((item) => ({
id: item.id,
priority: item.measuredValue >= 85 ? '긴급' : item.measuredValue >= 65 ? '높음' : item.measuredValue >= 40 ? '보통' : '낮음',
status: item.status === '대기' ? '접수' : item.status === '점검필요' ? '배정완료' : item.status === '진행' ? '조치중' : '완료',
assigneeName: item.status === '대기' ? '' : '호환저장',
})),
}),
});
}