corrad-bp/components/RepeatingTable.vue
Afiq d0cef85b72 Enhance ComponentPreview and RepeatingTable Functionality
- Updated ComponentPreview.vue to improve the handling of repeating groups, including the addition of helper functions for field value retrieval and input handling.
- Enhanced the rendering of repeating groups with better structure and improved user experience, including dynamic item addition and removal.
- Modified RepeatingTable.vue to increase the maximum visible columns from 20 to 50, allowing for better data presentation and horizontal scrolling.
- Improved column width calculations and added a minimum table width to ensure proper layout and usability.
- Updated safeGetField.js to allow for optional warning suppression, enhancing flexibility in data access without unnecessary console warnings.
- Refined styles across components for better visual consistency and usability.
2025-08-07 14:17:51 +08:00

1376 lines
33 KiB
Vue

<template>
<div class="repeating-table-container">
<!-- Header Section -->
<div class="repeating-table-header">
<div class="header-content">
<div class="header-left">
<h3 v-if="config.label" class="table-title">{{ config.label }}</h3>
<p v-if="config.help" class="table-help">{{ config.help }}</p>
</div>
<div v-if="showRecordCount" class="header-right">
<div class="record-count">
{{ data.length }} record{{ data.length !== 1 ? 's' : '' }}
<span v-if="config.maxRecords" class="record-limit">/ {{ config.maxRecords }} max</span>
</div>
</div>
</div>
</div>
<!-- Controls Section -->
<div v-if="config.enableSearch || (!isAddDisabled && data.length > 0)" class="controls-section">
<!-- Search Bar -->
<div v-if="config.enableSearch" class="search-container">
<div class="search-input-wrapper">
<Icon name="heroicons:magnifying-glass" class="search-icon" />
<input
v-model="searchQuery"
type="text"
placeholder="Search records..."
class="search-input"
/>
<RsButton
v-if="searchQuery"
@click="searchQuery = ''"
variant="secondary-text"
size="sm"
title="Clear search"
>
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</RsButton>
</div>
</div>
<!-- Add Button (only show when there are existing records) -->
<RsButton
v-if="data.length > 0"
@click="openAddModal"
:disabled="isAddDisabled"
variant="primary"
size="sm"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
{{ config.buttonText || 'Add Record' }}
</RsButton>
</div>
<!-- Data Table -->
<div class="table-container">
<!-- Performance warning for too many columns -->
<div v-if="config.columns.length > 50" class="performance-warning">
<div class="warning-content">
<Icon name="heroicons:information-circle" class="warning-icon" />
<div class="warning-text">
<p class="warning-title">Performance Notice</p>
<p class="warning-message">
This table has {{ config.columns.length }} columns. For optimal performance, only the first 50 columns are displayed.
The table is scrollable horizontally to view all data. Consider reducing the number of columns for better user experience.
</p>
</div>
</div>
</div>
<div v-if="paginatedData.length > 0" class="table-wrapper">
<div class="table-scroll-container" ref="tableContainer">
<table class="data-table" :style="{ minWidth: calculateMinTableWidth }">
<thead class="table-header">
<tr>
<th v-if="config.showRowNumbers" class="row-number-header">#</th>
<th
v-for="column in visibleColumns"
:key="column.name"
class="column-header"
:style="{ width: getColumnWidth(column), minWidth: getColumnWidth(column) }"
>
<div class="column-header-content">
<span class="header-text">{{ column.label }}</span>
<span v-if="column.required" class="required-indicator">*</span>
</div>
</th>
<th v-if="showActions" class="actions-header">Actions</th>
</tr>
</thead>
<tbody class="table-body">
<tr
v-for="(record, index) in paginatedData"
:key="getRecordKey(record, index)"
class="data-row"
:class="{ 'row-hover': !isPreview }"
>
<td v-if="config.showRowNumbers" class="row-number-cell">
<div class="row-number">{{ getRowNumber(index) }}</div>
</td>
<td
v-for="column in visibleColumns"
:key="column.name"
class="data-cell"
:style="{ width: getColumnWidth(column), minWidth: getColumnWidth(column) }"
>
<div class="cell-content">
<SimpleCellValue
:value="record[column.name]"
:column="column"
:record="record"
/>
</div>
</td>
<td v-if="showActions" class="actions-cell">
<div class="action-buttons-row">
<RsButton
v-if="config.allowEdit"
@click="openEditModal(record, getOriginalIndex(record))"
variant="info-text"
size="sm"
title="Edit record"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
</RsButton>
<RsButton
v-if="config.allowDelete"
@click="deleteRecord(getOriginalIndex(record))"
variant="danger-text"
size="sm"
title="Delete record"
>
<Icon name="heroicons:trash" class="w-4 h-4" />
</RsButton>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Empty State -->
<div v-else class="empty-state">
<div class="empty-content">
<Icon name="heroicons:table-cells" class="empty-icon" />
<h3 class="empty-title">No records yet</h3>
<p class="empty-message">
{{ config.help || 'Add your first record to get started.' }}
</p>
<RsButton
v-if="!isAddDisabled"
@click="openAddModal"
variant="primary"
size="sm"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
{{ config.buttonText || 'Add Record' }}
</RsButton>
</div>
</div>
</div>
<!-- Pagination -->
<div v-if="totalPages > 1" class="pagination-container">
<div class="pagination-info">
Showing {{ startRecord }} to {{ endRecord }} of {{ filteredData.length }} records
</div>
<div class="pagination-controls">
<RsButton
@click="goToPage(currentPage - 1)"
:disabled="currentPage === 1"
variant="secondary-outline"
size="sm"
>
<Icon name="heroicons:chevron-left" class="w-4 h-4" />
Previous
</RsButton>
<div class="page-numbers">
<RsButton
v-for="page in visiblePages"
:key="page"
@click="page === '...' ? null : goToPage(page)"
:variant="page === currentPage ? 'primary' : 'secondary-outline'"
size="sm"
:disabled="page === '...'"
class="page-button"
>
{{ page }}
</RsButton>
</div>
<RsButton
@click="goToPage(currentPage + 1)"
:disabled="currentPage === totalPages"
variant="secondary-outline"
size="sm"
>
Next
<Icon name="heroicons:chevron-right" class="w-4 h-4" />
</RsButton>
</div>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click="closeModal">
<div class="modal-container" @click.stop>
<div class="modal-header">
<h3 class="modal-title">
{{ editingIndex !== null ? 'Edit Record' : 'Add Record' }}
</h3>
<RsButton @click="closeModal" variant="secondary-text" size="sm">
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</RsButton>
</div>
<div class="modal-body">
<FormKit
:id="formId"
type="form"
:actions="false"
v-model="formData"
@submit="saveRecord"
class="record-form"
>
<div class="form-grid">
<div
v-for="column in config.columns"
:key="column.name"
class="form-field"
:class="{ 'form-field-full': column.type === 'textarea' }"
>
<FormKit
:type="getFormKitType(column.type)"
:label="column.label"
:name="column.name"
:placeholder="column.placeholder"
:validation="getValidationRules(column)"
:required="column.required"
:options="column.options"
:classes="{ outer: 'field-wrapper' }"
/>
</div>
</div>
</FormKit>
</div>
<div class="modal-footer">
<RsButton @click="closeModal" variant="secondary-outline">
Cancel
</RsButton>
<RsButton @click="submitForm" variant="primary">
<Icon name="heroicons:check" class="w-4 h-4 mr-2" />
{{ editingIndex !== null ? 'Update Record' : 'Add Record' }}
</RsButton>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteConfirm" class="modal-overlay" @click="cancelDelete">
<div class="modal-container delete-modal" @click.stop>
<div class="modal-header">
<h3 class="modal-title">Confirm Delete</h3>
<RsButton @click="cancelDelete" variant="secondary-text" size="sm">
<Icon name="heroicons:x-mark" class="w-4 h-4" />
</RsButton>
</div>
<div class="modal-body">
<div class="warning-content">
<Icon name="heroicons:exclamation-triangle" class="warning-icon" />
<div class="warning-text">
<p class="warning-title">Are you sure you want to delete this record?</p>
<p class="warning-message">This action cannot be undone.</p>
</div>
</div>
</div>
<div class="modal-footer">
<RsButton @click="cancelDelete" variant="secondary-outline">
Cancel
</RsButton>
<RsButton @click="confirmDelete" variant="danger">
<Icon name="heroicons:trash" class="w-4 h-4 mr-2" />
Delete Record
</RsButton>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { v4 as uuidv4 } from 'uuid'
import { useDebounceFn } from '@vueuse/core'
// Simple cell component that shows data immediately
const SimpleCellValue = defineComponent({
props: {
value: {
type: [String, Number, Boolean, Object],
default: null
},
column: {
type: Object,
required: true
},
record: {
type: Object,
required: true
}
},
setup(props) {
const formatValue = computed(() => {
const value = props.value
if (value === null || value === undefined || value === '') {
return '-'
}
if (props.column.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (props.column.type === 'time') {
return value
}
if (props.column.type === 'checkbox') {
return value ? 'Yes' : 'No'
}
if (props.column.type === 'select' && props.column.options) {
const option = props.column.options.find(opt => opt.value === value)
return option ? option.label : value
}
return value.toString()
})
return {
formatValue
}
},
template: `
<span class="cell-value">
{{ formatValue }}
</span>
`
})
const props = defineProps({
config: {
type: Object,
required: true
},
modelValue: {
type: Array,
default: () => []
},
isPreview: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
// Reactive state
const data = ref([...(props.modelValue || [])])
const searchQuery = ref('')
const showModal = ref(false)
const showDeleteConfirm = ref(false)
const editingIndex = ref(null)
const deleteIndex = ref(null)
const formData = ref({})
const formId = ref(`table-form-${uuidv4()}`)
const tableContainer = ref(null)
// Pagination state
const currentPage = ref(1)
const pageSize = ref(10)
// Performance optimizations
const columnCache = ref(new Map())
const recordKeys = ref(new Map())
// Debounced search for better performance
const debouncedSearch = useDebounceFn((query) => {
// Clear cache when search changes
columnCache.value.clear()
}, 300)
// Watch search query with debouncing
watch(searchQuery, (newQuery) => {
debouncedSearch(newQuery)
})
// Computed properties with memoization
const filteredData = computed(() => {
if (!searchQuery.value) return data.value
const query = searchQuery.value.toLowerCase()
return data.value.filter(record => {
return props.config.columns.some(column => {
const value = record[column.name]
return value && value.toString().toLowerCase().includes(query)
})
})
})
// Column virtualization for large datasets
const visibleColumns = computed(() => {
const columns = props.config.columns
const maxColumns = 50 // Increased limit for better user experience with scrolling
if (columns.length <= maxColumns) {
return columns
}
// Return first 50 columns for performance, but allow scrolling
return columns.slice(0, maxColumns)
})
// Column width calculation
const getColumnWidth = (column) => {
const baseWidth = 150 // Increased base width
const typeWidths = {
text: 150,
number: 100,
email: 180,
tel: 120,
date: 120,
time: 100,
url: 180,
select: 150,
checkbox: 80,
textarea: 200
}
// For tables with many columns, still use reasonable widths
const totalColumns = props.config.columns.length
if (totalColumns > 10) {
const adjustedWidths = {
text: 120,
number: 90,
email: 160,
tel: 110,
date: 110,
time: 90,
url: 160,
select: 120,
checkbox: 70,
textarea: 160
}
const width = `${adjustedWidths[column.type] || baseWidth}px`
console.log(`[RepeatingTable] Column ${column.name} width: ${width}`)
return width
}
const width = `${typeWidths[column.type] || baseWidth}px`
console.log(`[RepeatingTable] Column ${column.name} width: ${width}`)
return width
}
// Record key generation for better Vue rendering
const getRecordKey = (record, index) => {
if (!recordKeys.value.has(record)) {
recordKeys.value.set(record, `record-${index}-${Date.now()}`)
}
return recordKeys.value.get(record)
}
const isAddDisabled = computed(() => {
return props.config.maxRecords && data.value.length >= props.config.maxRecords
})
const showActions = computed(() => {
return props.config.allowEdit || props.config.allowDelete
})
const showRecordCount = computed(() => {
return data.value.length > 0 || props.config.maxRecords
})
// Pagination computed properties
const totalPages = computed(() => {
return Math.ceil(filteredData.value.length / pageSize.value)
})
const paginatedData = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
const end = start + pageSize.value
return filteredData.value.slice(start, end)
})
const startRecord = computed(() => {
if (filteredData.value.length === 0) return 0
return (currentPage.value - 1) * pageSize.value + 1
})
const endRecord = computed(() => {
const end = currentPage.value * pageSize.value
return Math.min(end, filteredData.value.length)
})
const visiblePages = computed(() => {
const delta = 2
const range = []
const rangeWithDots = []
for (let i = Math.max(2, currentPage.value - delta);
i <= Math.min(totalPages.value - 1, currentPage.value + delta);
i++) {
range.push(i)
}
if (currentPage.value - delta > 2) {
rangeWithDots.push(1, '...')
} else {
rangeWithDots.push(1)
}
rangeWithDots.push(...range)
if (currentPage.value + delta < totalPages.value - 1) {
rangeWithDots.push('...', totalPages.value)
} else {
rangeWithDots.push(totalPages.value)
}
return rangeWithDots.filter((item, index, array) => array.indexOf(item) === index)
})
// Guard to prevent recursive updates
const isUpdatingFromProps = ref(false)
// Watch for external data changes
watch(() => props.modelValue, (newValue) => {
// Handle the case where newValue might be undefined/null
const safeNewValue = newValue || []
// Prevent circular updates by checking if data actually changed
const newDataStr = JSON.stringify(safeNewValue)
const currentDataStr = JSON.stringify(data.value)
if (newDataStr === currentDataStr) return
isUpdatingFromProps.value = true
data.value = [...safeNewValue]
// Clear caches when data changes
columnCache.value.clear()
recordKeys.value.clear()
nextTick(() => {
isUpdatingFromProps.value = false
})
}, { deep: true, immediate: true })
// Watch for internal data changes and emit (only when not updating from props)
watch(data, (newData, oldData) => {
if (isUpdatingFromProps.value) return
// Check if data actually changed
const newDataStr = JSON.stringify(newData)
const oldDataStr = JSON.stringify(oldData)
if (newDataStr === oldDataStr) return
nextTick(() => {
emit('update:modelValue', [...newData])
})
}, { deep: true })
// Methods
const openAddModal = () => {
editingIndex.value = null
formData.value = {}
// Initialize form data with empty values
props.config.columns.forEach(column => {
formData.value[column.name] = getDefaultValue(column.type)
})
showModal.value = true
}
const openEditModal = (record, index) => {
editingIndex.value = index
formData.value = { ...record }
showModal.value = true
}
const closeModal = () => {
showModal.value = false
editingIndex.value = null
formData.value = {}
}
const submitForm = () => {
// Trigger form submission programmatically
const form = document.getElementById(formId.value)
if (form) {
form.requestSubmit()
}
}
const saveRecord = (formData) => {
if (editingIndex.value !== null) {
// Update existing record
data.value[editingIndex.value] = { ...formData }
} else {
// Add new record
data.value.push({ ...formData })
}
closeModal()
}
const deleteRecord = (index) => {
if (props.config.confirmDelete) {
deleteIndex.value = index
showDeleteConfirm.value = true
} else {
data.value.splice(index, 1)
}
}
const confirmDelete = () => {
if (deleteIndex.value !== null) {
data.value.splice(deleteIndex.value, 1)
}
cancelDelete()
}
const cancelDelete = () => {
showDeleteConfirm.value = false
deleteIndex.value = null
}
const getOriginalIndex = (record) => {
return data.value.findIndex(item => item === record)
}
const getRowNumber = (index) => {
return startRecord.value + index
}
const getFormKitType = (columnType) => {
const typeMap = {
text: 'text',
number: 'number',
email: 'email',
tel: 'tel',
date: 'date',
time: 'time',
url: 'url',
select: 'select',
checkbox: 'checkbox',
textarea: 'textarea'
}
return typeMap[columnType] || 'text'
}
const getValidationRules = (column) => {
let rules = []
if (column.required) {
rules.push('required')
}
if (column.validation) {
const customRules = column.validation.split('|').filter(rule => rule.trim())
rules.push(...customRules)
}
return rules.join('|')
}
const getDefaultValue = (type) => {
switch (type) {
case 'number':
return null
case 'checkbox':
return false
case 'date':
case 'time':
return ''
default:
return ''
}
}
const goToPage = (page) => {
currentPage.value = page
}
// Calculate minimum table width based on columns
const calculateMinTableWidth = computed(() => {
if (!props.config.columns) return 'auto'
let totalWidth = 0
// Add row number column width if enabled
if (props.config.showRowNumbers) {
totalWidth += 60 // Row number column width
}
// Add column widths
visibleColumns.value.forEach(column => {
const width = parseInt(getColumnWidth(column))
totalWidth += width
})
// Add actions column width if enabled
if (showActions.value) {
totalWidth += 100 // Actions column width
}
// Add some padding for borders and spacing
totalWidth += 40
console.log('[RepeatingTable] Calculated minimum width:', totalWidth)
return `${totalWidth}px`
})
// Debug table width on mount
onMounted(() => {
nextTick(() => {
if (tableContainer.value) {
const table = tableContainer.value.querySelector('.data-table')
if (table) {
console.log('[RepeatingTable] Table width:', table.offsetWidth)
console.log('[RepeatingTable] Container width:', tableContainer.value.offsetWidth)
console.log('[RepeatingTable] Scroll width:', table.scrollWidth)
console.log('[RepeatingTable] Has horizontal scroll:', table.scrollWidth > tableContainer.value.offsetWidth)
console.log('[RepeatingTable] Calculated min width:', calculateMinTableWidth.value)
}
}
})
})
// Cleanup on unmount
onUnmounted(() => {
columnCache.value.clear()
recordKeys.value.clear()
})
</script>
<style scoped>
/* Table Container with Sticky Header */
.table-container {
background: white;
width: 100%;
}
.table-scroll-container {
overflow-x: auto !important;
overflow-y: auto;
max-height: 500px;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f1f5f9;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
/* Ensure horizontal scrolling is always available */
width: 100%;
position: relative;
}
.data-table {
table-layout: auto; /* Changed from fixed to auto for better column sizing */
width: 100%;
border-collapse: collapse;
border-spacing: 0;
margin: 0;
/* Ensure table is wide enough to trigger scrolling */
white-space: nowrap;
/* Force minimum width to ensure scrolling */
min-width: 100%;
}
/* Sticky Header */
.table-header {
position: sticky;
top: 0;
z-index: 10;
background: #f9fafb;
border-bottom: 2px solid #e5e7eb;
}
/* Scrollable Body */
.table-body {
background: white;
}
/* Custom scrollbar for webkit browsers */
.table-scroll-container::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.table-scroll-container::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 4px;
}
.table-scroll-container::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.table-scroll-container::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
/* Horizontal scrollbar styling */
.table-scroll-container::-webkit-scrollbar:horizontal {
height: 8px;
}
.column-header,
.data-cell {
white-space: nowrap;
padding: 8px 12px;
vertical-align: middle;
/* Remove text truncation to allow full content display */
}
.cell-content {
/* Allow content to expand naturally */
min-width: max-content;
}
.cell-value {
display: block;
white-space: nowrap;
/* Remove text truncation to show full content */
}
/* Virtual scrolling optimizations */
.data-row {
contain: layout style paint;
}
/* Lazy loading optimizations */
.cell-value:empty {
min-height: 1.2em;
background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* Performance warning styles */
.performance-warning {
@apply bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-4;
}
.warning-content {
@apply flex items-start space-x-3;
}
.warning-icon {
@apply w-5 h-5 text-yellow-600 mt-0.5 flex-shrink-0;
}
.warning-text {
@apply flex-1;
}
.warning-title {
@apply text-sm font-medium text-yellow-800 mb-1;
}
.warning-message {
@apply text-xs text-yellow-700;
}
/* Responsive optimizations */
@media (max-width: 768px) {
.table-scroll-container {
max-height: 400px;
}
.column-header,
.data-cell {
min-width: 80px;
max-width: 120px;
}
}
@media (max-width: 480px) {
.table-scroll-container {
max-height: 300px;
}
.column-header,
.data-cell {
min-width: 60px;
max-width: 100px;
}
}
/* Enhanced scrolling for wide tables */
.table-scroll-container {
position: relative;
}
.table-scroll-container::after {
content: '';
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 20px;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.8));
pointer-events: none;
z-index: 1;
}
.table-scroll-container::before {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 20px;
background: linear-gradient(to left, transparent, rgba(255, 255, 255, 0.8));
pointer-events: none;
z-index: 1;
}
.repeating-table-container {
@apply border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm w-full;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.repeating-table-header {
@apply p-4 md:p-6 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200;
min-height: auto;
box-sizing: border-box;
}
.header-content {
/* Mobile first: flex-col, items-center (horizontal centering), gap-2 */
@apply flex flex-col items-center gap-2 w-full;
/* SM and up: flex-row, justify-between, items-baseline (vertical for text), gap-3 */
@apply sm:flex-row sm:justify-between sm:items-baseline sm:gap-3;
}
.header-left {
@apply flex-1 min-w-0; /* Allows shrinking, good for title/help */
}
.table-title {
@apply text-lg font-semibold text-gray-800 leading-tight; /* Ensure it can wrap if very long, though not the current issue */
}
.table-help {
@apply text-sm text-gray-600 break-words leading-relaxed mt-1; /* Spacing below title if help exists */
}
.header-right {
@apply flex items-center flex-shrink-0; /* flex-shrink-0 prevents the badge from shrinking */
}
.record-count {
@apply text-sm text-gray-600 bg-white px-3 py-1 rounded-full border border-gray-200 font-medium whitespace-nowrap;
}
.record-limit {
@apply text-gray-500;
}
.controls-section {
@apply flex flex-col md:flex-row md:justify-between md:items-center gap-3 p-4 bg-gray-50 border-b border-gray-200;
}
.search-container {
@apply flex-1 max-w-md;
}
.search-input-wrapper {
@apply relative;
}
.search-icon {
@apply absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400;
}
.search-input {
@apply w-full pl-10 pr-10 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm;
}
.clear-search {
@apply absolute right-3 top-1/2 transform -translate-y-1/2;
}
.add-button {
@apply inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium text-sm min-w-max;
}
.add-button.disabled {
@apply bg-gray-400 cursor-not-allowed hover:bg-gray-400;
}
.table-container {
@apply overflow-hidden;
}
.table-wrapper {
@apply overflow-hidden w-full;
}
.table-content {
@apply mx-4 md:mx-6;
}
.data-table {
@apply w-full divide-y divide-gray-200;
}
.table-header-row {
@apply bg-gray-50;
}
.column-header,
.row-number-header,
.actions-header {
@apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
background: #f9fafb;
border-right: 1px solid #e5e7eb;
border-bottom: 1px solid #e5e7eb;
position: relative;
vertical-align: top;
}
.column-header:last-child,
.actions-header {
border-right: none;
}
.row-number-header {
@apply w-16 text-center;
}
.actions-header {
@apply w-32 text-center;
}
.column-header-content {
@apply flex items-center;
}
.header-text {
@apply font-medium text-gray-700;
}
.required-indicator {
@apply text-red-500 ml-1 font-bold;
}
.table-body {
@apply bg-white divide-y divide-gray-200;
}
.data-row {
@apply transition-colors duration-150;
}
.data-row.row-hover:hover {
@apply bg-blue-50;
}
.row-number-cell,
.data-cell,
.actions-cell {
@apply px-6 py-4 whitespace-nowrap;
border-bottom: 1px solid #f3f4f6;
border-right: 1px solid #f3f4f6;
background: white;
vertical-align: middle;
margin: 0;
padding-left: 24px;
padding-right: 24px;
}
.data-cell:last-child,
.actions-cell {
border-right: none;
}
.row-number {
@apply flex items-center justify-center w-8 h-8 bg-gray-100 rounded-full text-sm text-gray-600 font-medium;
}
.cell-content {
@apply flex items-center w-full;
}
.cell-value {
@apply text-sm text-gray-900 w-full;
}
.action-buttons-row {
@apply flex justify-center space-x-2;
}
.action-button {
@apply p-2 rounded-md transition-colors duration-150;
}
.edit-button {
@apply text-blue-600 hover:text-blue-800 hover:bg-blue-100;
}
.delete-button {
@apply text-red-600 hover:text-red-800 hover:bg-red-100;
}
.empty-state {
@apply py-16 px-6;
}
.empty-content {
@apply flex flex-col items-center text-center max-w-md mx-auto;
}
.empty-icon-container {
@apply mb-6;
}
.empty-icon {
@apply w-16 h-16 text-gray-300;
}
.empty-text {
@apply mb-6;
}
.empty-title {
@apply text-xl font-medium text-gray-900 mb-2;
}
.empty-description {
@apply text-gray-500 leading-relaxed;
}
.empty-add-button {
@apply inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium;
}
.modal-overlay {
@apply fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 backdrop-blur-sm;
}
.modal-container {
@apply bg-white rounded-xl shadow-xl max-w-3xl w-full mx-4 max-h-[90vh] overflow-hidden;
}
.modal-container.modal-small {
@apply max-w-md;
}
.modal-header {
@apply flex justify-between items-start p-6 border-b border-gray-200 bg-gray-50;
}
.modal-title-section {
@apply flex-1 pr-4;
}
.modal-title {
@apply text-xl font-semibold text-gray-800 mb-1;
}
.modal-close {
@apply text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-md hover:bg-gray-200;
}
.modal-body {
@apply p-6 overflow-y-auto max-h-[60vh];
}
.form-grid {
@apply grid grid-cols-1 md:grid-cols-2 gap-6;
}
.form-field {
@apply col-span-1;
}
.form-field-full {
@apply col-span-full;
}
.field-wrapper {
@apply mb-0;
}
.modal-footer {
@apply flex justify-end space-x-3 p-6 border-t border-gray-200 bg-gray-50;
}
.modal-button {
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-150;
}
.cancel-button {
@apply text-gray-700 bg-white border border-gray-300 hover:bg-gray-50;
}
.save-button {
@apply text-white bg-blue-600 hover:bg-blue-700;
}
.delete-warning {
@apply flex items-start space-x-4;
}
.warning-icon-container {
@apply flex-shrink-0;
}
.warning-icon {
@apply w-6 h-6 text-amber-500;
}
.warning-content {
@apply flex-1;
}
.warning-title {
@apply font-medium text-gray-900 mb-2;
}
.warning-message {
@apply text-gray-600 text-sm;
}
.delete-confirm-button {
@apply text-white bg-red-600 hover:bg-red-700;
}
/* Pagination Styles */
.pagination-container {
@apply flex flex-col md:flex-row md:justify-between md:items-center gap-4 p-4 md:p-6 bg-gray-50 border-t border-gray-200;
}
.pagination-info {
@apply flex items-center;
}
.pagination-text {
@apply text-sm text-gray-700 font-medium;
}
.pagination-controls {
@apply flex flex-col sm:flex-row items-center gap-4;
}
.page-size-selector {
@apply flex items-center gap-2 text-sm;
}
.page-size-label {
@apply text-gray-700 font-medium;
}
.page-size-select {
@apply border border-gray-300 rounded-md px-3 py-1 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500;
}
.page-size-suffix {
@apply text-gray-600;
}
.page-navigation {
@apply flex items-center gap-1;
}
.pagination-button {
@apply flex items-center justify-center w-8 h-8 text-sm text-gray-500 bg-white border border-gray-300 rounded-md hover:bg-gray-50 hover:text-gray-700 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-white disabled:hover:text-gray-500 transition-colors;
}
.page-numbers {
@apply flex items-center gap-1 mx-2;
}
.page-number-button {
@apply flex items-center justify-center w-8 h-8 text-sm text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors;
}
.page-number-button.active {
@apply text-white bg-blue-600 border-blue-600 hover:bg-blue-700;
}
/* Responsive design */
@media (max-width: 768px) {
.repeating-table-header {
@apply p-3;
}
.header-content {
@apply flex-col gap-2;
}
.table-title {
@apply text-base font-semibold leading-tight;
}
.table-help {
@apply text-sm leading-relaxed;
}
.header-right {
@apply mt-2;
}
.controls-section {
@apply p-3 gap-3;
}
.table-content {
@apply mx-2;
}
.form-grid {
@apply grid-cols-1;
}
.table-container {
@apply text-sm;
}
.row-number-cell,
.data-cell,
.actions-cell {
@apply px-3 py-3;
}
.column-header,
.row-number-header,
.actions-header {
@apply px-3 py-2;
}
.modal-container {
@apply mx-2;
}
.empty-state {
@apply py-8 px-4;
}
.pagination-container {
@apply p-3 gap-3;
}
.pagination-controls {
@apply flex-col gap-3;
}
.page-navigation {
@apply justify-center;
}
}
</style>