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
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user