feat: add play apps and layout tools
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
260
src/views/play/apps/test/testPlayAppApi.ts
Normal file
260
src/views/play/apps/test/testPlayAppApi.ts
Normal 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 === '대기' ? '' : '호환저장',
|
||||
})),
|
||||
}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user