412 lines
13 KiB
Vue
412 lines
13 KiB
Vue
<script setup>
|
||
import {
|
||
FwbTable,
|
||
FwbTableHead,
|
||
FwbTableHeadCell,
|
||
FwbTableBody,
|
||
FwbTableRow,
|
||
FwbTableCell,
|
||
FwbBadge,
|
||
} from "flowbite-vue";
|
||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||
import {
|
||
faFilePdf,
|
||
faFileWord,
|
||
faFileExcel,
|
||
faFileLines,
|
||
faFileImage,
|
||
faFile,
|
||
faCircleInfo,
|
||
faEllipsisVertical,
|
||
faDownload,
|
||
faTrash,
|
||
} from "@fortawesome/free-solid-svg-icons";
|
||
import { ref } from "vue";
|
||
import { router } from "@inertiajs/vue3";
|
||
import Dropdown from "@/Components/Dropdown.vue";
|
||
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
|
||
import SecondaryButton from "./SecondaryButton.vue";
|
||
import DangerButton from "./DangerButton.vue";
|
||
|
||
const props = defineProps({
|
||
documents: { type: Array, 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 },
|
||
});
|
||
// 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"]);
|
||
|
||
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, i) => doc?.uuid ?? i;
|
||
const toggleDesc = (doc, i) => {
|
||
const key = rowKey(doc, i);
|
||
expandedDescKey.value = expandedDescKey.value === key ? null : key;
|
||
};
|
||
|
||
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;
|
||
};
|
||
|
||
function closeActions() {
|
||
/* noop placeholder for symmetry; Dropdown auto-closes */
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm"
|
||
>
|
||
<FwbTable hoverable striped class="text-sm">
|
||
<FwbTableHead
|
||
class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm"
|
||
>
|
||
<FwbTableHeadCell
|
||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||
>Naziv</FwbTableHeadCell
|
||
>
|
||
<FwbTableHeadCell
|
||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||
>Vrsta</FwbTableHeadCell
|
||
>
|
||
<FwbTableHeadCell
|
||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||
>Velikost</FwbTableHeadCell
|
||
>
|
||
<FwbTableHeadCell
|
||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||
>Dodano</FwbTableHeadCell
|
||
>
|
||
<FwbTableHeadCell
|
||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3"
|
||
>Vir</FwbTableHeadCell
|
||
>
|
||
<FwbTableHeadCell
|
||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-"
|
||
>Opis</FwbTableHeadCell
|
||
>
|
||
<FwbTableHeadCell
|
||
class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-"
|
||
></FwbTableHeadCell>
|
||
</FwbTableHead>
|
||
<FwbTableBody>
|
||
<template v-for="(doc, i) in documents" :key="doc.uuid || i">
|
||
<FwbTableRow>
|
||
<FwbTableCell>
|
||
<div class="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
class="text-indigo-600 hover:underline"
|
||
@click="$emit('view', doc)"
|
||
>
|
||
{{ doc.name }}
|
||
</button>
|
||
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
|
||
</div>
|
||
</FwbTableCell>
|
||
<FwbTableCell>
|
||
<div class="flex items-center gap-2">
|
||
<FontAwesomeIcon
|
||
:icon="fileTypeInfo(doc).icon"
|
||
:class="['h-5 w-5', fileTypeInfo(doc).color]"
|
||
/>
|
||
<span class="text-gray-700">{{ fileTypeInfo(doc).label }}</span>
|
||
</div>
|
||
</FwbTableCell>
|
||
<FwbTableCell>{{ formatSize(doc.size) }}</FwbTableCell>
|
||
<FwbTableCell>{{ new Date(doc.created_at).toLocaleString() }}</FwbTableCell>
|
||
<FwbTableCell>
|
||
<FwbBadge type="purple">{{ sourceLabel(doc) }}</FwbBadge>
|
||
</FwbTableCell>
|
||
<FwbTableCell class="text-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"
|
||
:disabled="!hasDesc(doc)"
|
||
:title="hasDesc(doc) ? 'Pokaži opis' : 'Ni opisa'"
|
||
type="button"
|
||
@click="toggleDesc(doc, i)"
|
||
>
|
||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
|
||
</button>
|
||
</FwbTableCell>
|
||
<FwbTableCell class="text-right whitespace-nowrap">
|
||
<Dropdown align="right" width="48">
|
||
<template #trigger>
|
||
<button
|
||
type="button"
|
||
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 focus:outline-none"
|
||
:title="'Actions'"
|
||
>
|
||
<FontAwesomeIcon
|
||
:icon="faEllipsisVertical"
|
||
class="h-4 w-4 text-gray-700"
|
||
/>
|
||
</button>
|
||
</template>
|
||
<template #content>
|
||
<button
|
||
type="button"
|
||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||
@click="emit('edit', doc)"
|
||
v-if="edit"
|
||
>
|
||
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-600" />
|
||
<span>Uredi</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
|
||
@click="handleDownload(doc)"
|
||
>
|
||
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
|
||
<span>Prenos</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="w-full px-3 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
|
||
@click="askDelete(doc)"
|
||
v-if="edit"
|
||
>
|
||
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
|
||
<span>Izbriši</span>
|
||
</button>
|
||
<!-- future actions can be slotted here -->
|
||
</template>
|
||
</Dropdown>
|
||
</FwbTableCell>
|
||
</FwbTableRow>
|
||
<!-- Expanded description row directly below the item -->
|
||
<FwbTableRow
|
||
:key="'desc-' + (doc.uuid || i)"
|
||
v-if="expandedDescKey === rowKey(doc, i)"
|
||
>
|
||
<FwbTableCell :colspan="6" class="bg-gray-50">
|
||
<div
|
||
class="px-4 py-3 text-sm text-gray-700 whitespace-pre-wrap border-l-2 border-indigo-400"
|
||
>
|
||
{{ doc.description }}
|
||
</div>
|
||
</FwbTableCell>
|
||
</FwbTableRow>
|
||
</template>
|
||
</FwbTableBody>
|
||
</FwbTable>
|
||
<div
|
||
v-if="!documents || documents.length === 0"
|
||
class="p-6 text-center text-sm text-gray-500"
|
||
>
|
||
No documents.
|
||
</div>
|
||
<!-- Delete confirmation modal using shared component -->
|
||
<ConfirmationModal
|
||
:show="confirmDelete"
|
||
:closeable="!deleting"
|
||
max-width="md"
|
||
@close="
|
||
confirmDelete = false;
|
||
docToDelete = null;
|
||
"
|
||
>
|
||
<template #title>Potrditev</template>
|
||
<template #content>
|
||
Ali res želite izbrisati dokument
|
||
<span class="font-medium">{{ docToDelete?.name }}</span
|
||
>? Tega dejanja ni mogoče razveljaviti.
|
||
</template>
|
||
<template #footer>
|
||
<SecondaryButton
|
||
type="button"
|
||
@click="
|
||
confirmDelete = false;
|
||
docToDelete = null;
|
||
"
|
||
:disabled="deleting"
|
||
>Prekliči</SecondaryButton
|
||
>
|
||
<DangerButton
|
||
:disabled="deleting"
|
||
type="button"
|
||
class="ml-2"
|
||
@click="requestDelete"
|
||
>{{ deleting ? "Brisanje…" : "Izbriši" }}</DangerButton
|
||
>
|
||
</template>
|
||
</ConfirmationModal>
|
||
</div>
|
||
</template>
|