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\Facades\Gate;
use Illuminate\Support\Str; use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Illuminate\Http\RedirectResponse;
class ContractDocumentGenerationController extends Controller 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 if (Gate::denies('read')) { // baseline read permission required to generate
abort(403); 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. // For custom tokens: pass overrides via request bag; service already reads request()->input('custom') if present.
$result = $renderer->render($template, $contract, Auth::user()); $result = $renderer->render($template, $contract, Auth::user());
} catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) { } catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) {
if ($wantsJson) {
return response()->json([ return response()->json([
'status' => 'error', 'status' => 'error',
'message' => 'Unresolved tokens detected.', 'message' => 'Unresolved tokens detected.',
'tokens' => $e->unresolved ?? [], '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) { } catch (\Throwable $e) {
try { try {
logger()->error('ContractDocumentGenerationController generation failed', [ logger()->error('ContractDocumentGenerationController generation failed', [
@ -66,12 +77,18 @@ public function __invoke(Request $request, Contract $contract): Response
} catch (\Throwable $logEx) { } catch (\Throwable $logEx) {
} }
if ($wantsJson) {
return response()->json([ return response()->json([
'status' => 'error', 'status' => 'error',
'message' => 'Generation failed.', 'message' => 'Generation failed.',
], 500); ], 500);
} }
return back()->withErrors([
'document' => 'Generation failed.',
]);
}
$doc = new Document; $doc = new Document;
$doc->fill([ $doc->fill([
'uuid' => (string) Str::uuid(), 'uuid' => (string) Str::uuid(),
@ -130,6 +147,7 @@ public function __invoke(Request $request, Contract $contract): Response
} }
} }
if ($wantsJson) {
return response()->json([ return response()->json([
'status' => 'ok', 'status' => 'ok',
'document_uuid' => $doc->uuid, '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 (! $isAllowed) {
if ($policy === 'fail') { if ($policy === 'fail') {
throw new \RuntimeException("Nedovoljen stolpec token: $token"); 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> <label class="block font-medium mb-1">Nov slug</label>
<input <input
v-model="uploadForm.slug" v-model="uploadForm.slug"
:disabled="selectedSlug" :disabled="!!selectedSlug"
type="text" type="text"
class="input input-bordered input-sm w-full" class="input input-bordered input-sm w-full"
placeholder="opomin" placeholder="opomin"

View File

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