documents

This commit is contained in:
Simon Pocrnjič 2025-10-12 12:24:17 +02:00
parent 3ab1c05fcc
commit e0303ece74
22 changed files with 898 additions and 88 deletions

View File

@ -46,6 +46,7 @@ public function toggleActive(DocumentTemplate $template)
public function show(DocumentTemplate $template)
{
$this->ensurePermission();
return Inertia::render('Admin/DocumentTemplates/Show', [
'template' => $template,
]);
@ -121,6 +122,11 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT
if ($dirty) {
$template->formatting_options = $fmt;
}
// Merge meta, including custom_defaults
if ($request->has('meta') && is_array($request->input('meta'))) {
$meta = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== '');
$template->meta = array_replace($template->meta ?? [], $meta);
}
$template->updated_by = Auth::id();
$template->save();

View File

@ -23,6 +23,8 @@ public function __invoke(Request $request, Contract $contract): Response
}
$request->validate([
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
'custom' => ['nullable', 'array'],
'custom.*' => ['nullable'],
]);
$template = DocumentTemplate::where('slug', $request->template_slug)
@ -36,6 +38,7 @@ public function __invoke(Request $request, Contract $contract): Response
$renderer = app(\App\Services\Documents\DocxTemplateRenderer::class);
try {
// 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) {
return response()->json([

View File

@ -26,6 +26,10 @@ public function update(Person $person, Request $request)
$person->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Person updated');
}
return response()->json([
'person' => [
'full_name' => $person->full_name,
@ -41,6 +45,8 @@ public function createAddress(Person $person, Request $request)
$attributes = $request->validate([
'address' => 'required|string|max:150',
'country' => 'nullable|string',
'post_code' => 'nullable|string|max:16',
'city' => 'nullable|string|max:100',
'type_id' => 'required|integer|exists:address_types,id',
'description' => 'nullable|string|max:125',
]);
@ -49,8 +55,15 @@ public function createAddress(Person $person, Request $request)
$address = $person->addresses()->firstOrCreate([
'address' => $attributes['address'],
'country' => $attributes['country'] ?? null,
'post_code' => $attributes['post_code'] ?? null,
'city' => $attributes['city'] ?? null,
], $attributes);
// Support Inertia form submissions (redirect back) and JSON (for API/axios)
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address created');
}
return response()->json([
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
]);
@ -61,6 +74,8 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$attributes = $request->validate([
'address' => 'required|string|max:150',
'country' => 'nullable|string',
'post_code' => 'nullable|string|max:16',
'city' => 'nullable|string|max:100',
'type_id' => 'required|integer|exists:address_types,id',
'description' => 'nullable|string|max:125',
]);
@ -69,6 +84,10 @@ public function updateAddress(Person $person, int $address_id, Request $request)
$address->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address updated');
}
return response()->json([
'address' => $address,
]);
@ -79,6 +98,10 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
$address = $person->addresses()->findOrFail($address_id);
$address->delete(); // soft delete
if ($request->header('X-Inertia')) {
return back()->with('success', 'Address deleted');
}
return response()->json(['status' => 'ok']);
}
@ -97,6 +120,10 @@ public function createPhone(Person $person, Request $request)
'country_code' => $attributes['country_code'] ?? null,
], $attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Phone added successfully');
}
return response()->json([
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id),
]);
@ -115,6 +142,10 @@ public function updatePhone(Person $person, int $phone_id, Request $request)
$phone->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'Phone updated successfully');
}
return response()->json([
'phone' => $phone,
]);
@ -125,6 +156,10 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
$phone = $person->phones()->findOrFail($phone_id);
$phone->delete(); // soft delete
if ($request->header('X-Inertia')) {
return back()->with('success', 'Phone deleted');
}
return response()->json(['status' => 'ok']);
}
@ -176,6 +211,10 @@ public function deleteEmail(Person $person, int $email_id, Request $request)
$email = $person->emails()->findOrFail($email_id);
$email->delete();
if ($request->header('X-Inertia')) {
return back()->with('success', 'Email deleted');
}
return response()->json(['status' => 'ok']);
}
@ -198,6 +237,10 @@ public function createTrr(Person $person, Request $request)
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
$trr = $person->bankAccounts()->create($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR added successfully');
}
return response()->json([
'trr' => BankAccount::findOrFail($trr->id),
]);
@ -222,6 +265,10 @@ public function updateTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->update($attributes);
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR updated successfully');
}
return response()->json([
'trr' => $trr,
]);
@ -232,6 +279,10 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
$trr = $person->bankAccounts()->findOrFail($trr_id);
$trr->delete();
if ($request->header('X-Inertia')) {
return back()->with('success', 'TRR deleted');
}
return response()->json(['status' => 'ok']);
}
}

View File

@ -30,6 +30,10 @@ public function rules(): array
'date_formats.*' => ['nullable', 'string', 'max:40'],
'meta' => ['sometimes', 'array'],
'meta.*' => ['nullable'],
'meta.custom_defaults' => ['nullable', 'array'],
'meta.custom_defaults.*' => ['nullable'],
'meta.custom_default_types' => ['nullable', 'array'],
'meta.custom_default_types.*' => ['nullable', 'in:string,number,date'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'activity_note_template' => ['nullable', 'string'],

View File

@ -13,12 +13,14 @@ class DocumentSetting extends Model
'preview_enabled',
'whitelist',
'date_formats',
'custom_defaults',
];
protected $casts = [
'preview_enabled' => 'boolean',
'whitelist' => 'array',
'date_formats' => 'array',
'custom_defaults' => 'array',
];
public static function instance(): self
@ -30,6 +32,7 @@ public static function instance(): self
'preview_enabled' => config('documents.preview.enabled', true),
'whitelist' => config('documents.whitelist'),
'date_formats' => [],
'custom_defaults' => [],
]);
}
}

View File

@ -12,33 +12,39 @@ class PersonAddress extends Model
{
/** @use HasFactory<\Database\Factories\Person/PersonAddressFactory> */
use HasFactory;
use Searchable;
use SoftDeletes;
protected $fillable = [
'address',
'country',
'post_code',
'city',
'type_id',
'description',
'person_id',
'user_id'
'user_id',
];
protected $hidden = [
'user_id',
'person_id',
'deleted'
'deleted',
];
public function toSearchableArray(): array
{
return [
'address' => $this->address,
'country' => $this->country
'country' => $this->country,
'post_code' => $this->post_code,
'city' => $this->city,
];
}
protected static function booted(){
protected static function booted()
{
static::creating(function (PersonAddress $address) {
$address->user_id = auth()->id();
});

View File

@ -25,4 +25,16 @@ public function fresh(): DocumentSetting
{
return $this->refresh();
}
/**
* Convenience accessor for custom defaults.
*
* @return array<string,mixed>
*/
public function customDefaults(): array
{
$settings = $this->get();
return is_array($settings->custom_defaults ?? null) ? $settings->custom_defaults : [];
}
}

View File

@ -41,9 +41,22 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
// Determine effective unresolved policy early (template override -> global -> config)
$globalSettingsEarly = app(\App\Services\Documents\DocumentSettings::class)->get();
$effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($globalSettingsEarly->unresolved_policy ?? config('documents.unresolved_policy', 'fail'));
$resolved = $this->resolver->resolve($tokens, $template, $contract, $user, $effectivePolicy);
// Resolve with support for custom.* tokens: per-generation overrides and defaults from template meta or global settings.
$customOverrides = request()->input('custom', []); // if called via HTTP context; otherwise pass explicitly from caller
$customDefaults = is_array($template->meta['custom_defaults'] ?? null) ? $template->meta['custom_defaults'] : null;
$resolved = $this->resolver->resolve(
$tokens,
$template,
$contract,
$user,
$effectivePolicy,
is_array($customOverrides) ? $customOverrides : [],
$customDefaults,
'empty'
);
$values = $resolved['values'];
$initialUnresolved = $resolved['unresolved'];
$customTypes = $resolved['customTypes'] ?? [];
// Formatting options
$fmt = $template->formatting_options ?? [];
$decimals = (int) ($fmt['number_decimals'] ?? 2);
@ -55,8 +68,10 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
$globalSettings = app(\App\Services\Documents\DocumentSettings::class)->get();
$globalDateFormats = $globalSettings->date_formats ?? [];
foreach ($values as $k => $v) {
// Date formatting (heuristic based on key ending with _date or .date)
if (is_string($v) && ($k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
$isTypedDate = ($customTypes[$k] ?? null) === 'date';
$isTypedNumber = ($customTypes[$k] ?? null) === 'number';
// Date formatting (typed or heuristic based on key ending with _date or .date)
if (is_string($v) && ($isTypedDate || $k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
$dateFmtOverrides = $fmt['date_formats'] ?? [];
$desiredFormat = $dateFmtOverrides[$k]
?? ($globalDateFormats[$k] ?? null)
@ -75,9 +90,11 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
}
}
}
if (is_numeric($v)) {
// Number formatting: only for explicitly typed numbers or common monetary fields
$isFinanceField = (bool) preg_match('/(^|\.)\b(amount|balance|total|price|cost)\b$/i', $k);
if (($isTypedNumber || $isFinanceField) && is_numeric($v)) {
$num = number_format((float) $v, $decimals, $decSep, $thouSep);
if ($currencySymbol && preg_match('/(amount|balance|total|price|cost)/i', $k)) {
if ($currencySymbol && $isFinanceField) {
$space = $currencySpace ? ' ' : '';
if ($currencyPos === 'after') {
$num = $num.$space.$currencySymbol;

View File

@ -4,7 +4,8 @@
class TokenScanner
{
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)\s*}}/';
// Allow entity.attr with attr accepting letters, digits, underscore, dot and hyphen for flexibility (e.g., custom.order-id)
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_.-]+)\s*}}/';
/**
* @return array<int,string>

View File

@ -13,12 +13,39 @@ class TokenValueResolver
* Returns array with keys: values (resolved token=>value) and unresolved (list of tokens not resolved / not allowed)
* Policy determines whether invalid tokens throw (fail) or are collected (blank|keep).
*
* @return array{values:array<string,string>,unresolved:array<int,string>}
* @return array{values:array<string,string>,unresolved:array<int,string>,customTypes?:array<string,string>}
*/
public function resolve(array $tokens, DocumentTemplate $template, Contract $contract, User $user, string $policy = 'fail'): array
{
public function resolve(
array $tokens,
DocumentTemplate $template,
Contract $contract,
User $user,
string $policy = 'fail',
array $customOverrides = [],
?array $customDefaults = null,
string $onMissingCustom = 'empty'
): array {
$values = [];
$unresolved = [];
$customTypesOut = [];
// Custom namespace: merge defaults from settings/template meta and overrides
$settings = app(\App\Services\Documents\DocumentSettings::class)->get();
$defaults = $customDefaults ?? ($template->meta['custom_defaults'] ?? null) ?? ($settings->custom_defaults ?? []);
if (! is_array($defaults)) {
$defaults = [];
}
if (! is_array($customOverrides)) {
$customOverrides = [];
}
$custom = array_replace($defaults, $customOverrides);
// Collect custom types from template meta (optional)
$customTypes = [];
if (isset($template->meta['custom_default_types']) && is_array($template->meta['custom_default_types'])) {
foreach ($template->meta['custom_default_types'] as $k => $t) {
$t = in_array($t, ['string', 'number', 'date'], true) ? $t : 'string';
$customTypes[(string) $k] = $t;
}
}
// Retrieve whitelist from DB settings (if present) and merge with config baseline (config acts as baseline; DB can add or override entity arrays)
$settingsWhitelist = app(\App\Services\Documents\DocumentSettings::class)->get()->whitelist ?? [];
$configWhitelist = config('documents.whitelist', []);
@ -37,6 +64,34 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
continue;
}
if ($entity === 'custom') {
// Track type info if present
if (isset($customTypes[$attr])) {
$customTypesOut[$token] = $customTypes[$attr];
}
if (array_key_exists($attr, $custom)) {
$v = $custom[$attr];
if (is_scalar($v) || (is_object($v) && method_exists($v, '__toString'))) {
$values[$token] = (string) $v;
} else {
$values[$token] = '';
}
} else {
// Missing custom apply onMissingCustom policy locally (empty|leave|error)
if ($onMissingCustom === 'error') {
if ($policy === 'fail') {
throw new \RuntimeException("Manjkajoč custom token: {$token}");
}
$unresolved[] = $token;
} elseif ($onMissingCustom === 'leave') {
$unresolved[] = $token;
} else { // empty
$values[$token] = '';
}
}
continue;
}
if (! in_array($entity, $templateEntities, true)) {
if ($policy === 'fail') {
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
@ -45,7 +100,12 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
continue;
}
$allowed = ($template->columns[$entity] ?? []) ?: ($globalWhitelist[$entity] ?? []);
// Allowed attributes: merge template-declared columns with global whitelist (config + DB settings)
// Rationale: old templates may not list newly allowed attributes (like nested paths),
// so we honor both sources instead of preferring one exclusively.
$allowedFromTemplate = $template->columns[$entity] ?? [];
$allowedFromGlobal = $globalWhitelist[$entity] ?? [];
$allowed = array_values(array_unique(array_merge($allowedFromTemplate, $allowedFromGlobal)));
if (! in_array($attr, $allowed, true)) {
if ($policy === 'fail') {
throw new \RuntimeException("Nedovoljen stolpec token: $token");
@ -57,7 +117,11 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
$values[$token] = $this->entityAttribute($entity, $attr, $contract) ?? '';
}
return ['values' => $values, 'unresolved' => array_values(array_unique($unresolved))];
return [
'values' => $values,
'unresolved' => array_values(array_unique($unresolved)),
'customTypes' => $customTypesOut,
];
}
private function generationAttribute(string $attr, User $user): string
@ -78,11 +142,25 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac
case 'client_case':
return (string) optional($contract->clientCase)->{$attr};
case 'client':
return (string) optional(optional($contract->clientCase)->client)->{$attr};
case 'person':
$person = optional(optional($contract->clientCase)->person);
$client = optional($contract->clientCase)->client;
if (! $client) {
return '';
}
if (str_contains($attr, '.')) {
return $this->resolveNestedFromModel($client, $attr);
}
return (string) $person->{$attr};
return (string) ($client->{$attr} ?? '');
case 'person':
$person = optional($contract->clientCase)->person;
if (! $person) {
return '';
}
if (str_contains($attr, '.')) {
return $this->resolveNestedFromModel($person, $attr);
}
return (string) ($person->{$attr} ?? '');
case 'account':
$account = optional($contract->account);
@ -91,4 +169,39 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac
return '';
}
}
/**
* Resolve nested dotted paths from a base model for supported relations/aliases.
* Supports:
* - Client: person.*
* - Person: person_address.* (uses first active address)
*/
private function resolveNestedFromModel(object $model, string $path): string
{
$segments = explode('.', $path);
$current = $model;
foreach ($segments as $seg) {
if (! $current) {
return '';
}
if ($current instanceof \App\Models\Client && $seg === 'person') {
$current = $current->person;
continue;
}
if ($current instanceof \App\Models\Person\Person && $seg === 'person_address') {
$current = $current->addresses()->first();
continue;
}
// Default attribute access
try {
$current = is_array($current) ? ($current[$seg] ?? null) : ($current->{$seg} ?? null);
} catch (\Throwable $e) {
return '';
}
}
return $current !== null ? (string) $current : '';
}
}

View File

@ -2365,13 +2365,15 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
if (! $field) {
continue;
}
$val = $addrData[$field] ?? null;
// Allow alias 'postal_code' in CSV mappings but persist as 'post_code' in DB
$targetField = $field === 'postal_code' ? 'post_code' : $field;
$val = $addrData[$field] ?? $addrData[$targetField] ?? null;
$mode = $map->apply_mode ?? 'both';
if (in_array($mode, ['insert', 'both'])) {
$applyInsert[$field] = $val;
$applyInsert[$targetField] = $val;
}
if (in_array($mode, ['update', 'both'])) {
$applyUpdate[$field] = $val;
$applyUpdate[$targetField] = $val;
}
}
if ($existing) {

View File

@ -20,7 +20,26 @@
'whitelist' => [
'contract' => ['reference', 'start_date', 'end_date', 'description'],
'client_case' => ['client_ref'],
'client' => [],
'person' => ['full_name', 'first_name', 'last_name', 'nu'],
'client' => [
'person',
'person.full_name',
'person.first_name',
'person.last_name',
'person.nu',
'person.person_address',
'person.person_address.address',
'person.person_address.post_code',
'person.person_address.city',
],
'person' => [
'full_name',
'first_name',
'last_name',
'nu',
'person_address',
'person_address.address',
'person_address.post_code',
'person_address.city',
],
],
];

View File

@ -21,6 +21,8 @@ public function definition(): array
return [
'address' => $this->faker->streetAddress(),
'country' => 'SI',
'post_code' => $this->faker->postcode(),
'city' => $this->faker->city(),
'type_id' => AddressType::factory(),
'user_id' => User::factory(),
];

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('person_addresses', function (Blueprint $table): void {
$table->string('post_code', 16)->nullable();
$table->string('city', 100)->nullable();
});
}
public function down(): void
{
Schema::table('person_addresses', function (Blueprint $table): void {
if (Schema::hasColumn('person_addresses', 'post_code')) {
$table->dropColumn('post_code');
}
if (Schema::hasColumn('person_addresses', 'city')) {
$table->dropColumn('city');
}
});
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('document_settings', function (Blueprint $table): void {
if (! Schema::hasColumn('document_settings', 'custom_defaults')) {
$table->json('custom_defaults')->nullable();
}
});
}
public function down(): void
{
Schema::table('document_settings', function (Blueprint $table): void {
if (Schema::hasColumn('document_settings', 'custom_defaults')) {
$table->dropColumn('custom_defaults');
}
});
}
};

View File

@ -1,5 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import { useForm, router, usePage } from '@inertiajs/vue3';
import DialogModal from './DialogModal.vue';
import InputLabel from './InputLabel.vue';
import SectionTitle from './SectionTitle.vue';
@ -33,47 +34,47 @@ const emit = defineEmits(['close']);
const close = () => {
emit('close');
setTimeout(() => {
errors.value = {};
}, 500);
try { form.clearErrors && form.clearErrors(); } catch {}
}, 300);
}
const form = ref({
const form = useForm({
address: '',
country: '',
type_id: props.types[0].id,
post_code: '',
city: '',
type_id: props.types?.[0]?.id ?? null,
description: ''
});
const resetForm = () => {
form.value = {
address: '',
country: '',
type_id: props.types[0].id,
description: ''
};
form.address = '';
form.country = '';
form.post_code = '';
form.city = '';
form.type_id = props.types?.[0]?.id ?? null;
form.description = '';
}
const create = async () => {
processing.value = true;
errors.value = {};
const data = await axios({
method: 'post',
url: route('person.address.create', props.person),
data: form.value
}).then((response) => {
props.person.addresses.push(response.data.address);
processing.value = false;
close();
resetForm();
}).catch((reason) => {
errors.value = reason.response.data.errors;
processing.value = false;
form.post(route('person.address.create', props.person), {
preserveScroll: true,
onSuccess: () => {
// Optimistically append from last created record in DB by refetch or expose via flash if needed.
// For now, trigger a lightweight reload of person's addresses via a GET if you have an endpoint, else trust parent reactivity.
processing.value = false;
close();
form.reset();
},
onError: (e) => {
errors.value = e || {};
processing.value = false;
},
});
}
@ -81,23 +82,17 @@ const update = async () => {
processing.value = true;
errors.value = {};
const data = await axios({
method: 'put',
url: route('person.address.update', {person: props.person, address_id: props.id}),
data: form.value
}).then((response) => {
console.log(response.data.address)
const index = props.person.addresses.findIndex( a => a.id === response.data.address.id );
props.person.addresses[index] = response.data.address;
processing.value = false;
close();
resetForm();
}).catch((reason) => {
errors.value = reason.response.data.errors;
processing.value = false;
form.put(route('person.address.update', {person: props.person, address_id: props.id}), {
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
form.reset();
},
onError: (e) => {
errors.value = e || {};
processing.value = false;
},
});
}
@ -108,12 +103,12 @@ watch(
console.log(props.edit)
props.person.addresses.filter((a) => {
if(a.id === props.id){
form.value = {
address: a.address,
country: a.country,
type_id: a.type_id,
description: a.description
};
form.address = a.address;
form.country = a.country;
form.post_code = a.post_code || a.postal_code || '';
form.city = a.city || '';
form.type_id = a.type_id;
form.description = a.description;
}
});
return;
@ -175,6 +170,28 @@ const callSubmit = () => {
<InputError v-if="errors.address !== undefined" v-for="err in errors.address" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="cr_post_code" value="Poštna številka" />
<TextInput
id="cr_post_code"
v-model="form.post_code"
type="text"
class="mt-1 block w-full"
autocomplete="postal-code"
/>
<InputError v-if="errors.post_code !== undefined" v-for="err in errors.post_code" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="cr_city" value="Mesto" />
<TextInput
id="cr_city"
v-model="form.city"
type="text"
class="mt-1 block w-full"
autocomplete="address-level2"
/>
<InputError v-if="errors.city !== undefined" v-for="err in errors.city" :message="err" />
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="cr_type" value="Tip"/>
<select

View File

@ -1,17 +1,197 @@
<template>
<div></div>
</template>
<script setup>
import { ref, watch } from "vue";
import { useForm } from "@inertiajs/vue3";
import DialogModal from "./DialogModal.vue";
import InputLabel from "./InputLabel.vue";
import SectionTitle from "./SectionTitle.vue";
import TextInput from "./TextInput.vue";
import InputError from "./InputError.vue";
import PrimaryButton from "./PrimaryButton.vue";
<script>
export default {
name: "Test",
created() {},
data() {
return {};
},
props: {},
methods: {},
const props = defineProps({
show: { type: Boolean, default: false },
person: Object,
types: Array,
id: { type: Number, default: 0 },
});
const processing = ref(false);
const errors = ref({});
const emit = defineEmits(["close"]);
const close = () => {
emit("close");
setTimeout(() => {
errors.value = {};
try {
form.clearErrors && form.clearErrors();
} catch {}
}, 300);
};
const form = useForm({
address: "",
country: "",
post_code: "",
city: "",
type_id: props.types?.[0]?.id ?? null,
description: "",
});
const resetForm = () => {
form.address = "";
form.country = "";
form.post_code = "";
form.city = "";
form.type_id = props.types?.[0]?.id ?? null;
form.description = "";
};
const hydrate = () => {
const id = props.id;
if (id) {
const a = (props.person.addresses || []).find((x) => x.id === id);
if (a) {
form.address = a.address;
form.country = a.country;
form.post_code = a.post_code || a.postal_code || "";
form.city = a.city || "";
form.type_id = a.type_id;
form.description = a.description || "";
}
} else {
resetForm();
}
};
watch(
() => props.id,
() => hydrate(),
{ immediate: true }
);
watch(
() => props.show,
(v) => {
if (v) hydrate();
}
);
const update = async () => {
processing.value = true;
errors.value = {};
form.put(
route("person.address.update", { person: props.person, address_id: props.id }),
{
preserveScroll: true,
onSuccess: () => {
processing.value = false;
close();
form.reset();
},
onError: (e) => {
errors.value = e || {};
processing.value = false;
},
}
);
};
</script>
<style lang="scss" scoped></style>
<template>
<DialogModal :show="show" @close="close">
<template #title>Spremeni naslov</template>
<template #content>
<form @submit.prevent="update">
<SectionTitle class="border-b mb-4"
><template #title>Naslov</template></SectionTitle
>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="up_address" value="Naslov" />
<TextInput
id="up_address"
v-model="form.address"
type="text"
class="mt-1 block w-full"
autocomplete="address"
/>
<InputError
v-if="errors.address !== undefined"
v-for="err in errors.address"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="up_country" value="Država" />
<TextInput
id="up_country"
v-model="form.country"
type="text"
class="mt-1 block w-full"
autocomplete="country"
/>
<InputError
v-if="errors.country !== undefined"
v-for="err in errors.country"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="up_post_code" value="Poštna številka" />
<TextInput
id="up_post_code"
v-model="form.post_code"
type="text"
class="mt-1 block w-full"
autocomplete="postal-code"
/>
<InputError
v-if="errors.post_code !== undefined"
v-for="err in errors.post_code"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="up_city" value="Mesto" />
<TextInput
id="up_city"
v-model="form.city"
type="text"
class="mt-1 block w-full"
autocomplete="address-level2"
/>
<InputError
v-if="errors.city !== undefined"
v-for="err in errors.city"
:message="err"
/>
</div>
<div class="col-span-6 sm:col-span-4">
<InputLabel for="up_type" value="Tip" />
<select
id="up_type"
v-model="form.type_id"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
>
<option v-for="type in types" :key="type.id" :value="type.id">
{{ type.name }}
</option>
</select>
</div>
<div class="flex justify-end mt-4">
<PrimaryButton :class="{ 'opacity-25': processing }" :disabled="processing"
>Shrani</PrimaryButton
>
</div>
</form>
</template>
</DialogModal>
</template>
<style scoped></style>

View File

@ -7,6 +7,7 @@ import { provide, ref, watch } from 'vue';
import axios from 'axios';
import PersonUpdateForm from './PersonUpdateForm.vue';
import AddressCreateForm from './AddressCreateForm.vue';
import AddressUpdateForm from './AddressUpdateForm.vue';
import PhoneCreateForm from './PhoneCreateForm.vue';
import EmailCreateForm from './EmailCreateForm.vue';
import EmailUpdateForm from './EmailUpdateForm.vue';
@ -74,8 +75,9 @@ const closeConfirm = () => { confirm.value.show = false; };
const getMainAddress = (adresses) => {
const addr = adresses.filter( a => a.type.id === 1 )[0] ?? '';
if( addr !== '' ){
const tail = (addr.post_code && addr.city) ? `, ${addr.post_code} ${addr.city}` : '';
const country = addr.country !== '' ? ` - ${addr.country}` : '';
return addr.address !== '' ? addr.address + country : '';
return addr.address !== '' ? (addr.address + tail + country) : '';
}
return '';
@ -234,7 +236,9 @@ const getTRRs = (p) => {
<button @click="openConfirm('address', address.id, address.address)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
</div>
</div>
<p class="text-sm md:text-base leading-7 text-gray-900">{{ address.address }}</p>
<p class="text-sm md:text-base leading-7 text-gray-900">
{{ (address.post_code && address.city) ? `${address.address}, ${address.post_code} ${address.city}` : address.address }}
</p>
</div>
</div>
</CusTab>
@ -335,6 +339,13 @@ const getTRRs = (p) => {
:id="editAddressId"
:edit="editAddress"
/>
<AddressUpdateForm
:show="drawerAddAddress && editAddress"
@close="drawerAddAddress = false"
:person="person"
:types="types.address_types"
:id="editAddressId"
/>
<PhoneCreateForm
:show="drawerAddPhone"

View File

@ -205,6 +205,49 @@
</div>
</div>
<!-- Custom tokens defaults -->
<div class="bg-white border rounded-lg shadow-sm p-5 space-y-5">
<h2 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
Custom tokens (privzete vrednosti)
</h2>
<div class="space-y-3">
<div class="flex items-center gap-2">
<button type="button" :class="[btnBase, btnOutline]" @click="addCustomDefault">
Dodaj vrstico
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div
v-for="(row, idx) in customRows"
:key="idx"
class="grid grid-cols-12 items-center gap-2"
>
<input
v-model="row.key"
type="text"
class="input input-bordered input-sm w-full col-span-4"
placeholder="custom ključ (npr. order_id)"
/>
<input
v-model="row.value"
type="text"
class="input input-bordered input-sm w-full col-span-5"
placeholder="privzeta vrednost"
/>
<select v-model="row.type" class="select select-bordered select-sm w-full col-span-2">
<option value="string">string</option>
<option value="number">number</option>
<option value="date">date</option>
</select>
<button type="button" class="btn btn-ghost btn-xs col-span-1" @click="removeCustomDefault(idx)"></button>
</div>
</div>
<p class="text-[11px] text-gray-500">
Uporabite v predlogi kot <code v-pre>{{custom.your_key}}</code>. Manjkajoče vrednosti se privzeto izpraznijo.
</p>
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<button
type="submit"
@ -274,7 +317,7 @@
</template>
<script setup>
import { computed } from "vue";
import { computed, reactive } from "vue";
import { useForm, Link, router } from "@inertiajs/vue3";
import AdminLayout from "@/Layouts/AdminLayout.vue";
@ -303,6 +346,8 @@ const form = useForm({
action_id: props.template.action_id ?? null,
decision_id: props.template.decision_id ?? null,
activity_note_template: props.template.activity_note_template || "",
// meta will include custom_defaults on submit
meta: props.template.meta || {},
});
const toggleForm = useForm({});
@ -322,6 +367,20 @@ function handleActionChange() {
}
function submit() {
// Build meta.custom_defaults object from rows
const entries = customRows
.filter((r) => (r.key || "").trim() !== "")
.reduce((acc, r) => {
acc[r.key.trim()] = r.value ?? "";
return acc;
}, {});
const types = customRows
.filter((r) => (r.key || "").trim() !== "")
.reduce((acc, r) => {
acc[r.key.trim()] = r.type || 'string';
return acc;
}, {});
form.meta = Object.assign({}, form.meta || {}, { custom_defaults: entries, custom_default_types: types });
form.put(route("admin.document-templates.settings.update", props.template.id));
}
@ -330,4 +389,22 @@ function toggleActive() {
preserveScroll: true,
});
}
// Custom defaults rows state
const baseDefaults = (props.template.meta && props.template.meta.custom_defaults) || {};
const baseTypes = (props.template.meta && props.template.meta.custom_default_types) || {};
const customRows = reactive(
Object.keys(baseDefaults).length
? Object.entries(baseDefaults).map(([k, v]) => ({ key: k, value: v, type: baseTypes[k] || 'string' }))
: [{ key: "", value: "", type: 'string' }]
);
function addCustomDefault() {
customRows.push({ key: "", value: "", type: 'string' });
}
function removeCustomDefault(idx) {
customRows.splice(idx, 1);
if (!customRows.length) customRows.push({ key: "", value: "" });
}
</script>

View File

@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use App\Models\Contract;
use App\Models\DocumentTemplate;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentCustomTokensTest extends TestCase
{
public function test_custom_tokens_defaults_and_overrides(): void
{
Storage::fake('public');
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
$this->actingAs($user);
// Minimal docx with custom tokens
$tmp = tempnam(sys_get_temp_dir(), 'doc');
$zip = new \ZipArchive;
$zip->open($tmp, \ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
$zip->addFromString('word/document.xml', '<w:document><w:body>{{custom.order_id}}|{{custom.missing}}</w:body></w:document>');
$zip->close();
$bytes = file_get_contents($tmp);
$upload = UploadedFile::fake()->createWithContent('template.docx', $bytes);
// Store template via controller to ensure tokens/metadata recorded
$resp = $this->post(route('admin.document-templates.store'), [
'name' => 'Custom Test',
'slug' => 'custom-test',
'file' => $upload,
]);
$resp->assertRedirect();
// Add defaults via meta
$template = DocumentTemplate::where('slug', 'custom-test')->orderByDesc('version')->firstOrFail();
$template->meta = ['custom_defaults' => ['order_id' => 'DEF-123']];
$template->save();
$contract = Contract::factory()->create();
// Call generation with override for order_id; missing should become empty by default
$gen = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'custom-test',
'custom' => ['order_id' => 'OVR-999'],
]);
$gen->assertOk()->assertJson(['status' => 'ok']);
$doc = \App\Models\Document::latest('id')->first();
$this->assertNotNull($doc);
// For strictness, we could open the docx to verify replacements, but that is heavy.
// A lighter check: ensure no unresolved/fail occurred and file exists.
$this->assertTrue(Storage::disk('public')->exists($doc->path));
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace Tests\Feature;
use App\Models\Contract;
use App\Models\DocumentTemplate;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentCustomTokensTypesTest extends TestCase
{
public function test_typed_custom_tokens_are_formatted(): void
{
Storage::fake('public');
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
$this->actingAs($user);
// Minimal docx with custom tokens for number and date
$tmp = tempnam(sys_get_temp_dir(), 'doc');
$zip = new \ZipArchive;
$zip->open($tmp, \ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
$zip->addFromString('word/document.xml', '<w:document><w:body>{{custom.total_amount}}|{{custom.due_date}}</w:body></w:document>');
$zip->close();
$bytes = file_get_contents($tmp);
$upload = UploadedFile::fake()->createWithContent('typed.docx', $bytes);
// Create template
$resp = $this->post(route('admin.document-templates.store'), [
'name' => 'Typed Custom',
'slug' => 'typed-custom',
'file' => $upload,
]);
$resp->assertRedirect();
$template = DocumentTemplate::where('slug', 'typed-custom')->orderByDesc('version')->firstOrFail();
$template->meta = [
'custom_defaults' => [
'total_amount' => null,
'due_date' => null,
],
'custom_default_types' => [
'total_amount' => 'number',
'due_date' => 'date',
],
];
$template->formatting_options = [
'number_decimals' => 2,
'decimal_separator' => ',',
'thousands_separator' => '.',
// date format may be controlled via template->date_format or formatting_options['default_date_format']
'default_date_format' => 'd.m.Y',
];
$template->save();
$contract = Contract::factory()->create();
// Generate with explicit overrides
$gen = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'typed-custom',
'custom' => [
'total_amount' => '1234.5',
'due_date' => '2025-10-12',
],
]);
$gen->assertOk()->assertJson(['status' => 'ok']);
$doc = \App\Models\Document::latest('id')->first();
$this->assertNotNull($doc);
$this->assertTrue(Storage::disk('public')->exists($doc->path));
// Inspect generated document.xml to assert formatted values exist
$path = Storage::disk('public')->path($doc->path);
$z = new \ZipArchive;
$this->assertTrue($z->open($path) === true);
$xml = $z->getFromName('word/document.xml');
$z->close();
$this->assertIsString($xml);
// Expect EU formatted number and date
$this->assertStringContainsString('1.234,50', $xml);
$this->assertStringContainsString('12.10.2025', $xml);
}
}

View File

@ -0,0 +1,80 @@
<?php
namespace Tests\Feature;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Contract;
use App\Models\DocumentTemplate;
use App\Models\Person\Person;
use App\Models\Person\PersonAddress;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentNestedEntitiesTest extends TestCase
{
public function test_client_person_and_person_address_tokens(): void
{
Storage::fake('public');
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
$this->actingAs($user);
// Build a simple DOCX containing nested tokens
$tmp = tempnam(sys_get_temp_dir(), 'doc');
$zip = new \ZipArchive;
$zip->open($tmp, \ZipArchive::OVERWRITE);
$zip->addFromString('[Content_Types].xml', '<Types></Types>');
$zip->addFromString('word/document.xml', '<w:document><w:body>{{client.person.full_name}}|{{person.person_address.address}},{{person.person_address.post_code}} {{person.person_address.city}}</w:body></w:document>');
$zip->close();
$bytes = file_get_contents($tmp);
$upload = UploadedFile::fake()->createWithContent('nested.docx', $bytes);
$resp = $this->post(route('admin.document-templates.store'), [
'name' => 'Nested',
'slug' => 'nested',
'file' => $upload,
]);
$resp->assertRedirect();
$template = DocumentTemplate::where('slug', 'nested')->orderByDesc('version')->firstOrFail();
// Create models
$person = Person::factory()->create(['full_name' => 'Jane Doe']);
$type = \App\Models\Person\AddressType::factory()->create();
PersonAddress::create([
'person_id' => $person->id,
'address' => 'Main 1',
'post_code' => '1000',
'city' => 'Ljubljana',
'type_id' => $type->id,
'active' => 1,
]);
$client = Client::factory()->create(['person_id' => $person->id]);
$case = ClientCase::factory()->create(['client_id' => $client->id, 'person_id' => $person->id]);
$contract = Contract::factory()->create(['client_case_id' => $case->id]);
$gen = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'nested',
]);
$gen->assertOk()->assertJson(['status' => 'ok']);
$doc = \App\Models\Document::latest('id')->first();
$this->assertTrue(Storage::disk('public')->exists($doc->path));
// Inspect XML
$path = Storage::disk('public')->path($doc->path);
$z = new \ZipArchive;
$this->assertTrue($z->open($path) === true);
$xml = $z->getFromName('word/document.xml');
$z->close();
$this->assertIsString($xml);
$this->assertStringContainsString('Jane Doe', $xml);
$this->assertStringContainsString('Main 1,1000 Ljubljana', $xml);
}
}