refactor(frontend): finish PrimeVue adapters for DatePicker, StatusChip, TabPanel, DataGrid, MasterGrid
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 20s
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 11s
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 12s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 9s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 9s

This commit is contained in:
2026-07-26 02:48:10 +09:00
parent aa8438e9bf
commit af3b1c72aa
5 changed files with 249 additions and 372 deletions
+53 -162
View File
@@ -1,168 +1,59 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { AgGridVue } from 'ag-grid-vue3';
import GridHeaderToolbar from './grid/GridHeaderToolbar.vue';
import type { ColDef, GridApi, GridReadyEvent, FirstDataRenderedEvent, CellValueChangedEvent, RowSelectionOptions } from 'ag-grid-community';
const props = defineProps<{
columnDefs: ColDef[];
rowData: any[];
rowSelection?: 'single' | 'multiple' | RowSelectionOptions;
filename?: string;
}>();
const emit = defineEmits(['row-selected', 'cell-value-changed', 'cell-double-clicked']);
const gridApi = ref<GridApi | null>(null);
const selectedCount = ref(0);
const quickFilterText = ref('');
// AG Grid v36 rowSelection deprecation 대응
const normalizedRowSelection = computed<RowSelectionOptions>(() => {
if (typeof props.rowSelection === 'object' && props.rowSelection !== null) {
return props.rowSelection;
}
if (props.rowSelection === 'multiple') {
return { mode: 'multiRow' };
}
return { mode: 'singleRow' };
});
const onGridReady = (params: GridReadyEvent) => {
gridApi.value = params.api;
setTimeout(() => {
params.api.sizeColumnsToFit();
}, 100);
};
const onFirstDataRendered = (params: FirstDataRenderedEvent) => {
params.api.sizeColumnsToFit();
};
const onSelectionChanged = () => {
if (!gridApi.value) return;
const selectedNodes = gridApi.value.getSelectedNodes();
selectedCount.value = selectedNodes.length;
const selectedData = selectedNodes.map(node => node.data);
emit('row-selected', selectedRowPayload(selectedData));
};
const onCellDoubleClicked = (event: any) => {
emit('cell-double-clicked', event);
};
const selectedRowPayload = (selectedData: any[]) => {
if (props.rowSelection === 'multiple' || (typeof props.rowSelection === 'object' && props.rowSelection?.mode === 'multiRow')) {
return selectedData;
}
return selectedData.length > 0 ? selectedData[0] : null;
};
const onCellValueChanged = (event: CellValueChangedEvent) => {
emit('cell-value-changed', event);
};
const resetGridState = () => {
if (!gridApi.value) return;
gridApi.value.setFilterModel(null);
gridApi.value.resetColumnState();
quickFilterText.value = '';
gridApi.value.sizeColumnsToFit();
};
const autoSizeColumns = () => {
if (!gridApi.value) return;
gridApi.value.sizeColumnsToFit();
};
const exportToCsv = () => {
if (!gridApi.value) return;
gridApi.value.exportDataAsCsv({
fileName: `${props.filename || 'quant_grid_export'}_${new Date().toISOString().substring(0, 10)}.csv`
});
};
defineExpose({ exportToExcel: exportToCsv, resetGridState, autoSizeColumns, gridApi });
</script>
<!-- PrimeVue DataTable Adapter Wrapper: QuantDataGrid -->
<template>
<div class="quant-grid-masterpiece-container flex flex-col h-full w-full border border-slate-300 bg-white rounded-md overflow-hidden shadow-sm">
<!-- Componentized Grid Header Toolbar (GridHeaderToolbar) -->
<GridHeaderToolbar
v-model:quickFilterText="quickFilterText"
:totalCount="rowData.length"
:selectedCount="selectedCount"
:columnCount="columnDefs.length"
@reset="resetGridState"
@autoSize="autoSizeColumns"
@exportCsv="exportToCsv"
/>
<!-- AG Grid Container (Auto-Fit Columns & Compact High-Density View) -->
<div class="flex-1 ag-theme-alpine w-full grid-wrapper">
<ag-grid-vue
class="h-full w-full quant-ag-grid-instance"
theme="legacy"
:columnDefs="columnDefs"
:rowData="rowData"
:quickFilterText="quickFilterText"
:rowHeight="36"
:headerHeight="38"
:defaultColDef="{
resizable: true,
sortable: true,
filter: true,
flex: 1,
minWidth: 90
}"
:rowSelection="normalizedRowSelection"
@grid-ready="onGridReady"
@first-data-rendered="onFirstDataRendered"
@selection-changed="onSelectionChanged"
@cell-value-changed="onCellValueChanged"
@cell-double-clicked="onCellDoubleClicked"
/>
</div>
<div class="quant-datagrid-wrapper w-full overflow-hidden border border-slate-300 rounded-lg shadow-2xs bg-white">
<DataTable
:value="items"
:loading="loading"
:paginator="paginator"
:rows="rows || 10"
:rowsPerPageOptions="[10, 25, 50, 100]"
dataKey="id"
responsiveLayout="scroll"
class="p-datatable-sm w-full text-xs"
tableStyle="min-width: 50rem"
>
<template #empty>
<div class="text-center py-6 text-slate-400 font-medium">
조회된 데이터가 없습니다.
</div>
</template>
<style scoped>
.quant-grid-masterpiece-container {
min-height: 480px;
<Column
v-for="col in headers"
:key="col.key"
:field="col.key"
:header="col.label"
:style="{ width: col.width || 'auto', textAlign: col.align || 'left' }"
sortable
>
<template #body="slotProps">
<slot :name="col.key" :item="slotProps.data" :value="slotProps.data[col.key]">
<span class="font-medium text-slate-800">
{{ slotProps.data[col.key] ?? '-' }}
</span>
</slot>
</template>
</Column>
</DataTable>
</div>
</template>
<script setup lang="ts">
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
export interface GridColumn {
key: string;
label: string;
width?: string;
align?: 'left' | 'center' | 'right';
}
.grid-wrapper {
height: calc(100% - 44px);
min-height: 440px;
display: flex;
flex-direction: column;
}
.quant-ag-grid-instance {
height: 100% !important;
min-height: 440px !important;
flex: 1;
}
.ag-theme-alpine {
--ag-header-background-color: #F8FAFC;
--ag-header-foreground-color: #1E293B;
--ag-selected-row-background-color: rgba(37, 99, 235, 0.12);
--ag-row-hover-color: rgba(226, 232, 240, 0.5);
--ag-font-size: 12px;
--ag-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--ag-border-color: #E2E8F0;
}
:deep(.ag-cell) {
display: flex;
align-items: center;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
:deep(.ag-header-cell-label) {
font-weight: 700;
color: #334155;
}
</style>
const props = defineProps<{
headers: GridColumn[];
items: any[];
loading?: boolean;
paginator?: boolean;
rows?: number;
}>();
</script>
+53 -47
View File
@@ -1,51 +1,57 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
const props = defineProps<{
modelValue: string
readonly?: boolean
}>()
const emit = defineEmits(['update:modelValue', 'enter'])
const isFocused = ref(false)
const formattedDate = computed(() => {
const v = String(props.modelValue || '').replace(/[^0-9]/g, '')
if (v.length === 8) {
return `${v.substring(0,4)}-${v.substring(4,6)}-${v.substring(6,8)}`
}
return props.modelValue
})
const onInput = (e: Event) => {
const target = e.target as HTMLInputElement
const raw = target.value.replace(/[^0-9]/g, '')
emit('update:modelValue', raw)
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Enter') emit('enter')
}
</script>
<!-- PrimeVue DatePicker Adapter Wrapper: QuantDatePicker -->
<template>
<div style="display: inline-flex; align-items: center; width: 100%;">
<input
:value="formattedDate"
type="text"
placeholder="YYYY-MM-DD"
:readonly="readonly"
:style="{
borderColor: isFocused ? '#2980B9' : '#CBD5E1',
boxShadow: isFocused ? '0 0 4px rgba(41, 128, 185, 0.4)' : 'none',
backgroundColor: readonly ? '#ECF0F1' : '#FFFFFF'
}"
style="width: 100%; padding: 4px 8px; border: 1px solid #CBD5E1; border-radius: 2px; font-size: 12px; font-weight: bold; outline: none;"
@focus="isFocused = true"
@blur="isFocused = false"
@input="onInput"
@keydown="onKeyDown"
<div class="quant-datepicker-wrapper flex flex-col gap-1 w-full">
<label v-if="label" class="text-xs font-bold text-slate-800">
{{ label }} <span v-if="required" class="text-rose-600">*</span>
</label>
<DatePicker
:id="id"
:modelValue="dateValue"
dateFormat="yy-mm-dd"
:placeholder="placeholder || 'YYYY-MM-DD'"
:disabled="disabled"
class="w-full text-xs"
inputClass="w-full h-9 px-3 border border-slate-300 rounded text-xs text-slate-900 font-medium bg-white placeholder:text-slate-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-slate-100"
showIcon
iconDisplay="input"
@update:modelValue="onDateChange"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import DatePicker from 'primevue/datepicker';
const props = defineProps<{
id?: string;
label?: string;
modelValue?: string | Date;
placeholder?: string;
required?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits(['update:modelValue', 'change']);
const dateValue = computed(() => {
if (!props.modelValue) return null;
if (props.modelValue instanceof Date) return props.modelValue;
const d = new Date(props.modelValue);
return isNaN(d.getTime()) ? null : d;
});
const onDateChange = (val: Date | null) => {
if (!val) {
emit('update:modelValue', '');
emit('change', '');
return;
}
const yyyy = val.getFullYear();
const mm = String(val.getMonth() + 1).padStart(2, '0');
const dd = String(val.getDate()).padStart(2, '0');
const strVal = `${yyyy}-${mm}-${dd}`;
emit('update:modelValue', strVal);
emit('change', strVal);
};
</script>
+53 -76
View File
@@ -1,86 +1,63 @@
<script setup lang="ts">
const props = defineProps<{
title?: string
headers: Array<{ key: string; label: string; width?: string; align?: 'left' | 'center' | 'right' }>
items: any[]
loading?: boolean
selectedId?: any
}>()
const emit = defineEmits(['selectRow', 'create', 'refresh'])
const onRowClick = (item: any) => {
emit('selectRow', item)
}
</script>
<!-- PrimeVue DataTable Adapter Wrapper: QuantMasterGrid -->
<template>
<div class="card shadow-sm border h-100 d-flex flex-column">
<div class="card-header bg-navy text-white d-flex justify-content-between align-items-center py-2 px-3">
<h5 class="card-title m-0 font-weight-bold text-white fs-6">
<i class="ti ti-list me-1"></i> {{ title || '데이터 그리드 목록' }}
</h5>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-primary fw-bold" @click="emit('create')">
<i class="ti ti-plus me-1"></i> 신규 등록
</button>
<button type="button" class="btn btn-sm btn-outline-light fw-bold" @click="emit('refresh')">
<i class="ti ti-refresh me-1"></i> 새로고침
</button>
</div>
<div class="quant-master-grid-wrapper w-full overflow-hidden border border-slate-300 rounded shadow-2xs bg-white">
<DataTable
:value="items || []"
dataKey="id"
responsiveLayout="scroll"
class="p-datatable-sm w-full text-xs"
:style="{ maxHeight: height ? height + 'px' : '300px' }"
>
<template #empty>
<div class="text-center py-4 text-slate-400 font-medium">
마스터 데이터가 없습니다.
</div>
</template>
<div class="table-responsive flex-grow-1">
<table class="table table-hover table-vcenter card-table text-nowrap mb-0">
<thead class="bg-light">
<tr>
<th
v-for="h in headers"
:key="h.key"
:style="{ width: h.width || 'auto', textAlign: h.align || 'left' }"
class="fw-bold fs-7 text-uppercase"
<Column
v-for="col in gridHeaders"
:key="col.key"
:field="col.key"
:header="col.label"
:style="{ width: col.width || 'auto', textAlign: col.align || 'left' }"
>
{{ h.label }}
</th>
</tr>
</thead>
<tbody>
<template v-if="items && items.length > 0">
<tr
v-for="(item, idx) in items"
:key="idx"
:class="{ 'table-active fw-bold': selectedId && item.id === selectedId }"
style="cursor: pointer;"
@click="onRowClick(item)"
>
<td
v-for="h in (headers || [])"
:key="h.key"
:style="{ textAlign: h.align || 'left' }"
class="fs-7"
>
<slot :name="`cell-${h.key}`" :item="item" :value="item[h.key]">
{{ item[h.key] }}
<template #body="slotProps">
<slot :name="col.key" :item="slotProps.data" :value="slotProps.data[col.key]">
<span class="font-medium text-slate-800">
{{ slotProps.data[col.key] ?? '-' }}
</span>
</slot>
</td>
</tr>
</template>
<template v-else>
<tr>
<td :colspan="(headers || []).length || 1" class="text-center py-4 text-muted">
<i class="ti ti-database-off fs-2 d-block mb-1"></i>
조회된 데이터가 없습니다.
</td>
</tr>
</template>
</tbody>
</table>
</div>
</Column>
</DataTable>
</div>
</template>
<style scoped>
.bg-navy {
background-color: #1E293B;
<script setup lang="ts">
import { computed } from 'vue';
import DataTable from 'primevue/datatable';
import Column from 'primevue/column';
export interface GridHeader {
key: string;
label: string;
width?: string;
align?: 'left' | 'center' | 'right';
}
</style>
const props = defineProps<{
headers?: GridHeader[];
items?: any[];
height?: number;
}>();
const gridHeaders = computed(() => {
if (props.headers && props.headers.length > 0) return props.headers;
// Fallback defaults if not supplied
return [
{ key: 'code', label: '코드', width: '120px' },
{ key: 'name', label: '명칭' },
{ key: 'status', label: '상태', width: '90px', align: 'center' }
];
});
</script>
+29 -16
View File
@@ -1,18 +1,31 @@
<script setup lang="ts">
defineProps<{
type: 'PASS' | 'LIMIT' | 'FAIL' | 'ACTIVE' | 'ARCHIVED' | 'APPROVED' | 'SHADOW'
label?: string
}>()
</script>
<!-- PrimeVue Tag Adapter Wrapper: QuantStatusChip -->
<template>
<span
:style="{
backgroundColor: type === 'PASS' || type === 'ACTIVE' || type === 'APPROVED' ? '#E8F8F5' : type === 'LIMIT' ? '#FEF9E7' : '#FDEDEC',
color: type === 'PASS' || type === 'ACTIVE' || type === 'APPROVED' ? '#117864' : type === 'LIMIT' ? '#B9770E' : '#922B21',
border: '1px solid ' + (type === 'PASS' || type === 'ACTIVE' || type === 'APPROVED' ? '#2ECC71' : type === 'LIMIT' ? '#F39C12' : '#E74C3C')
}"
style="padding: 2px 8px; border-radius: 10px; font-weight: bold; font-size: 11px; display: inline-block;">
{{ label || type }}
</span>
<Tag
:value="label || status"
:severity="severity"
class="quant-status-chip font-bold px-2 py-0.5 text-[11px] rounded shadow-2xs"
/>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Tag from 'primevue/tag';
const props = defineProps<{
status?: string;
label?: string;
type?: 'success' | 'warning' | 'danger' | 'info' | 'secondary';
}>();
const severity = computed(() => {
if (props.type) {
if (props.type === 'danger') return 'warn'; // or 'danger'
return props.type;
}
const s = (props.status || '').toLowerCase();
if (s.includes('완료') || s.includes('승인') || s.includes('success') || s.includes('active')) return 'success';
if (s.includes('대기') || s.includes('진행') || s.includes('warning') || s.includes('pending')) return 'warn';
if (s.includes('반려') || s.includes('오류') || s.includes('취소') || s.includes('error') || s.includes('failed')) return 'danger';
return 'secondary';
});
</script>
+55 -65
View File
@@ -1,74 +1,64 @@
<script setup lang="ts">
import { ref } from 'vue'
<!-- PrimeVue Tabs Adapter Wrapper: QuantTabPanel -->
<template>
<div class="quant-tab-panel-wrapper w-full flex flex-col h-full">
<Tabs :value="activeTabId" class="w-full h-full flex flex-col" @update:value="onTabChange">
<TabList class="bg-slate-100 border-b border-slate-300">
<Tab
v-for="tab in tabs"
:key="tab.id"
:value="tab.id"
class="px-4 py-2.5 text-xs font-bold text-slate-700 focus:outline-none cursor-pointer border-b-2 border-transparent data-[p-active=true]:border-blue-600 data-[p-active=true]:text-blue-600"
>
{{ tab.label }}
<span v-if="tab.badge" class="ml-1.5 px-1.5 py-0.5 rounded-full text-[10px] bg-slate-200 text-slate-700">
{{ tab.badge }}
</span>
</Tab>
</TabList>
interface TabItem {
id: string
label: string
icon?: string
badge?: string | number
<TabPanels class="flex-1 p-4 bg-white overflow-y-auto">
<TabPanel v-for="tab in tabs" :key="tab.id" :value="tab.id">
<slot :name="tab.id"></slot>
</TabPanel>
</TabPanels>
</Tabs>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import Tabs from 'primevue/tabs';
import TabList from 'primevue/tablist';
import Tab from 'primevue/tab';
import TabPanels from 'primevue/tabpanels';
import TabPanel from 'primevue/tabpanel';
export interface TabItem {
id: string;
label: string;
badge?: string | number;
}
const props = defineProps<{
tabs: TabItem[]
activeTabId?: string
}>()
tabs: TabItem[];
modelValue?: string;
}>();
const emit = defineEmits(['changeTab'])
const emit = defineEmits(['update:modelValue', 'tab-change']);
const currentTab = ref(props.activeTabId || (props.tabs.length > 0 ? props.tabs[0].id : ''))
const activeTabId = ref<string>(props.modelValue || (props.tabs[0] ? props.tabs[0].id : ''));
const selectTab = (tabId: string) => {
currentTab.value = tabId
emit('changeTab', tabId)
watch(
() => props.modelValue,
(newVal) => {
if (newVal) activeTabId.value = newVal;
}
);
const onTabChange = (val: string | number | undefined) => {
const strVal = String(val ?? '');
activeTabId.value = strVal;
emit('update:modelValue', strVal);
emit('tab-change', strVal);
};
</script>
<template>
<div class="card shadow-sm border w-100 h-100 d-flex flex-column">
<!-- Header with Tab Controls -->
<div class="card-header bg-navy text-white p-0 d-flex justify-content-between align-items-center">
<ul class="nav nav-tabs card-header-tabs m-0 border-0">
<li v-for="tab in tabs" :key="tab.id" class="nav-item">
<button
type="button"
class="nav-link px-3 py-2 border-0 fw-bold fs-7 rounded-0"
:class="{ 'active bg-white text-navy': currentTab === tab.id, 'text-light': currentTab !== tab.id }"
@click="selectTab(tab.id)"
>
<i v-if="tab.icon" :class="[tab.icon, 'me-1']"></i>
{{ tab.label }}
<span v-if="tab.badge" class="badge bg-primary ms-1 fs-8">{{ tab.badge }}</span>
</button>
</li>
</ul>
<div class="pe-3">
<slot name="header-actions"></slot>
</div>
</div>
<!-- Tab Content Body Area -->
<div class="card-body p-3 flex-grow-1 overflow-auto bg-light">
<template v-for="tab in tabs" :key="tab.id">
<div v-show="currentTab === tab.id" class="h-100">
<slot :name="`tab-${tab.id}`">
<div class="text-muted p-3 text-center">
[{{ tab.label }}] 영역입니다.
</div>
</slot>
</div>
</template>
</div>
</div>
</template>
<style scoped>
.bg-navy {
background-color: #1E293B;
}
.text-navy {
color: #1E293B !important;
}
.nav-link.active {
border-top: 3px solid #3B82F6 !important;
}
</style>