conflict-nuxt-4/app/components/lazy-load/global/MyTable.vue
Baghi330 7892a7cefb 1
2026-02-14 10:41:53 +03:30

764 lines
26 KiB
Vue
Executable File

<template>
<div class="">
<!-- Header: جستجو -->
<div class="mb-6 flex items-center gap-2" v-if="props.showSearch">
<UInput
v-model="searchQuery"
placeholder="...جستجو"
class="w-full max-w-xs bg-gray-100 dark:bg-dark-primary-800 border border-gray-300 dark:border-dark-primary-700 rounded px-3 py-2 text-dark-primary-800 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<UButton
v-if="searchQuery"
icon="heroicons:x-mark"
size="sm"
color="gray"
variant="ghost"
@click="searchQuery = ''"
class="px-2 py-1 rounded bg-gray-200 dark:bg-dark-primary-700 hover:bg-gray-300 dark:hover:bg-dark-primary-600 text-dark-primary-800 dark:text-gray-100 transition-colors"
/>
</div>
<!-- جدول / کارت‌ها -->
<div
class="bg-white dark:bg-dark-primary-800 rounded-lg overflow-hidden shadow-md border border-gray-200 dark:border-dark-primary-700"
>
<!-- حالت دسکتاپ: جدول -->
<div class="hidden md:block">
<div
class="bg-white dark:bg-dark-primary-800 rounded-lg overflow-hidden shadow-md border border-gray-200 dark:border-dark-primary-700"
>
<table
class="w-full text-sm min-w-full"
style="table-layout: fixed; border-collapse: collapse"
>
<thead
class="bg-gray-50 dark:bg-dark-primary text-dark-primary-700 dark:text-gray-300"
style="display: table; width: 100%; table-layout: fixed"
>
<tr>
<th
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider whitespace-nowrap"
style="width: 3rem"
>
#
</th>
<th
v-for="col in tableColumns"
:key="col.key"
:style="{ width: col.width ? col.width : 'auto' }"
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider"
>
{{ col.title }}
</th>
<th
class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider whitespace-nowrap"
style="width: 6rem"
v-if="props.actionButtons?.length"
>
عملیات
</th>
</tr>
</thead>
</table>
<!-- بدنه جدول با اسکرول عمودی -->
<div
class="overflow-y-auto"
:style="
props.tableBodyMaxHeight
? { maxHeight: props.tableBodyMaxHeight }
: {}
"
>
<table
class="w-full text-sm min-w-full"
style="table-layout: fixed; border-collapse: collapse"
>
<tbody style="display: block; width: 100%">
<tr
v-for="(item, index) in searchedItems"
:key="item.id"
class="border-t border-gray-200 dark:border-dark-primary-700 cursor-move"
:class="index % 2 === 0 ? 'even-row' : 'odd-row'"
style="display: table; width: 100%; table-layout: fixed"
:draggable="props.draggableRows"
@dragstart="props.draggableRows && onRowDragStart(item)"
>
<!-- شماره ردیف -->
<td class="p-0" style="width: 3rem">
<div
class="w-full h-full flex items-center justify-center px-4 text-sm font-medium"
:style="{
paddingTop: pyClassValue,
paddingBottom: pyClassValue,
}"
>
{{ item.rowNumber }}
</div>
</td>
<!-- سلول‌های داده -->
<td
v-for="col in tableColumns"
:key="col.key"
:style="{ width: col.width ? col.width : 'auto' }"
class="p-0"
>
<UContextMenu
v-if="col.contextmenu"
:items="getMenuItemsWithHandler(col, item, index)"
:ui="{ content: 'w-48' }"
>
<div
class="w-full h-full flex items-center px-4 text-right break-words text-sm"
:style="{
paddingTop: pyClassValue,
paddingBottom: pyClassValue,
}"
@click="
!col.isLink &&
handleInteraction({
type: 'click',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
@dblclick="
handleInteraction({
type: 'dblclick',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
>
<span
v-if="col.isLink"
@click.stop="
handleInteraction({
type: 'link',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
v-html="getCellValue(item, col)"
>
</span>
<span v-else v-html="getCellValue(item, col)"></span>
</div>
</UContextMenu>
<div
v-else
class="w-full cursor-pointer h-full flex items-center px-4 text-right break-words text-sm whitespace-normal"
:style="{
paddingTop: pyClassValue,
paddingBottom: pyClassValue,
}"
@click="
!col.isLink &&
handleInteraction({
type: 'click',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
@dblclick="
handleInteraction({
type: 'dblclick',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
>
<span
v-if="col.isLink"
@click.stop="
handleInteraction({
type: 'link',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
v-html="getCellValue(item, col)"
>
</span>
<span v-else v-html="getCellValue(item, col)"></span>
</div>
</td>
<!-- عملیات -->
<td
v-if="props.actionButtons?.length"
class="p-0 pl-2"
style="width: 6rem"
>
<div
class="w-full h-full flex items-center justify-center px-4"
:style="{
paddingTop: pyClassValue,
paddingBottom: pyClassValue,
}"
>
<div class="flex gap-2">
<UButton
v-if="props.actionButtons.length <= 3"
v-for="btn in props.actionButtons"
:key="btn.key"
:icon="btn.icon"
size="md"
:color="getButtonColor(btn.key)"
variant="ghost"
square
@click.stop="
handleActionFromButton(btn.key, item, index, $event)
"
/>
<ClientOnly>
<UDropdownMenu
v-if="props.actionButtons.length > 3"
:items="
allActionMenuItems(
props.actionButtons,
item,
index
)
"
>
<UButton
icon="heroicons:ellipsis-vertical"
size="xs"
color="gray"
variant="ghost"
square
/>
</UDropdownMenu>
</ClientOnly>
</div>
</div>
</td>
</tr>
<!-- بدون داده -->
<tr v-if="searchedItems.length === 0">
<td
:colspan="tableColumns.length + 2"
class="py-8 text-center text-gray-400 dark:text-gray-500 text-sm"
>
❌ داده‌ای یافت نشد
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- حالت موبایل: کارت‌ها -->
<div class="md:hidden">
<div
class="overflow-y-auto px-4 py-2"
:style="
props.tableBodyMaxHeight
? { maxHeight: props.tableBodyMaxHeight }
: {}
"
>
<div class="space-y-4">
<div
v-for="(item, index) in searchedItems"
:key="item.id"
class="border border-gray-200 dark:border-dark-primary-700 rounded-lg p-4 bg-white dark:bg-dark-primary-800 shadow-sm"
:class="index % 2 === 0 ? 'even-row' : 'odd-row'"
:draggable="props.draggableRows"
@dragstart="props.draggableRows && onRowDragStart(item)"
>
<div class="space-y-3">
<!-- شماره ردیف -->
<div class="flex justify-between items-start gap-2">
<span
class="text-xs font-medium text-gray-500 dark:text-gray-400"
>
ردیف
</span>
<span
class="text-right text-sm font-medium text-dark-primary dark:text-gray-100"
>
{{ item.rowNumber }}
</span>
</div>
<!-- سایر ستون‌ها -->
<div
v-for="col in tableColumns"
:key="col.key"
class="flex justify-between items-start gap-2"
>
<span
class="text-xs font-medium text-gray-500 dark:text-gray-400 min-w-0"
>
{{ col.title }}
</span>
<div
class="text-right flex-1 text-sm text-dark-primary dark:text-gray-100"
>
<UContextMenu
v-if="col.contextmenu"
:items="getMenuItemsWithHandler(col, item, index)"
:ui="{ content: 'w-48' }"
>
<span
v-if="col.isLink"
@click="
handleInteraction({
type: 'link',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
v-html="getCellValue(item, col)"
></span>
<span
v-else
@click="
!col.isLink &&
handleInteraction({
type: 'click',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
@dblclick="
handleInteraction({
type: 'dblclick',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="cursor-pointer"
v-html="getCellValue(item, col)"
></span>
</UContextMenu>
<template v-else>
<span
v-if="col.isLink"
@click="
handleInteraction({
type: 'link',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="text-blue-600 dark:text-blue-400 hover:underline cursor-pointer"
v-html="getCellValue(item, col)"
></span>
<span
v-else
@click="
!col.isLink &&
handleInteraction({
type: 'click',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
@dblclick="
handleInteraction({
type: 'dblclick',
column: col,
item,
value: getCellValue(item, col),
index,
event: $event,
})
"
class="cursor-pointer"
v-html="getCellValue(item, col)"
></span>
</template>
</div>
</div>
</div>
<!-- دکمه‌های عملیات -->
<div
class="flex justify-end gap-2 mt-4"
v-if="props.actionButtons?.length"
>
<UButton
v-if="props.actionButtons.length <= 3"
v-for="btn in props.actionButtons"
:key="btn.key"
:icon="btn.icon"
size="xs"
:color="getButtonColor(btn.key)"
variant="ghost"
square
@click.stop="
handleActionFromButton(btn.key, item, index, $event)
"
/>
<ClientOnly>
<UDropdownMenu
v-if="props.actionButtons.length > 3"
:items="
allActionMenuItems(props.actionButtons, item, index)
"
>
<UButton
icon="heroicons:ellipsis-vertical"
size="xs"
color="gray"
variant="ghost"
square
/>
</UDropdownMenu>
</ClientOnly>
</div>
</div>
</div>
</div>
<div
v-if="searchedItems.length === 0"
class="text-center py-8 text-gray-400 dark:text-gray-500 text-sm"
>
❌ داده‌ای یافت نشد
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
const props = defineProps({
tableColumns: { type: Array, required: true },
actionButtons: { type: Array, default: () => [] },
rawData: { type: Array, required: true },
cellMenuItems: { type: Array, default: () => [] },
showSearch: { type: Boolean, default: true },
tableBodyMaxHeight: { type: String, default: "80dvh" },
pagination: { type: Object, required: true },
draggableRows: { type: Boolean, default: false },
});
const emit = defineEmits(["my-table-action", "drag-start"]);
const searchQuery = ref("");
const clickTimer = ref(null);
const pyClassValue = "0.75rem"; // می‌توانید از props هم بگیرید
// شماره ردیف پایدار
const rowNumberMap = ref(new Map());
const nextRowNumber = ref(1);
const getItemKey = (item, idx) => {
if (!item) return `__idx_${idx}`;
if (item.id) return String(item.id);
if (item._id) return String(item._id);
if (item._source && (item._source.id || item._source._id))
return String(item._source.id || item._source._id);
try {
return JSON.stringify(item);
} catch {
return `__idx_${idx}`;
}
};
watch(
() => props.rawData,
(newVal) => {
if (!Array.isArray(newVal)) return;
newVal.forEach((rawItem, idx) => {
const key = getItemKey(rawItem, idx);
if (!rowNumberMap.value.has(key)) {
rowNumberMap.value.set(key, nextRowNumber.value++);
}
});
},
{ immediate: true, deep: true }
);
const processedData = computed(() => {
if (!Array.isArray(props.rawData)) return [];
const offset = props.pagination?.offset ?? 0;
return props.rawData.map((item, idx) => {
const key = getItemKey(item, idx);
const computedId = item._id || item.id || key;
const rowNumber = offset + idx + 1;
return {
...item,
id: computedId,
rowNumber,
};
});
});
const searchedItems = computed(() => {
if (!searchQuery.value) return processedData.value;
const q = searchQuery.value.toLowerCase();
return processedData.value.filter((item) =>
Object.values(item).some((val) =>
String(val ?? "")
.toLowerCase()
.includes(q)
)
);
});
const getButtonColor = (key) => {
return { edit: "primary", delete: "red", clone: "green" }[key] || "gray";
};
// ====================== getCellValue اصلی ======================
const getObjectText = (schema, data) => {
let text = "";
if (schema.is_object == 1 && schema.object_text) {
Object.keys(schema.object_text).forEach((k) => {
if (data[k] !== undefined) {
if (schema.object_text[k]) text += " - " + schema.object_text[k] + ":";
text += data[k];
}
});
} else {
text = String(data);
}
return text;
};
const formatDateToPersian = (item) => {
if (!item) return "";
let date;
const num = Number(item);
if (!isNaN(num) && item.toString().length === 10) {
date = new Date(item * 1000);
} else if (typeof item === "string" && !item.includes("T00:00:00")) {
date = new Date(item + "T00:00:00");
} else {
date = new Date(item);
}
if (isNaN(date.getTime())) return item;
return date.toLocaleDateString("fa-IR");
};
const getCellValue = (rowItem, column) => {
let key = column.key;
let trancate_word = column?.trancate_word;
let value = "";
if (rowItem.highlight && key in rowItem.highlight) {
if (Array.isArray(rowItem.highlight[key])) {
value = rowItem.highlight[key][0];
} else {
value = rowItem.highlight[key];
}
return value;
}
let rowItem_data = rowItem._source ?? rowItem;
if (!rowItem_data) return "";
key = key.replace("__", ".");
if (key.includes(".")) {
const keys = key.split(".");
for (let i = 0; i < keys.length - 1; i++) {
if (rowItem_data[keys[i]] !== undefined) {
rowItem_data = rowItem_data[keys[i]];
} else {
return "";
}
}
key = keys[keys.length - 1];
}
if (column?.object_text && rowItem_data[column.key]) {
const data = rowItem_data[column.key];
if (column.is_array == 1 && Array.isArray(data)) {
data.forEach((el) => {
const text = getObjectText(column, el);
if (text) value += text + "-";
});
} else {
value = getObjectText(column, data);
}
}
if (value === "" && rowItem_data[key] !== undefined) {
value = rowItem_data[key];
}
if (value && trancate_word && trancate_word > 0) {
const words = String(value).split(" ");
if (words.length > trancate_word) {
value = words.slice(0, trancate_word).join(" ") + "...";
}
}
if (value !== "" && column?.process) {
if (column.process === "len") {
return String(value).length;
} else if (
column.process === "convert_date" ||
column.process === "convert_gdate"
) {
return formatDateToPersian(value);
}
}
if (
value !== "" &&
(key.toLowerCase().includes("date") || key.toLowerCase().includes("time"))
) {
return formatDateToPersian(value);
}
if (column?.colors && value in column.colors) {
value = `<span style="color:${column.colors[value]}">${value}</span>`;
}
return value ?? "";
};
// ====================== Action & Interaction Handlers ======================
const emitAction = (actionKey, item, index) => {
const payload = {
column: null,
item,
index,
menuItem: props.actionButtons.find((b) => b.key === actionKey),
};
emit("my-table-action", { action: actionKey, payload: payload });
};
const handleActionFromButton = (actionKey, item, index, event) => {
event.stopPropagation();
emitAction(actionKey, item, index);
};
const allActionMenuItems = (buttons, item, index) => {
if (buttons.length <= 3) return [];
return buttons.map((btn) => ({
label: btn.title || btn.key,
icon: btn.icon,
onSelect: () => emitAction(btn.key, item, index),
}));
};
const getMenuItemsWithHandler = (column, item, index) => {
return props.cellMenuItems.map((section) =>
section.map((menuItem) => ({
...menuItem,
onSelect: () => {
emit("my-table-action", {
type: "contextmenu",
column,
item,
value: getCellValue(item, column),
index,
menuItem,
});
},
}))
);
};
const handleInteraction = ({ type, column, item, value, index, event }) => {
if (type === "click" && event?.detail > 1) return;
if (type === "link") {
emit("my-table-action", { type, column, item, value, index, event });
return;
}
if (type === "dblclick") {
if (clickTimer.value) clearTimeout(clickTimer.value);
emit("my-table-action", { type, column, item, value, index, event });
return;
}
if (type === "click") {
if (clickTimer.value) clearTimeout(clickTimer.value);
clickTimer.value = setTimeout(() => {
emit("my-table-action", { type, column, item, value, index, event });
clickTimer.value = null;
}, 200);
}
};
const onRowDragStart = (item) => {
if (!props.draggableRows) return;
emit("drag-start", item);
};
</script>
<style scoped>
table {
table-layout: fixed;
}
td {
word-wrap: break-word;
word-break: break-word;
}
.even-row {
background-color: #f9fafb;
}
.odd-row {
background-color: #ffffff;
}
.dark .even-row {
background-color: #1f2937;
}
.dark .odd-row {
background-color: #111827;
}
</style>
<style>
.text__orange {
color: var(--color-text__orange);
}
</style>