updated document

This commit is contained in:
Simon Pocrnjič 2025-10-12 19:07:41 +02:00
parent 23f2011e33
commit ec6456cf23
4 changed files with 205 additions and 81 deletions

View File

@ -13,11 +13,15 @@
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use Illuminate\Http\RedirectResponse;
class ContractDocumentGenerationController extends Controller
{
public function __invoke(Request $request, Contract $contract): Response
public function __invoke(Request $request, Contract $contract): Response|RedirectResponse
{
// Inertia requests include the X-Inertia header and should receive redirects or Inertia responses, not JSON
$isInertia = (bool) $request->header('X-Inertia');
$wantsJson = ! $isInertia && ($request->expectsJson() || $request->wantsJson());
if (Gate::denies('read')) { // baseline read permission required to generate
abort(403);
}
@ -50,11 +54,18 @@ public function __invoke(Request $request, Contract $contract): Response
// For custom tokens: pass overrides via request bag; service already reads request()->input('custom') if present.
$result = $renderer->render($template, $contract, Auth::user());
} catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) {
if ($wantsJson) {
return response()->json([
'status' => 'error',
'message' => 'Unresolved tokens detected.',
'tokens' => $e->unresolved ?? [],
], 422);
], 500);
}
// Return back with validation-like errors so Inertia can surface them via onError
return back()->withErrors([
'document' => 'Unresolved tokens detected.',
])->with('unresolved_tokens', $e->unresolved ?? []);
} catch (\Throwable $e) {
try {
logger()->error('ContractDocumentGenerationController generation failed', [
@ -66,12 +77,18 @@ public function __invoke(Request $request, Contract $contract): Response
} catch (\Throwable $logEx) {
}
if ($wantsJson) {
return response()->json([
'status' => 'error',
'message' => 'Generation failed.',
], 500);
}
return back()->withErrors([
'document' => 'Generation failed.',
]);
}
$doc = new Document;
$doc->fill([
'uuid' => (string) Str::uuid(),
@ -130,6 +147,7 @@ public function __invoke(Request $request, Contract $contract): Response
}
}
if ($wantsJson) {
return response()->json([
'status' => 'ok',
'document_uuid' => $doc->uuid,
@ -143,4 +161,18 @@ public function __invoke(Request $request, Contract $contract): Response
],
]);
}
// Flash some lightweight info if needed by the UI; Inertia will GET the page after redirect
return back()->with([
'doc_generated' => [
'uuid' => $doc->uuid,
'path' => $doc->path,
'template' => [
'slug' => $template->slug,
'version' => $template->version,
],
'stats' => $result['stats'] ?? null,
],
]);
}
}

View File

@ -136,10 +136,6 @@ public function resolve(
}
}
}
// If still not allowed, permit tokens explicitly scanned/stored on the template
if (! $isAllowed && $templateTokens) {
$isAllowed = in_array($token, $templateTokens, true);
}
if (! $isAllowed) {
if ($policy === 'fail') {
throw new \RuntimeException("Nedovoljen stolpec token: $token");

View File

@ -109,7 +109,7 @@ const groups = computed(() => {
<label class="block font-medium mb-1">Nov slug</label>
<input
v-model="uploadForm.slug"
:disabled="selectedSlug"
:disabled="!!selectedSlug"
type="text"
class="input input-bordered input-sm w-full"
placeholder="opomin"

View File

@ -154,7 +154,7 @@ const onAddActivity = (c) => emit("add-activity", c);
// CaseObject dialog state
import { ref, computed } from "vue";
import { router, useForm } from "@inertiajs/vue3";
import axios from "axios";
import DialogModal from "@/Components/DialogModal.vue";
// Document generation state/dialog
const generating = ref({}); // contract_uuid => boolean
const generatedDocs = ref({}); // contract_uuid => { uuid, path }
@ -173,22 +173,37 @@ const personAddressSource = ref("case_person"); // for person.person_address.*
const clientAddress = computed(() => {
const addr = props.client?.person?.addresses?.[0] || null;
return addr
? { address: addr.address || "", post_code: addr.post_code || "", city: addr.city || "" }
? {
address: addr.address || "",
post_code: addr.post_code || "",
city: addr.city || "",
}
: { address: "", post_code: "", city: "" };
});
const casePersonAddress = computed(() => {
const addr = props.client_case?.person?.addresses?.[0] || null;
return addr
? { address: addr.address || "", post_code: addr.post_code || "", city: addr.city || "" }
? {
address: addr.address || "",
post_code: addr.post_code || "",
city: addr.city || "",
}
: { address: "", post_code: "", city: "" };
});
const customTokenList = computed(() => (templateTokens.value || []).filter((t) => t.startsWith("custom.")));
const customTokenList = computed(() =>
(templateTokens.value || []).filter((t) => t.startsWith("custom."))
);
function openGenerateDialog(c) {
generateFor.value = c;
// Prefer a template that actually has tokens; fallback to the first available
const first = (props.templates || []).find(t => Array.isArray(t?.tokens) && t.tokens.length > 0) || (props.templates || [])[0] || null;
const first =
(props.templates || []).find(
(t) => Array.isArray(t?.tokens) && t.tokens.length > 0
) ||
(props.templates || [])[0] ||
null;
selectedTemplateSlug.value = first?.slug || null;
templateTokens.value = Array.isArray(first?.tokens) ? first.tokens : [];
templateCustomDefaults.value = (first?.meta && first.meta.custom_defaults) || {};
@ -225,8 +240,14 @@ async function submitGenerate() {
generating.value[c.uuid] = true;
generationError.value[c.uuid] = null;
try {
const clientAddr = clientAddressSource.value === "case_person" ? casePersonAddress.value : clientAddress.value;
const personAddr = personAddressSource.value === "case_person" ? casePersonAddress.value : clientAddress.value;
const clientAddr =
clientAddressSource.value === "case_person"
? casePersonAddress.value
: clientAddress.value;
const personAddr =
personAddressSource.value === "case_person"
? casePersonAddress.value
: clientAddress.value;
const token_overrides = {
"client.person.person_address.address": clientAddr.address,
"client.person.person_address.post_code": clientAddr.post_code,
@ -240,33 +261,27 @@ async function submitGenerate() {
template_version: tpl.version,
custom: { ...customInputs.value },
token_overrides,
unresolved_policy: 'fail',
unresolved_policy: "fail",
};
const { data } = await axios.post(
await router.post(
route("contracts.generate-document", { contract: c.uuid }),
payload
);
if (data.status === "ok") {
generatedDocs.value[c.uuid] = { uuid: data.document_uuid, path: data.path };
// if no tokens were found/replaced, surface a gentle warning inline
const stats = data.stats || null;
// Show warning only when zero tokens were found in the template (most common real issue)
if (stats && stats.tokensFound === 0) {
generationError.value[c.uuid] = "Opozorilo: V predlogi niso bili najdeni tokeni.";
} else {
payload,
{
preserveScroll: true,
onSuccess: () => {
// Close dialog and refresh documents list
generationError.value[c.uuid] = null;
}
showGenerateDialog.value = false;
router.reload({ only: ["documents"] });
} else {
generationError.value[c.uuid] = data.message || "Napaka pri generiranju.";
}
} catch (e) {
if (e?.response?.status === 422) {
},
onError: () => {
// Typically 422 validation-like errors
generationError.value[c.uuid] = "Manjkajoči tokeni v predlogi.";
} else {
generationError.value[c.uuid] = "Neuspešno generiranje.";
},
}
);
} catch (e) {
generationError.value[c.uuid] = "Neuspešno generiranje.";
} finally {
generating.value[c.uuid] = false;
}
@ -747,7 +762,13 @@ const closePaymentsDialog = () => {
class="h-4 w-4 text-gray-600"
:class="generating[c.uuid] ? 'animate-spin' : ''"
/>
<span>{{ generating[c.uuid] ? 'Generiranje...' : (templates && templates.length ? 'Generiraj dokument' : 'Ni predlog') }}</span>
<span>{{
generating[c.uuid]
? "Generiranje..."
: templates && templates.length
? "Generiraj dokument"
: "Ni predlog"
}}</span>
</button>
<a
v-if="generatedDocs[c.uuid]?.path"
@ -941,15 +962,23 @@ const closePaymentsDialog = () => {
/>
<!-- Generate document dialog -->
<div v-if="showGenerateDialog" class="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div class="bg-white rounded-lg shadow-lg p-4 w-full max-w-lg">
<div class="text-base font-medium text-gray-900 mb-2">Generiraj dokument</div>
<DialogModal :show="showGenerateDialog" max-width="4xl" @close="closeGenerateDialog">
<template #title>Generiraj dokument</template>
<template #content>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700">Predloga</label>
<select v-model="selectedTemplateSlug" @change="onTemplateChange" class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500">
<option v-if="!templates || templates.length===0" :value="null" disabled>Ni aktivnih predlog</option>
<option v-for="t in templates" :key="t.slug" :value="t.slug">{{ t.name }} ({{ t.version }})</option>
<select
v-model="selectedTemplateSlug"
@change="onTemplateChange"
class="mt-1 w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option v-if="!templates || templates.length === 0" :value="null" disabled>
Ni aktivnih predlog
</option>
<option v-for="t in templates" :key="t.slug" :value="t.slug">
{{ t.name }} ({{ t.version }})
</option>
</select>
</div>
<div>
@ -970,15 +999,36 @@ const closePaymentsDialog = () => {
<div class="mt-2 grid grid-cols-3 gap-2 text-sm">
<div>
<div class="text-gray-500">Naslov</div>
<div class="text-gray-900 truncate">{{ (clientAddressSource==='case_person'?casePersonAddress:clientAddress).address || '-' }}</div>
<div class="text-gray-900 truncate">
{{
(clientAddressSource === "case_person"
? casePersonAddress
: clientAddress
).address || "-"
}}
</div>
</div>
<div>
<div class="text-gray-500">Pošta</div>
<div class="text-gray-900 truncate">{{ (clientAddressSource==='case_person'?casePersonAddress:clientAddress).post_code || '-' }}</div>
<div class="text-gray-900 truncate">
{{
(clientAddressSource === "case_person"
? casePersonAddress
: clientAddress
).post_code || "-"
}}
</div>
</div>
<div>
<div class="text-gray-500">Kraj</div>
<div class="text-gray-900 truncate">{{ (clientAddressSource==='case_person'?casePersonAddress:clientAddress).city || '-' }}</div>
<div class="text-gray-900 truncate">
{{
(clientAddressSource === "case_person"
? casePersonAddress
: clientAddress
).city || "-"
}}
</div>
</div>
</div>
</div>
@ -997,15 +1047,36 @@ const closePaymentsDialog = () => {
<div class="mt-2 grid grid-cols-3 gap-2 text-sm">
<div>
<div class="text-gray-500">Naslov</div>
<div class="text-gray-900 truncate">{{ (personAddressSource==='case_person'?casePersonAddress:clientAddress).address || '-' }}</div>
<div class="text-gray-900 truncate">
{{
(personAddressSource === "case_person"
? casePersonAddress
: clientAddress
).address || "-"
}}
</div>
</div>
<div>
<div class="text-gray-500">Pošta</div>
<div class="text-gray-900 truncate">{{ (personAddressSource==='case_person'?casePersonAddress:clientAddress).post_code || '-' }}</div>
<div class="text-gray-900 truncate">
{{
(personAddressSource === "case_person"
? casePersonAddress
: clientAddress
).post_code || "-"
}}
</div>
</div>
<div>
<div class="text-gray-500">Kraj</div>
<div class="text-gray-900 truncate">{{ (personAddressSource==='case_person'?casePersonAddress:clientAddress).city || '-' }}</div>
<div class="text-gray-900 truncate">
{{
(personAddressSource === "case_person"
? casePersonAddress
: clientAddress
).city || "-"
}}
</div>
</div>
</div>
</div>
@ -1014,15 +1085,24 @@ const closePaymentsDialog = () => {
<div v-if="customTokenList.length" class="pt-2">
<div class="text-sm font-medium text-gray-700 mb-1">Dodatna polja</div>
<div class="space-y-2">
<div v-for="tok in customTokenList" :key="tok" class="grid grid-cols-3 gap-2 items-start">
<div
v-for="tok in customTokenList"
:key="tok"
class="grid grid-cols-3 gap-2 items-start"
>
<div class="col-span-1 text-sm text-gray-600">{{ tok }}</div>
<div class="col-span-2">
<template v-if="templateCustomTypes[tok.replace(/^custom\./,'')] === 'text'">
<template
v-if="templateCustomTypes[tok.replace(/^custom\./, '')] === 'text'"
>
<textarea
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 text-sm"
rows="3"
v-model="customInputs[tok.replace(/^custom\./, '')]"
:placeholder="templateCustomDefaults[tok.replace(/^custom\./,'')] ?? 'privzeta vrednost'"
:placeholder="
templateCustomDefaults[tok.replace(/^custom\./, '')] ??
'privzeta vrednost'
"
></textarea>
</template>
<template v-else>
@ -1030,7 +1110,10 @@ const closePaymentsDialog = () => {
type="text"
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
v-model="customInputs[tok.replace(/^custom\./, '')]"
:placeholder="templateCustomDefaults[tok.replace(/^custom\./,'')] ?? 'privzeta vrednost'"
:placeholder="
templateCustomDefaults[tok.replace(/^custom\./, '')] ??
'privzeta vrednost'
"
/>
</template>
</div>
@ -1038,11 +1121,24 @@ const closePaymentsDialog = () => {
</div>
</div>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300" @click="closeGenerateDialog">Prekliči</button>
<button class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700" :disabled="!selectedTemplateSlug || generating[generateFor?.uuid]" @click="submitGenerate">Generiraj</button>
</div>
<div v-if="generationError[generateFor?.uuid]" class="mt-2 text-sm text-rose-600">{{ generationError[generateFor?.uuid] }}</div>
</div>
<div v-if="generationError[generateFor?.uuid]" class="mt-3 text-sm text-rose-600">
{{ generationError[generateFor?.uuid] }}
</div>
</template>
<template #footer>
<button
class="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 mr-2"
@click="closeGenerateDialog"
>
Prekliči
</button>
<button
class="px-4 py-2 rounded bg-indigo-600 text-white hover:bg-indigo-700"
:disabled="!selectedTemplateSlug || generating[generateFor?.uuid]"
@click="submitGenerate"
>
Generiraj
</button>
</template>
</DialogModal>
</template>