Teren-app/resources/js/Components/DocumentsTable/DocumentsTable.vue
2025-11-20 18:11:43 +01:00

417 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faFilePdf,
faFileWord,
faFileExcel,
faFileLines,
faFileImage,
faFile,
faCircleInfo,
faEllipsisVertical,
faDownload,
faTrash,
faFileAlt,
} from "@fortawesome/free-solid-svg-icons";
import { ref, computed } from "vue";
import { router } from "@inertiajs/vue3";
import DataTable from "../DataTable/DataTableNew2.vue";
import Dropdown from "@/Components/Dropdown.vue";
import DeleteDialog from "../Dialogs/DeleteDialog.vue";
import { Badge } from "@/Components/ui/badge";
import TableActions from "@/Components/DataTable/TableActions.vue";
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { fmtDateTime } from "@/Utilities/functions";
const props = defineProps({
documents: { type: [Array, Object], default: () => [] },
viewUrlBuilder: { type: Function, default: null },
// Optional: direct download URL builder; if absent we emit 'download'
downloadUrlBuilder: { type: Function, default: null },
// Optional: direct delete URL builder; if absent we emit 'delete'
deleteUrlBuilder: { type: Function, default: null },
edit: { type: Boolean, default: false },
pageSize: {
type: Number,
default: 15,
},
pageSizeOptions: {
type: Array,
default: () => [10, 15, 25, 50, 100],
},
// Server-side pagination support
clientCase: { type: Object, default: null },
});
// Define columns for DataTable
const columns = [
{ key: "name", label: "Naziv", sortable: false },
{ key: "type", label: "Vrsta", sortable: false },
{ key: "size", label: "Velikost", align: "right", sortable: false },
{ key: "created_at", label: "Dodano", sortable: false },
{ key: "source", label: "Vir", sortable: false },
{ key: "description", label: "Opis", align: "center", sortable: false },
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
];
// Derive a human-friendly source for a document: Case or Contract reference
const sourceLabel = (doc) => {
// Server can include optional documentable meta; fall back to type
if (doc.documentable_type?.toLowerCase?.().includes("contract")) {
return doc.contract_reference ? `Pogodba ${doc.contract_reference}` : "Pogodba";
}
return "Primer";
};
const emit = defineEmits(["view", "download", "delete", "edit"]);
// Support both array and Resource Collection (object with data property)
const documentsData = computed(() => {
if (Array.isArray(props.documents)) {
return props.documents;
}
return props.documents?.data || [];
});
// Check if using server-side pagination
const isServerSide = computed(() => {
return !!(props.documents?.links && props.clientCase);
});
const formatSize = (bytes) => {
if (bytes == null) return "-";
const thresh = 1024;
if (Math.abs(bytes) < thresh) return bytes + " B";
const units = ["KB", "MB", "GB", "TB"];
let u = -1;
do {
bytes /= thresh;
++u;
} while (Math.abs(bytes) >= thresh && u < units.length - 1);
return bytes.toFixed(1) + " " + units[u];
};
const extFrom = (doc) => {
let ext = (doc?.extension || "").toLowerCase();
if (!ext && doc?.original_name) {
const parts = String(doc.original_name).toLowerCase().split(".");
if (parts.length > 1) ext = parts.pop();
}
// derive from mime
if (!ext && doc?.mime_type) {
const mime = String(doc.mime_type).toLowerCase();
if (mime.includes("pdf")) ext = "pdf";
else if (mime.includes("word") || mime.includes("msword") || mime.includes("doc"))
ext = "docx";
else if (mime.includes("excel") || mime.includes("sheet")) ext = "xlsx";
else if (mime.includes("csv")) ext = "csv";
else if (mime.startsWith("image/")) ext = "img";
else if (mime.includes("text")) ext = "txt";
}
return ext;
};
const fileTypeInfo = (doc) => {
const ext = extFrom(doc);
const mime = (doc?.mime_type || "").toLowerCase();
switch (ext) {
case "pdf":
return { icon: faFilePdf, color: "text-red-600", label: "PDF" };
case "doc":
case "docx":
return {
icon: faFileWord,
color: "text-blue-600",
label: (ext || "DOCX").toUpperCase(),
};
case "xls":
case "xlsx":
return {
icon: faFileExcel,
color: "text-green-600",
label: (ext || "XLSX").toUpperCase(),
};
case "csv":
// treat CSV as spreadsheet-like
return { icon: faFileExcel, color: "text-emerald-600", label: "CSV" };
case "txt":
return { icon: faFileLines, color: "text-slate-600", label: "TXT" };
case "jpg":
case "jpeg":
case "png":
case "img":
return {
icon: faFileImage,
color: "text-fuchsia-600",
label: ext === "img" ? "IMG" : (ext || "IMG").toUpperCase(),
};
default:
if (mime.startsWith("image/"))
return { icon: faFileImage, color: "text-fuchsia-600", label: "IMG" };
return {
icon: faFile,
color: "text-gray-600",
label: (ext || "FILE").toUpperCase(),
};
}
};
const hasDesc = (doc) => {
const d = doc?.description;
return typeof d === "string" && d.trim().length > 0;
};
const expandedDescKey = ref(null);
const rowKey = (doc) => doc?.uuid ?? doc?.id ?? null;
const toggleDesc = (doc) => {
const key = rowKey(doc);
if (!key) return;
expandedDescKey.value = expandedDescKey.value === key ? null : key;
};
// Track which documents have expanded descriptions
const isExpanded = (doc) => {
const key = rowKey(doc);
return key ? expandedDescKey.value === key : false;
};
const resolveDownloadUrl = (doc) => {
if (typeof props.downloadUrlBuilder === "function")
return props.downloadUrlBuilder(doc);
// If no builder provided, parent can handle via emitted event
return null;
};
const handleDownload = (doc) => {
const url = resolveDownloadUrl(doc);
if (url) {
// Trigger a navigation to a direct-download endpoint; server should set Content-Disposition: attachment
const a = document.createElement("a");
a.href = url;
a.target = "_self";
a.rel = "noopener";
// In many browsers, simply setting href is enough
a.click();
} else {
emit("download", doc);
}
closeActions();
};
// ---------------- Delete logic ----------------
const confirmDelete = ref(false);
const deleting = ref(false);
const docToDelete = ref(null);
const resolveDeleteUrl = (doc) => {
// 1. Explicit builder via prop takes precedence
if (typeof props.deleteUrlBuilder === "function") {
return props.deleteUrlBuilder(doc);
}
// 2. Attempt automatic route resolution (requires Ziggy's global `route` helper)
try {
const type = (doc?.documentable_type || "").toLowerCase();
// Contract document
if (type.includes("contract") && doc?.contract_uuid && doc?.uuid) {
if (typeof route === "function") {
return route("contract.document.delete", {
contract: doc.contract_uuid,
document: doc.uuid,
});
}
}
// Case document
if (doc?.client_case_uuid && doc?.uuid) {
if (typeof route === "function") {
return route("clientCase.document.delete", {
client_case: doc.client_case_uuid,
document: doc.uuid,
});
}
}
} catch (e) {
// swallow fallback to emit path
}
// 3. Fallback: no URL, caller must handle emitted event
return null;
};
const requestDelete = async () => {
if (!docToDelete.value) {
return;
}
const url = resolveDeleteUrl(docToDelete.value);
deleting.value = true;
try {
if (url) {
await router.delete(url, { preserveScroll: true });
} else {
emit("delete", docToDelete.value);
}
} finally {
deleting.value = false;
confirmDelete.value = false;
docToDelete.value = null;
}
};
const askDelete = (doc) => {
docToDelete.value = doc;
confirmDelete.value = true;
};
const closeDeleteDialog = () => {
confirmDelete.value = false;
docToDelete.value = null;
};
function closeActions() {
/* noop placeholder for symmetry; Dropdown auto-closes */
}
</script>
<template>
<div class="space-y-4">
<DataTable
:columns="columns"
:data="documentsData"
:meta="isServerSide ? documents : null"
:route-name="isServerSide ? 'clientCase.show' : null"
:route-params="isServerSide ? { client_case: clientCase.uuid } : {}"
:only-props="isServerSide ? ['documents'] : []"
:page-size="pageSize"
:page-size-options="pageSizeOptions"
page-param-name="documentsPage"
per-page-param-name="documentsPerPage"
:show-pagination="false"
:show-toolbar="true"
:hoverable="true"
row-key="uuid"
empty-text="Ni dokumentov."
>
<template #toolbar-actions>
<slot name="add" />
</template>
<!-- Name column -->
<template #cell-name="{ row }">
<div>
<div class="flex items-center gap-2">
<button
type="button"
class="text-indigo-600 hover:underline"
@click.stop="$emit('view', row)"
>
{{ row.name }}
</button>
<Badge
v-if="row.is_public"
variant="secondary"
class="bg-green-100 text-green-700 hover:bg-green-200"
>Public</Badge
>
</div>
<!-- Expanded description -->
<div
v-if="isExpanded(row)"
class="mt-2 bg-gray-50 px-4 py-3 text-sm text-gray-700 whitespace-pre-wrap border-l-2 border-indigo-400 rounded"
>
{{ row.description }}
</div>
</div>
</template>
<!-- Type column -->
<template #cell-type="{ row }">
<div class="flex items-center gap-2">
<FontAwesomeIcon
:icon="fileTypeInfo(row).icon"
:class="['h-5 w-5', fileTypeInfo(row).color]"
/>
<span class="text-gray-700">{{ fileTypeInfo(row).label }}</span>
</div>
</template>
<!-- Size column -->
<template #cell-size="{ row }">
{{ formatSize(row.size) }}
</template>
<!-- Created at column -->
<template #cell-created_at="{ row }">
<div class="text-gray-800 font-medium leading-tight">
{{ row.created_by }}
</div>
<div v-if="row.created_at" class="mt-1">
<span
class="inline-block px-2 py-0.5 rounded text-[11px] bg-gray-100 text-gray-600 tracking-wide"
>
{{ fmtDateTime(row.created_at) }}
</span>
</div>
</template>
<!-- Source column -->
<template #cell-source="{ row }">
<Badge
variant="secondary"
class="bg-purple-100 text-purple-700 hover:bg-purple-200"
>{{ sourceLabel(row) }}</Badge
>
</template>
<!-- Description column -->
<template #cell-description="{ row }">
<div class="flex justify-center">
<button
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
:disabled="!hasDesc(row)"
:title="hasDesc(row) ? 'Pokaži opis' : 'Ni opisa'"
type="button"
@click.stop="toggleDesc(row)"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
</button>
</div>
</template>
<!-- Actions column -->
<template #cell-actions="{ row }">
<TableActions align="right">
<template #default>
<ActionMenuItem
v-if="edit"
:icon="faCircleInfo"
label="Uredi"
@click="emit('edit', row)"
/>
<ActionMenuItem
:icon="faDownload"
label="Prenos"
@click="handleDownload(row)"
/>
<ActionMenuItem
v-if="edit"
:icon="faTrash"
label="Izbriši"
danger
@click="askDelete(row)"
/>
</template>
</TableActions>
</template>
</DataTable>
<!-- Delete confirmation dialog -->
<DeleteDialog
:show="confirmDelete"
title="Potrditev brisanja"
:message="`Ali res želite izbrisati dokument '${docToDelete?.name}'?`"
:item-name="docToDelete?.name"
confirm-text="Izbriši"
:processing="deleting"
@close="closeDeleteDialog"
@confirm="requestDelete"
/>
</div>
</template>