fixed some bugs with dialog and viewing docx works again

This commit is contained in:
Simon Pocrnjič 2026-01-29 19:14:35 +01:00
parent ad0f7a7a01
commit 2968bcf3f8
5 changed files with 285 additions and 170 deletions

View File

@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogContent :class="maxWidthClass"> <DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@ -6,34 +6,40 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/Components/ui/dialog'; } from "@/Components/ui/dialog";
import { Button } from '@/Components/ui/button'; import { Button } from "@/Components/ui/button";
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faTrashCan, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons'; import { faTrashCan, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import { ref, watch } from 'vue'; import { ref, watch } from "vue";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
title: { type: String, default: 'Izbriši' }, title: { type: String, default: "Izbriši" },
message: { type: String, default: 'Ali ste prepričani, da želite izbrisati ta element?' }, message: {
confirmText: { type: String, default: 'Izbriši' }, type: String,
cancelText: { type: String, default: 'Prekliči' }, default: "Ali ste prepričani, da želite izbrisati ta element?",
},
confirmText: { type: String, default: "Izbriši" },
cancelText: { type: String, default: "Prekliči" },
processing: { type: Boolean, default: false }, processing: { type: Boolean, default: false },
itemName: { type: String, default: null }, // Optional name to show in confirmation itemName: { type: String, default: null }, // Optional name to show in confirmation
}); });
const emit = defineEmits(['update:show', 'close', 'confirm']); const emit = defineEmits(["update:show", "close", "confirm"]);
const open = ref(props.show); const open = ref(props.show);
watch(() => props.show, (newVal) => { watch(
open.value = newVal; () => props.show,
}); (newVal) => {
open.value = newVal;
}
);
watch(open, (newVal) => { watch(open, (newVal) => {
emit('update:show', newVal); emit("update:show", newVal);
if (!newVal) { if (!newVal) {
emit('close'); emit("close");
} }
}); });
@ -42,7 +48,7 @@ const onClose = () => {
}; };
const onConfirm = () => { const onConfirm = () => {
emit('confirm'); emit("confirm");
}; };
</script> </script>
@ -59,8 +65,13 @@ const onConfirm = () => {
<DialogDescription> <DialogDescription>
<div class="flex items-start gap-4 pt-4"> <div class="flex items-start gap-4 pt-4">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"> <div
<FontAwesomeIcon :icon="faTriangleExclamation" class="h-6 w-6 text-red-600" /> class="flex items-center justify-center h-12 w-12 rounded-full bg-red-100"
>
<FontAwesomeIcon
:icon="faTriangleExclamation"
class="h-6 w-6 text-red-600"
/>
</div> </div>
</div> </div>
<div class="flex-1 space-y-2"> <div class="flex-1 space-y-2">
@ -70,9 +81,7 @@ const onConfirm = () => {
<p v-if="itemName" class="text-sm font-medium text-gray-900"> <p v-if="itemName" class="text-sm font-medium text-gray-900">
{{ itemName }} {{ itemName }}
</p> </p>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">Ta dejanje ni mogoče razveljaviti.</p>
Ta dejanje ni mogoče razveljaviti.
</p>
</div> </div>
</div> </div>
</DialogDescription> </DialogDescription>
@ -82,15 +91,10 @@ const onConfirm = () => {
<Button variant="outline" @click="onClose" :disabled="processing"> <Button variant="outline" @click="onClose" :disabled="processing">
{{ cancelText }} {{ cancelText }}
</Button> </Button>
<Button <Button variant="destructive" @click="onConfirm" :disabled="processing">
variant="destructive"
@click="onConfirm"
:disabled="processing"
>
{{ confirmText }} {{ confirmText }}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</template> </template>

View File

@ -69,7 +69,7 @@ const maxWidthClass = computed(() => {
<template> <template>
<Dialog v-model:open="open"> <Dialog v-model:open="open">
<DialogContent :class="maxWidthClass"> <DialogContent class="overflow-auto max-h-3/4" :class="maxWidthClass">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">

View File

@ -9,6 +9,8 @@ import {
} from "@/Components/ui/dialog"; } from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button"; import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge"; import { Badge } from "../ui/badge";
import { Loader2 } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({ const props = defineProps({
show: { type: Boolean, default: false }, show: { type: Boolean, default: false },
@ -21,6 +23,8 @@ const emit = defineEmits(["close"]);
const textContent = ref(""); const textContent = ref("");
const loading = ref(false); const loading = ref(false);
const previewGenerating = ref(false);
const previewError = ref("");
const fileExtension = computed(() => { const fileExtension = computed(() => {
if (props.filename) { if (props.filename) {
@ -34,6 +38,9 @@ const viewerType = computed(() => {
const mime = props.mimeType.toLowerCase(); const mime = props.mimeType.toLowerCase();
if (ext === "pdf" || mime === "application/pdf") return "pdf"; if (ext === "pdf" || mime === "application/pdf") return "pdf";
// DOCX/DOC files are converted to PDF by backend - treat as PDF viewer
if (["doc", "docx"].includes(ext) || mime.includes("word") || mime.includes("msword"))
return "docx";
if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/")) if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext) || mime.startsWith("image/"))
return "image"; return "image";
if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text"; if (["txt", "csv", "xml"].includes(ext) || mime.startsWith("text/")) return "text";
@ -45,8 +52,8 @@ const loadTextContent = async () => {
if (!props.src || viewerType.value !== "text") return; if (!props.src || viewerType.value !== "text") return;
loading.value = true; loading.value = true;
try { try {
const response = await fetch(props.src); const response = await axios.get(props.src);
textContent.value = await response.text(); textContent.value = response.data;
} catch (e) { } catch (e) {
textContent.value = "Napaka pri nalaganju vsebine."; textContent.value = "Napaka pri nalaganju vsebine.";
} finally { } finally {
@ -54,12 +61,64 @@ const loadTextContent = async () => {
} }
}; };
// For DOCX files, the backend converts to PDF. If the preview isn't ready yet (202 status),
// we poll until it's available.
const docxPreviewUrl = ref("");
const loadDocxPreview = async () => {
if (!props.src || viewerType.value !== "docx") return;
previewGenerating.value = true;
previewError.value = "";
docxPreviewUrl.value = "";
const maxRetries = 15;
const retryDelay = 2000; // 2 seconds between retries
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await axios.head(props.src, { validateStatus: () => true });
if (response.status >= 200 && response.status < 300) {
// Preview is ready
docxPreviewUrl.value = props.src;
previewGenerating.value = false;
return;
} else if (response.status === 202) {
// Preview is being generated, wait and retry
await new Promise((resolve) => setTimeout(resolve, retryDelay));
} else {
// Other error
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
} catch (e) {
previewError.value = "Napaka pri nalaganju predogleda.";
previewGenerating.value = false;
return;
}
}
// Max retries reached
previewError.value = "Predogled ni na voljo. Prosimo poskusite znova kasneje.";
previewGenerating.value = false;
};
watch( watch(
() => [props.show, props.src], () => [props.show, props.src],
([show]) => { ([show]) => {
if (show && viewerType.value === "text") { if (show && viewerType.value === "text") {
loadTextContent(); loadTextContent();
} }
if (show && viewerType.value === "docx") {
loadDocxPreview();
}
// Reset states when dialog closes
if (!show) {
previewGenerating.value = false;
previewError.value = "";
docxPreviewUrl.value = "";
}
}, },
{ immediate: true } { immediate: true }
); );
@ -89,6 +148,35 @@ watch(
/> />
</template> </template>
<!-- DOCX Viewer (converted to PDF by backend) -->
<template v-else-if="viewerType === 'docx'">
<!-- Loading/generating state -->
<div
v-if="previewGenerating"
class="flex flex-col items-center justify-center h-full gap-4"
>
<Loader2 class="h-8 w-8 animate-spin text-indigo-600" />
<span class="text-gray-500">Priprava predogleda dokumenta...</span>
</div>
<!-- Error state -->
<div
v-else-if="previewError"
class="flex flex-col items-center justify-center h-full gap-4 text-gray-500"
>
<span>{{ previewError }}</span>
<Button as="a" :href="props.src" target="_blank" variant="outline">
Prenesi datoteko
</Button>
</div>
<!-- Preview ready -->
<iframe
v-else-if="docxPreviewUrl"
:src="docxPreviewUrl"
class="w-full h-full rounded border"
type="application/pdf"
/>
</template>
<!-- Image Viewer --> <!-- Image Viewer -->
<template v-else-if="viewerType === 'image' && props.src"> <template v-else-if="viewerType === 'image' && props.src">
<img <img

View File

@ -120,10 +120,11 @@ const store = async () => {
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
}; };
const contractUuids = Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0 const contractUuids =
? form.contract_uuids Array.isArray(form.contract_uuids) && form.contract_uuids.length > 0
: null; ? form.contract_uuids
: null;
const isMultipleContracts = contractUuids && contractUuids.length > 1; const isMultipleContracts = contractUuids && contractUuids.length > 1;
form form
@ -175,37 +176,43 @@ const autoMailRequiresContract = computed(() => {
}); });
const contractItems = computed(() => { const contractItems = computed(() => {
return pageContracts.value.map(c => ({ return pageContracts.value.map((c) => ({
value: c.uuid, value: c.uuid,
label: `${c.reference}${c.name ? ` - ${c.name}` : ''}` label: `${c.reference}${c.name ? ` - ${c.name}` : ""}`,
})); }));
}); });
const autoMailDisabled = computed(() => { const autoMailDisabled = computed(() => {
if (!showSendAutoMail()) return false; if (!showSendAutoMail()) return false;
// Disable if multiple contracts selected // Disable if multiple contracts selected
if (form.contract_uuids && form.contract_uuids.length > 1) return true; if (form.contract_uuids && form.contract_uuids.length > 1) return true;
// Disable if template requires contract but none selected // Disable if template requires contract but none selected
if (autoMailRequiresContract.value && (!form.contract_uuids || form.contract_uuids.length === 0)) { if (
autoMailRequiresContract.value &&
(!form.contract_uuids || form.contract_uuids.length === 0)
) {
return true; return true;
} }
return false; return false;
}); });
const autoMailDisabledHint = computed(() => { const autoMailDisabledHint = computed(() => {
if (!showSendAutoMail()) return ""; if (!showSendAutoMail()) return "";
if (form.contract_uuids && form.contract_uuids.length > 1) { if (form.contract_uuids && form.contract_uuids.length > 1) {
return "Avtomatska e-pošta ni na voljo pri več pogodbah."; return "Avtomatska e-pošta ni na voljo pri več pogodbah.";
} }
if (autoMailRequiresContract.value && (!form.contract_uuids || form.contract_uuids.length === 0)) { if (
autoMailRequiresContract.value &&
(!form.contract_uuids || form.contract_uuids.length === 0)
) {
return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo."; return "Ta e-poštna predloga zahteva pogodbo. Najprej izberite pogodbo.";
} }
return ""; return "";
}); });
watch( watch(
@ -333,133 +340,148 @@ watch(
@confirm="store" @confirm="store"
> >
<form @submit.prevent="store"> <form @submit.prevent="store">
<div class="space-y-4"> <div class="space-y-4">
<div class="space-y-2"> <div class="space-y-2">
<Label>Akcija</Label> <Label>Akcija</Label>
<Select v-model="form.action_id" :disabled="!actions || !actions.length"> <Select v-model="form.action_id" :disabled="!actions || !actions.length">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Izberi akcijo" /> <SelectValue placeholder="Izberi akcijo" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem v-for="a in actions" :key="a.id" :value="a.id"> <SelectItem v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }} {{ a.name }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Odločitev</Label> <Label>Odločitev</Label>
<Select v-model="form.decision_id" :disabled="!decisions || !decisions.length"> <Select v-model="form.decision_id" :disabled="!decisions || !decisions.length">
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Izberi odločitev" /> <SelectValue placeholder="Izberi odločitev" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem v-for="d in decisions" :key="d.id" :value="d.id"> <SelectItem v-for="d in decisions" :key="d.id" :value="d.id">
{{ d.name }} {{ d.name }}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label>Pogodbe</Label> <Label>Pogodbe</Label>
<AppMultiSelect <AppMultiSelect
v-model="form.contract_uuids" v-model="form.contract_uuids"
:items="contractItems" :items="contractItems"
placeholder="Izberi pogodbe (neobvezno)" placeholder="Izberi pogodbe (neobvezno)"
search-placeholder="Išči pogodbo..." search-placeholder="Išči pogodbo..."
empty-text="Ni pogodb." empty-text="Ni pogodb."
:clearable="true" :clearable="true"
:show-selected-chips="true" :show-selected-chips="true"
/> />
<p v-if="form.contract_uuids && form.contract_uuids.length > 1" class="text-xs text-muted-foreground"> <p
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako pogodbo). v-if="form.contract_uuids && form.contract_uuids.length > 1"
</p> class="text-xs text-muted-foreground"
</div> >
Bo ustvarjenih {{ form.contract_uuids.length }} aktivnosti (ena za vsako
pogodbo).
</p>
</div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="activityNote">Opomba</Label> <Label for="activityNote">Opomba</Label>
<Textarea <Textarea
id="activityNote" id="activityNote"
v-model="form.note" v-model="form.note"
class="block w-full" class="block w-full max-h-72"
placeholder="Opomba" placeholder="Opomba"
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="activityDueDate">Datum zapadlosti</Label> <Label for="activityDueDate">Datum zapadlosti</Label>
<DatePicker <DatePicker
id="activityDueDate" id="activityDueDate"
v-model="form.due_date" v-model="form.due_date"
format="dd.MM.yyyy" format="dd.MM.yyyy"
:error="form.errors.due_date" :error="form.errors.due_date"
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<Label for="activityAmount">Znesek</Label> <Label for="activityAmount">Znesek</Label>
<CurrencyInput <CurrencyInput
id="activityAmount" id="activityAmount"
v-model="form.amount" v-model="form.amount"
:precision="{ min: 0, max: 4 }" :precision="{ min: 0, max: 4 }"
placeholder="0,00" placeholder="0,00"
class="w-full" class="w-full"
/> />
</div> </div>
<div v-if="showSendAutoMail()" class="space-y-2"> <div v-if="showSendAutoMail()" class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Switch <Switch v-model="form.send_auto_mail" :disabled="autoMailDisabled" />
v-model="form.send_auto_mail" <Label class="cursor-pointer">Send auto email</Label>
:disabled="autoMailDisabled"
/>
<Label class="cursor-pointer">Send auto email</Label>
</div>
</div> </div>
<p v-if="autoMailDisabled" class="text-xs text-amber-600"> </div>
{{ autoMailDisabledHint }} <p v-if="autoMailDisabled" class="text-xs text-amber-600">
</p> {{ autoMailDisabledHint }}
</p>
<div v-if="templateAllowsAttachments && form.contract_uuids && form.contract_uuids.length === 1" class="mt-3"> <div
<label class="inline-flex items-center gap-2"> v-if="
<Switch v-model="form.attach_documents" /> templateAllowsAttachments &&
<span class="text-sm">Dodaj priponke iz izbrane pogodbe</span> form.contract_uuids &&
</label> form.contract_uuids.length === 1
<div "
v-if="form.attach_documents" class="mt-3"
class="mt-2 border rounded p-2 max-h-48 overflow-auto" >
> <label class="inline-flex items-center gap-2">
<div class="text-xs text-gray-600 mb-2"> <Switch v-model="form.attach_documents" />
Izberite dokumente, ki bodo poslani kot priponke: <span class="text-sm">Dodaj priponke iz izbrane pogodbe</span>
</div> </label>
<div class="space-y-1"> <div
<template v-for="c in pageContracts" :key="c.uuid || c.id"> v-if="form.attach_documents"
<div v-if="c.uuid === form.contract_uuids[0]"> class="mt-2 border rounded p-2 max-h-48 overflow-auto"
<div class="font-medium text-sm text-gray-700 mb-1"> >
Pogodba {{ c.reference }} <div class="text-xs text-gray-600 mb-2">
</div> Izberite dokumente, ki bodo poslani kot priponke:
<div class="space-y-1"> </div>
<div <div class="space-y-1">
v-for="doc in availableContractDocs" <template v-for="c in pageContracts" :key="c.uuid || c.id">
:key="doc.uuid || doc.id" <div v-if="c.uuid === form.contract_uuids[0]">
class="flex items-center gap-2 text-sm" <div class="font-medium text-sm text-gray-700 mb-1">
> Pogodba {{ c.reference }}
<Switch </div>
:model-value="form.attachment_document_ids.includes(doc.id)" <div class="space-y-1">
@update:model-value="(checked) => { <div
v-for="doc in availableContractDocs"
:key="doc.uuid || doc.id"
class="flex items-center max-w-sm gap-2 text-sm"
>
<Switch
:model-value="form.attachment_document_ids.includes(doc.id)"
@update:model-value="
(checked) => {
if (checked) { if (checked) {
if (!form.attachment_document_ids.includes(doc.id)) { if (!form.attachment_document_ids.includes(doc.id)) {
form.attachment_document_ids.push(doc.id); form.attachment_document_ids.push(doc.id);
} }
} else { } else {
form.attachment_document_ids = form.attachment_document_ids.filter(id => id !== doc.id); form.attachment_document_ids = form.attachment_document_ids.filter(
(id) => id !== doc.id
);
} }
}" }
/> "
<span>{{ doc.original_name || doc.name }}</span> />
<div class="wrap-anywhere">
<p>
{{ doc.original_name || doc.name }}
</p>
<span class="text-xs text-gray-400" <span class="text-xs text-gray-400"
>({{ doc.extension?.toUpperCase() || "" }}, >({{ doc.extension?.toUpperCase() || "" }},
{{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span {{ (doc.size / 1024 / 1024).toFixed(2) }} MB)</span
@ -467,22 +489,23 @@ watch(
</div> </div>
</div> </div>
</div> </div>
</template>
<div
v-if="availableContractDocs.length === 0"
class="text-sm text-gray-500"
>
Ni dokumentov, povezanih s to pogodbo.
</div> </div>
</template>
<div
v-if="availableContractDocs.length === 0"
class="text-sm text-gray-500"
>
Ni dokumentov, povezanih s to pogodbo.
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<ActionMessage :on="form.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</ActionMessage>
</div> </div>
</form>
</CreateDialog> <ActionMessage :on="form.recentlySuccessful" class="text-sm text-green-600">
Shranjuje.
</ActionMessage>
</div>
</form>
</CreateDialog>
</template> </template>