Changes to post|put|patch|delete

This commit is contained in:
Simon Pocrnjič
2025-11-02 21:46:02 +01:00
parent 63e0958b66
commit fd9f26d82a
21 changed files with 786 additions and 465 deletions
@@ -0,0 +1,386 @@
<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 } from "vue";
import { router } from "@inertiajs/vue3";
import DataTable from "../DataTable/DataTable.vue";
import Dropdown from "@/Components/Dropdown.vue";
import DeleteDialog from "../Dialogs/DeleteDialog.vue";
import { Badge } from "@/Components/ui/badge";
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 },
});
// Define columns for DataTable
const columns = [
{ key: 'name', label: 'Naziv' },
{ key: 'type', label: 'Vrsta' },
{ key: 'size', label: 'Velikost', align: 'right' },
{ key: 'created_at', label: 'Dodano' },
{ key: 'source', label: 'Vir' },
{ key: 'description', label: 'Opis', 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"]);
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>
<DataTable
:columns="columns"
:rows="documents"
:show-toolbar="false"
:show-pagination="false"
:striped="false"
:hoverable="true"
:show-actions="true"
row-key="uuid"
empty-text="Ni dokumentov."
empty-icon="faFileAlt"
>
<!-- 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 }">
{{ new Date(row.created_at).toLocaleString() }}
</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 #actions="{ row }">
<div @click.stop>
<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 transition-colors"
title="Možnosti"
>
<FontAwesomeIcon
:icon="faEllipsisVertical"
class="h-4 w-4 text-gray-700"
/>
</button>
</template>
<template #content>
<div class="py-1">
<button
type="button"
class="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
@click="emit('edit', row)"
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-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center gap-2 transition-colors"
@click="handleDownload(row)"
>
<FontAwesomeIcon :icon="faDownload" class="h-4 w-4 text-gray-600" />
<span>Prenos</span>
</button>
<button
type="button"
class="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 transition-colors"
@click="askDelete(row)"
v-if="edit"
>
<FontAwesomeIcon :icon="faTrash" class="h-4 w-4" />
<span>Izbriši</span>
</button>
</div>
</template>
</Dropdown>
</div>
</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>