tethys.backend/resources/js/Components/TablePersons.vue
Arno Kaimbacher 38c05f6714
Some checks failed
build.yaml / fix: Update TablePersons components for improved event handling and layout consistency (push) Failing after 0s
fix: Update TablePersons components for improved event handling and layout consistency
2025-11-05 15:38:28 +01:00

598 lines
27 KiB
Vue

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { mdiTrashCan } from '@mdi/js';
import { mdiDragVariant, mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { mdiAccount, mdiDomain } from '@mdi/js';
import BaseIcon from '@/Components/BaseIcon.vue';
import BaseButton from '@/Components/BaseButton.vue';
import { Person } from '@/Dataset';
import Draggable from 'vuedraggable';
import FormControl from '@/Components/FormControl.vue';
interface Props {
checkable?: boolean;
persons?: Person[];
relation: string;
contributortypes?: Record<string, string>;
errors?: Record<string, string[]>;
isLoading?: boolean;
canDelete?: boolean;
canEdit?: boolean;
canReorder?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
checkable: false,
persons: () => [],
contributortypes: () => ({}),
errors: () => ({}),
isLoading: false,
canDelete: true,
canEdit: true,
canReorder: true,
});
const emit = defineEmits<{
'update:persons': [value: Person[]];
'remove-person': [index: number, person: Person];
'person-updated': [index: number, person: Person];
'reorder': [oldIndex: number, newIndex: number];
}>();
// Local state
const perPage = ref(5);
const currentPage = ref(0);
const dragEnabled = ref(props.canReorder);
// Name type options
const nameTypeOptions = {
Personal: 'Personal',
Organizational: 'Org',
};
// Computed properties
const items = computed({
get() {
return props.persons;
},
set(value) {
props.persons.length = 0;
props.persons.push(...value);
},
});
const itemsPaginated = computed(() => {
const start = perPage.value * currentPage.value;
const end = perPage.value * (currentPage.value + 1);
return items.value.slice(start, end);
});
const numPages = computed(() => Math.ceil(items.value.length / perPage.value));
const currentPageHuman = computed(() => currentPage.value + 1);
const hasMultiplePages = computed(() => numPages.value > 1);
const showContributorTypes = computed(() => Object.keys(props.contributortypes).length > 0);
const pagesList = computed(() => {
const pages: number[] = [];
const maxVisible = 10;
if (numPages.value <= maxVisible) {
for (let i = 0; i < numPages.value; i++) {
pages.push(i);
}
} else {
// Smart pagination with ellipsis
if (currentPage.value <= 2) {
for (let i = 0; i < 4; i++) pages.push(i);
pages.push(-1); // Ellipsis marker
pages.push(numPages.value - 1);
} else if (currentPage.value >= numPages.value - 3) {
pages.push(0);
pages.push(-1);
for (let i = numPages.value - 4; i < numPages.value; i++) {
pages.push(i);
}
} else {
pages.push(0);
pages.push(-1);
for (let i = currentPage.value - 1; i <= currentPage.value + 1; i++) {
pages.push(i);
}
pages.push(-1);
pages.push(numPages.value - 1);
}
}
return pages;
});
// Methods
const removeAuthor = (index: number) => {
const actualIndex = perPage.value * currentPage.value + index;
const person = items.value[actualIndex];
const displayName =
person.name_type === 'Organizational'
? person.last_name || person.email
: `${person.first_name || ''} ${person.last_name || person.email}`.trim();
if (confirm(`Are you sure you want to remove ${displayName}?`)) {
items.value.splice(actualIndex, 1);
emit('remove-person', actualIndex, person);
if (itemsPaginated.value.length === 0 && currentPage.value > 0) {
currentPage.value--;
}
}
};
const updatePerson = (index: number, field: keyof Person, value: any) => {
const actualIndex = perPage.value * currentPage.value + index;
const person = items.value[actualIndex];
// Handle name_type change - clear first_name if switching to Organizational
if (field === 'name_type' && value === 'Organizational') {
person.first_name = '';
}
(person as any)[field] = value;
emit('person-updated', actualIndex, person);
};
const goToPage = (page: number) => {
if (page >= 0 && page < numPages.value) {
currentPage.value = page;
}
};
const getFieldError = (index: number, field: string): string => {
const actualIndex = perPage.value * currentPage.value + index;
const errorKey = `${props.relation}.${actualIndex}.${field}`;
return props.errors[errorKey]?.join(', ') || '';
};
const handleDragEnd = (evt: any) => {
if (evt.oldIndex !== evt.newIndex) {
emit('reorder', evt.oldIndex, evt.newIndex);
}
};
// Watchers
watch(
() => props.persons.length,
() => {
if (currentPage.value >= numPages.value && numPages.value > 0) {
currentPage.value = numPages.value - 1;
}
},
);
// Pagination helper
const perPageOptions = [
{ value: 5, label: '5 per page' },
{ value: 10, label: '10 per page' },
{ value: 20, label: '20 per page' },
{ value: 50, label: '50 per page' },
];
</script>
<template>
<div class="card">
<!-- Table Controls -->
<div
v-if="hasMultiplePages"
class="flex justify-between items-center px-4 py-2.5 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50"
>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-600 dark:text-gray-400">
{{ currentPage * perPage + 1 }}-{{ Math.min((currentPage + 1) * perPage, items.length) }} of {{ items.length }}
</span>
</div>
<select
v-model="perPage"
@change="currentPage = 0"
class="px-2 py-1 text-xs border rounded dark:bg-slate-800 dark:border-slate-600 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option v-for="option in perPageOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
<!-- Table -->
<div class="overflow-x-auto">
<table class="w-full table-compact">
<thead>
<tr class="bg-gray-50 dark:bg-slate-800/50 border-b border-gray-200 dark:border-slate-700">
<th v-if="canReorder" class="w-8 px-2 py-2" />
<th scope="col" class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 w-10">#</th>
<th class="text-left px-2 py-2 text-[10px] font-semibold text-gray-600 dark:text-gray-300 w-40">Type</th>
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[120px]">First Name</th>
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[160px]">
Last Name / Org
</th>
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[140px]">ORCID</th>
<th class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 min-w-[160px]">Email</th>
<th
v-if="showContributorTypes"
scope="col"
class="text-left px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300 w-32"
>
Role
</th>
<th v-if="canDelete" class="w-16 px-2 py-2 text-xs font-semibold text-gray-600 dark:text-gray-300">Actions</th>
</tr>
</thead>
<!-- Draggable tbody for non-paginated view -->
<draggable
v-if="canReorder && !hasMultiplePages"
tag="tbody"
v-model="items"
item-key="id"
:disabled="!dragEnabled || isLoading"
@end="handleDragEnd"
handle=".drag-handle"
>
<template #item="{ index, element }">
<tr
class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors"
>
<td v-if="canReorder" class="px-2 py-2">
<div class="drag-handle cursor-move text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<BaseIcon :path="mdiDragVariant" :size="18" />
</div>
</td>
<td class="px-2 py-2 text-xs text-gray-600 dark:text-gray-400">{{ index + 1 }}</td>
<!-- Name Type Selector -->
<td class="px-2 py-2">
<div class="flex items-center gap-1.5">
<BaseIcon
:path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount"
:size="16"
:class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'"
:title="element.name_type"
/>
<FormControl
required
v-model="element.name_type"
type="select"
:options="nameTypeOptions"
:is-read-only="element.status == true"
class="text-[8px] compact-select-mini flex-1"
/>
</div>
<div
class="text-red-500 text-[8px] mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.name_type`])"
>
{{ errors[`${relation}.${index}.name_type`][0] }}
</div>
</td>
<!-- First Name - Only shown for Personal type -->
<td class="px-2 py-2">
<FormControl
v-if="element.name_type !== 'Organizational'"
required
v-model="element.first_name"
type="text"
:is-read-only="element.status == true"
placeholder="First name"
class="text-xs compact-input"
/>
<span v-else class="text-gray-400 text-xs italic"></span>
<div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.first_name`])"
>
{{ errors[`${relation}.${index}.first_name`][0] }}
</div>
</td>
<!-- Last Name / Organization Name -->
<td class="px-2 py-2">
<FormControl
required
v-model="element.last_name"
type="text"
:is-read-only="element.status == true"
:placeholder="element.name_type === 'Organizational' ? 'Organization' : 'Last name'"
class="text-xs compact-input"
/>
<div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.last_name`])"
>
{{ errors[`${relation}.${index}.last_name`][0] }}
</div>
</td>
<!-- Orcid -->
<td class="px-2 py-2">
<FormControl
v-model="element.identifier_orcid"
type="text"
:is-read-only="element.status == true"
placeholder="0000-0000-0000-0000"
class="text-xs compact-input font-mono"
/>
<div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.identifier_orcid`])"
>
{{ errors[`${relation}.${index}.identifier_orcid`][0] }}
</div>
</td>
<!-- Email -->
<td class="px-2 py-2">
<FormControl
required
v-model="element.email"
type="email"
:is-read-only="element.status == true"
placeholder="email@example.com"
class="text-xs compact-input"
/>
<div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.email`])"
>
{{ errors[`${relation}.${index}.email`][0] }}
</div>
</td>
<!-- Contributor Type -->
<td v-if="Object.keys(contributortypes).length" class="px-2 py-2">
<FormControl
required
v-model="element.pivot_contributor_type"
type="select"
:options="contributortypes"
placeholder="Role"
class="text-xs compact-select"
/>
<div
class="text-red-500 text-xs mt-0.5"
v-if="errors && Array.isArray(errors[`${relation}.${index}.pivot_contributor_type`])"
>
{{ errors[`${relation}.${index}.pivot_contributor_type`][0] }}
</div>
</td>
<!-- Actions -->
<td class="px-2 py-2 whitespace-nowrap">
<BaseButton
color="danger"
:icon="mdiTrashCan"
small
@click.prevent="removeAuthor(index)"
class="compact-button"
/>
</td>
</tr>
</template>
</draggable>
<!-- Non-draggable tbody for paginated view -->
<tbody v-else>
<tr
v-for="(element, index) in itemsPaginated"
:key="element.id || index"
class="border-b border-gray-100 dark:border-slate-800 hover:bg-blue-50 dark:hover:bg-slate-800/70 transition-colors"
>
<td class="px-2 py-2 text-gray-400">
<BaseIcon v-if="canReorder && !hasMultiplePages" :path="mdiDragVariant" :size="18" />
</td>
<td class="px-2 py-2 text-xs text-gray-600 dark:text-gray-400">{{ currentPage * perPage + index + 1 }}</td>
<!-- Name Type Selector -->
<td class="px-2 py-2">
<div class="flex items-center gap-1.5">
<BaseIcon
:path="element.name_type === 'Organizational' ? mdiDomain : mdiAccount"
:size="16"
:class="element.name_type === 'Organizational' ? 'text-purple-500' : 'text-blue-500'"
:title="element.name_type"
/>
<FormControl
required
v-model="element.name_type"
type="select"
:options="nameTypeOptions"
:is-read-only="element.status == true"
class="text-xs compact-select"
:error="getFieldError(index, 'name_type')"
/>
</div>
<div v-if="getFieldError(index, 'name_type')" class="text-red-500 text-xs mt-0.5">
{{ getFieldError(index, 'name_type') }}
</div>
</td>
<!-- First Name -->
<td class="px-2 py-2">
<FormControl
v-if="element.name_type !== 'Organizational'"
required
:model-value="element.first_name"
@update:model-value="updatePerson(index, 'first_name', $event)"
type="text"
:is-read-only="element.status || !canEdit"
placeholder="First name"
class="text-xs compact-input"
:error="getFieldError(index, 'first_name')"
/>
<span v-else class="text-gray-400 text-xs italic"></span>
<div v-if="getFieldError(index, 'first_name')" class="text-red-500 text-xs mt-0.5">
{{ getFieldError(index, 'first_name') }}
</div>
</td>
<!-- Last Name / Organization -->
<td class="px-2 py-2">
<FormControl
required
:model-value="element.last_name"
@update:model-value="updatePerson(index, 'last_name', $event)"
type="text"
:is-read-only="element.status || !canEdit"
:placeholder="element.name_type === 'Organizational' ? 'Organization' : 'Last name'"
class="text-xs compact-input"
:error="getFieldError(index, 'last_name')"
/>
<div v-if="getFieldError(index, 'last_name')" class="text-red-500 text-xs mt-0.5">
{{ getFieldError(index, 'last_name') }}
</div>
</td>
<!-- Orcid -->
<td class="px-2 py-2">
<FormControl
:model-value="element.identifier_orcid"
@update:model-value="updatePerson(index, 'identifier_orcid', $event)"
type="text"
:is-read-only="element.status || !canEdit"
placeholder="0000-0000-0000-0000"
class="text-xs compact-input font-mono"
:error="getFieldError(index, 'identifier_orcid')"
/>
<div v-if="getFieldError(index, 'identifier_orcid')" class="text-red-500 text-xs mt-0.5">
{{ getFieldError(index, 'identifier_orcid') }}
</div>
</td>
<!-- Email -->
<td class="px-2 py-2">
<FormControl
required
:model-value="element.email"
@update:model-value="updatePerson(index, 'email', $event)"
type="email"
:is-read-only="element.status || !canEdit"
placeholder="email@example.com"
class="text-xs compact-input"
:error="getFieldError(index, 'email')"
/>
<div v-if="getFieldError(index, 'email')" class="text-red-500 text-xs mt-0.5">
{{ getFieldError(index, 'email') }}
</div>
</td>
<!-- Contributor Type -->
<td v-if="showContributorTypes" class="px-2 py-2">
<FormControl
required
:model-value="element.pivot_contributor_type"
@update:model-value="updatePerson(index, 'pivot_contributor_type', $event)"
type="select"
:options="contributortypes"
:is-read-only="!canEdit"
placeholder="Role"
class="text-xs compact-select"
:error="getFieldError(index, 'pivot_contributor_type')"
/>
<div v-if="getFieldError(index, 'pivot_contributor_type')" class="text-red-500 text-xs mt-0.5">
{{ getFieldError(index, 'pivot_contributor_type') }}
</div>
</td>
<!-- Actions -->
<td v-if="canDelete" class="px-2 py-2 whitespace-nowrap">
<BaseButton
color="danger"
:icon="mdiTrashCan"
small
@click.prevent="removeAuthor(index)"
title="Remove person"
class="compact-button"
/>
</td>
</tr>
<!-- Empty State -->
<!-- <tr v-if="items.length === 0">
<td :colspan="showContributorTypes ? 9 : 8" class="text-center py-12 text-gray-400">
<div class="flex flex-col items-center gap-2">
<BaseIcon :path="mdiBookOpenPageVariant" :size="32" class="text-gray-300" />
<span class="text-sm">No persons added yet</span>
</div>
</td>
</tr>
</tbody>-if="canDelete" class="p-3">
<BaseButtons type="justify-start lg:justify-end" no-wrap>
<BaseButton
color="danger"
:icon="mdiTrashCan"
small
@click="removeAuthor(index)"
:disabled="element.status || !canEdit"
title="Remove person"
/>
</BaseButtons>
</td>
</tr>
<tr v-if="items.length === 0">
<td :colspan="showContributorTypes ? 10 : 9" class="text-center p-8 text-gray-500">
No persons added yet
</td>
</tr> -->
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="hasMultiplePages" class="flex justify-between items-center p-3 border-t border-gray-200 dark:border-slate-700">
<div class="flex gap-1">
<BaseButton :disabled="currentPage === 0" @click="goToPage(currentPage - 1)" :icon="mdiChevronLeft" small outline />
<template v-for="(page, i) in pagesList" :key="i">
<span v-if="page === -1" class="px-3 py-1">...</span>
<BaseButton
v-else
@click="goToPage(page)"
:label="String(page + 1)"
:color="page === currentPage ? 'info' : 'whiteDark'"
small
:outline="page !== currentPage"
/>
</template>
<BaseButton
:disabled="currentPage >= numPages - 1"
@click="goToPage(currentPage + 1)"
:icon="mdiChevronRight"
small
outline
/>
</div>
<span class="text-sm text-gray-600 dark:text-gray-400"> Page {{ currentPageHuman }} of {{ numPages }} </span>
</div>
</div>
</template>
<style lang="postcss" scoped>
.drag-handle {
transition: color 0.2s;
}
.card {
@apply bg-white dark:bg-slate-900 rounded-lg shadow-sm;
}
@media (max-width: 768px) {
table {
font-size: 0.875rem;
}
th,
td {
padding: 0.5rem !important;
}
}
</style>