documents
This commit is contained in:
parent
3ab1c05fcc
commit
e0303ece74
|
|
@ -46,6 +46,7 @@ public function toggleActive(DocumentTemplate $template)
|
||||||
public function show(DocumentTemplate $template)
|
public function show(DocumentTemplate $template)
|
||||||
{
|
{
|
||||||
$this->ensurePermission();
|
$this->ensurePermission();
|
||||||
|
|
||||||
return Inertia::render('Admin/DocumentTemplates/Show', [
|
return Inertia::render('Admin/DocumentTemplates/Show', [
|
||||||
'template' => $template,
|
'template' => $template,
|
||||||
]);
|
]);
|
||||||
|
|
@ -121,6 +122,11 @@ public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentT
|
||||||
if ($dirty) {
|
if ($dirty) {
|
||||||
$template->formatting_options = $fmt;
|
$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->updated_by = Auth::id();
|
||||||
$template->save();
|
$template->save();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ public function __invoke(Request $request, Contract $contract): Response
|
||||||
}
|
}
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
|
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
|
||||||
|
'custom' => ['nullable', 'array'],
|
||||||
|
'custom.*' => ['nullable'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$template = DocumentTemplate::where('slug', $request->template_slug)
|
$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);
|
$renderer = app(\App\Services\Documents\DocxTemplateRenderer::class);
|
||||||
try {
|
try {
|
||||||
|
// 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) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,10 @@ public function update(Person $person, Request $request)
|
||||||
|
|
||||||
$person->update($attributes);
|
$person->update($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Person updated');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'person' => [
|
'person' => [
|
||||||
'full_name' => $person->full_name,
|
'full_name' => $person->full_name,
|
||||||
|
|
@ -41,6 +45,8 @@ public function createAddress(Person $person, Request $request)
|
||||||
$attributes = $request->validate([
|
$attributes = $request->validate([
|
||||||
'address' => 'required|string|max:150',
|
'address' => 'required|string|max:150',
|
||||||
'country' => 'nullable|string',
|
'country' => 'nullable|string',
|
||||||
|
'post_code' => 'nullable|string|max:16',
|
||||||
|
'city' => 'nullable|string|max:100',
|
||||||
'type_id' => 'required|integer|exists:address_types,id',
|
'type_id' => 'required|integer|exists:address_types,id',
|
||||||
'description' => 'nullable|string|max:125',
|
'description' => 'nullable|string|max:125',
|
||||||
]);
|
]);
|
||||||
|
|
@ -49,8 +55,15 @@ public function createAddress(Person $person, Request $request)
|
||||||
$address = $person->addresses()->firstOrCreate([
|
$address = $person->addresses()->firstOrCreate([
|
||||||
'address' => $attributes['address'],
|
'address' => $attributes['address'],
|
||||||
'country' => $attributes['country'] ?? null,
|
'country' => $attributes['country'] ?? null,
|
||||||
|
'post_code' => $attributes['post_code'] ?? null,
|
||||||
|
'city' => $attributes['city'] ?? null,
|
||||||
], $attributes);
|
], $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([
|
return response()->json([
|
||||||
'address' => \App\Models\Person\PersonAddress::with(['type'])->findOrFail($address->id),
|
'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([
|
$attributes = $request->validate([
|
||||||
'address' => 'required|string|max:150',
|
'address' => 'required|string|max:150',
|
||||||
'country' => 'nullable|string',
|
'country' => 'nullable|string',
|
||||||
|
'post_code' => 'nullable|string|max:16',
|
||||||
|
'city' => 'nullable|string|max:100',
|
||||||
'type_id' => 'required|integer|exists:address_types,id',
|
'type_id' => 'required|integer|exists:address_types,id',
|
||||||
'description' => 'nullable|string|max:125',
|
'description' => 'nullable|string|max:125',
|
||||||
]);
|
]);
|
||||||
|
|
@ -69,6 +84,10 @@ public function updateAddress(Person $person, int $address_id, Request $request)
|
||||||
|
|
||||||
$address->update($attributes);
|
$address->update($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Address updated');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'address' => $address,
|
'address' => $address,
|
||||||
]);
|
]);
|
||||||
|
|
@ -79,6 +98,10 @@ public function deleteAddress(Person $person, int $address_id, Request $request)
|
||||||
$address = $person->addresses()->findOrFail($address_id);
|
$address = $person->addresses()->findOrFail($address_id);
|
||||||
$address->delete(); // soft delete
|
$address->delete(); // soft delete
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Address deleted');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'ok']);
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,6 +120,10 @@ public function createPhone(Person $person, Request $request)
|
||||||
'country_code' => $attributes['country_code'] ?? null,
|
'country_code' => $attributes['country_code'] ?? null,
|
||||||
], $attributes);
|
], $attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Phone added successfully');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'phone' => \App\Models\Person\PersonPhone::with(['type'])->findOrFail($phone->id),
|
'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);
|
$phone->update($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Phone updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'phone' => $phone,
|
'phone' => $phone,
|
||||||
]);
|
]);
|
||||||
|
|
@ -125,6 +156,10 @@ public function deletePhone(Person $person, int $phone_id, Request $request)
|
||||||
$phone = $person->phones()->findOrFail($phone_id);
|
$phone = $person->phones()->findOrFail($phone_id);
|
||||||
$phone->delete(); // soft delete
|
$phone->delete(); // soft delete
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Phone deleted');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'ok']);
|
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 = $person->emails()->findOrFail($email_id);
|
||||||
$email->delete();
|
$email->delete();
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'Email deleted');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'ok']);
|
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
|
// Create without dedup (IBAN may be null or vary); could dedup by IBAN if provided
|
||||||
$trr = $person->bankAccounts()->create($attributes);
|
$trr = $person->bankAccounts()->create($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'TRR added successfully');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'trr' => BankAccount::findOrFail($trr->id),
|
'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 = $person->bankAccounts()->findOrFail($trr_id);
|
||||||
$trr->update($attributes);
|
$trr->update($attributes);
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'TRR updated successfully');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'trr' => $trr,
|
'trr' => $trr,
|
||||||
]);
|
]);
|
||||||
|
|
@ -232,6 +279,10 @@ public function deleteTrr(Person $person, int $trr_id, Request $request)
|
||||||
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
$trr = $person->bankAccounts()->findOrFail($trr_id);
|
||||||
$trr->delete();
|
$trr->delete();
|
||||||
|
|
||||||
|
if ($request->header('X-Inertia')) {
|
||||||
|
return back()->with('success', 'TRR deleted');
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json(['status' => 'ok']);
|
return response()->json(['status' => 'ok']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,10 @@ public function rules(): array
|
||||||
'date_formats.*' => ['nullable', 'string', 'max:40'],
|
'date_formats.*' => ['nullable', 'string', 'max:40'],
|
||||||
'meta' => ['sometimes', 'array'],
|
'meta' => ['sometimes', 'array'],
|
||||||
'meta.*' => ['nullable'],
|
'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'],
|
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
|
||||||
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
|
||||||
'activity_note_template' => ['nullable', 'string'],
|
'activity_note_template' => ['nullable', 'string'],
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,14 @@ class DocumentSetting extends Model
|
||||||
'preview_enabled',
|
'preview_enabled',
|
||||||
'whitelist',
|
'whitelist',
|
||||||
'date_formats',
|
'date_formats',
|
||||||
|
'custom_defaults',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'preview_enabled' => 'boolean',
|
'preview_enabled' => 'boolean',
|
||||||
'whitelist' => 'array',
|
'whitelist' => 'array',
|
||||||
'date_formats' => 'array',
|
'date_formats' => 'array',
|
||||||
|
'custom_defaults' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public static function instance(): self
|
public static function instance(): self
|
||||||
|
|
@ -30,6 +32,7 @@ public static function instance(): self
|
||||||
'preview_enabled' => config('documents.preview.enabled', true),
|
'preview_enabled' => config('documents.preview.enabled', true),
|
||||||
'whitelist' => config('documents.whitelist'),
|
'whitelist' => config('documents.whitelist'),
|
||||||
'date_formats' => [],
|
'date_formats' => [],
|
||||||
|
'custom_defaults' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,33 +12,39 @@ class PersonAddress extends Model
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\Person/PersonAddressFactory> */
|
/** @use HasFactory<\Database\Factories\Person/PersonAddressFactory> */
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
use Searchable;
|
use Searchable;
|
||||||
use SoftDeletes;
|
use SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'address',
|
'address',
|
||||||
'country',
|
'country',
|
||||||
|
'post_code',
|
||||||
|
'city',
|
||||||
'type_id',
|
'type_id',
|
||||||
'description',
|
'description',
|
||||||
'person_id',
|
'person_id',
|
||||||
'user_id'
|
'user_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'user_id',
|
'user_id',
|
||||||
'person_id',
|
'person_id',
|
||||||
'deleted'
|
'deleted',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function toSearchableArray(): array
|
public function toSearchableArray(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'address' => $this->address,
|
'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) {
|
static::creating(function (PersonAddress $address) {
|
||||||
$address->user_id = auth()->id();
|
$address->user_id = auth()->id();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -25,4 +25,16 @@ public function fresh(): DocumentSetting
|
||||||
{
|
{
|
||||||
return $this->refresh();
|
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 : [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,22 @@ public function render(DocumentTemplate $template, Contract $contract, User $use
|
||||||
// Determine effective unresolved policy early (template override -> global -> config)
|
// Determine effective unresolved policy early (template override -> global -> config)
|
||||||
$globalSettingsEarly = app(\App\Services\Documents\DocumentSettings::class)->get();
|
$globalSettingsEarly = app(\App\Services\Documents\DocumentSettings::class)->get();
|
||||||
$effectivePolicy = $template->fail_on_unresolved ? 'fail' : ($globalSettingsEarly->unresolved_policy ?? config('documents.unresolved_policy', 'fail'));
|
$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'];
|
$values = $resolved['values'];
|
||||||
$initialUnresolved = $resolved['unresolved'];
|
$initialUnresolved = $resolved['unresolved'];
|
||||||
|
$customTypes = $resolved['customTypes'] ?? [];
|
||||||
// Formatting options
|
// Formatting options
|
||||||
$fmt = $template->formatting_options ?? [];
|
$fmt = $template->formatting_options ?? [];
|
||||||
$decimals = (int) ($fmt['number_decimals'] ?? 2);
|
$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();
|
$globalSettings = app(\App\Services\Documents\DocumentSettings::class)->get();
|
||||||
$globalDateFormats = $globalSettings->date_formats ?? [];
|
$globalDateFormats = $globalSettings->date_formats ?? [];
|
||||||
foreach ($values as $k => $v) {
|
foreach ($values as $k => $v) {
|
||||||
// Date formatting (heuristic based on key ending with _date or .date)
|
$isTypedDate = ($customTypes[$k] ?? null) === 'date';
|
||||||
if (is_string($v) && ($k === 'generation.date' || preg_match('/(^|\.)[A-Za-z_]*date$/i', $k))) {
|
$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'] ?? [];
|
$dateFmtOverrides = $fmt['date_formats'] ?? [];
|
||||||
$desiredFormat = $dateFmtOverrides[$k]
|
$desiredFormat = $dateFmtOverrides[$k]
|
||||||
?? ($globalDateFormats[$k] ?? null)
|
?? ($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);
|
$num = number_format((float) $v, $decimals, $decSep, $thouSep);
|
||||||
if ($currencySymbol && preg_match('/(amount|balance|total|price|cost)/i', $k)) {
|
if ($currencySymbol && $isFinanceField) {
|
||||||
$space = $currencySpace ? ' ' : '';
|
$space = $currencySpace ? ' ' : '';
|
||||||
if ($currencyPos === 'after') {
|
if ($currencyPos === 'after') {
|
||||||
$num = $num.$space.$currencySymbol;
|
$num = $num.$space.$currencySymbol;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@
|
||||||
|
|
||||||
class TokenScanner
|
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>
|
* @return array<int,string>
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,39 @@ class TokenValueResolver
|
||||||
* Returns array with keys: values (resolved token=>value) and unresolved (list of tokens not resolved / not allowed)
|
* 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).
|
* 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 = [];
|
$values = [];
|
||||||
$unresolved = [];
|
$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)
|
// 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 ?? [];
|
$settingsWhitelist = app(\App\Services\Documents\DocumentSettings::class)->get()->whitelist ?? [];
|
||||||
$configWhitelist = config('documents.whitelist', []);
|
$configWhitelist = config('documents.whitelist', []);
|
||||||
|
|
@ -37,6 +64,34 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
|
||||||
|
|
||||||
continue;
|
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 (! in_array($entity, $templateEntities, true)) {
|
||||||
if ($policy === 'fail') {
|
if ($policy === 'fail') {
|
||||||
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
|
throw new \RuntimeException("Nedovoljen entiteta token: $entity");
|
||||||
|
|
@ -45,7 +100,12 @@ public function resolve(array $tokens, DocumentTemplate $template, Contract $con
|
||||||
|
|
||||||
continue;
|
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 (! in_array($attr, $allowed, true)) {
|
||||||
if ($policy === 'fail') {
|
if ($policy === 'fail') {
|
||||||
throw new \RuntimeException("Nedovoljen stolpec token: $token");
|
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) ?? '';
|
$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
|
private function generationAttribute(string $attr, User $user): string
|
||||||
|
|
@ -78,11 +142,25 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac
|
||||||
case 'client_case':
|
case 'client_case':
|
||||||
return (string) optional($contract->clientCase)->{$attr};
|
return (string) optional($contract->clientCase)->{$attr};
|
||||||
case 'client':
|
case 'client':
|
||||||
return (string) optional(optional($contract->clientCase)->client)->{$attr};
|
$client = optional($contract->clientCase)->client;
|
||||||
case 'person':
|
if (! $client) {
|
||||||
$person = optional(optional($contract->clientCase)->person);
|
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':
|
case 'account':
|
||||||
$account = optional($contract->account);
|
$account = optional($contract->account);
|
||||||
|
|
||||||
|
|
@ -91,4 +169,39 @@ private function entityAttribute(string $entity, string $attr, Contract $contrac
|
||||||
return '';
|
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 : '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2365,13 +2365,15 @@ private function upsertAddress(int $personId, array $addrData, $mappings): array
|
||||||
if (! $field) {
|
if (! $field) {
|
||||||
continue;
|
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';
|
$mode = $map->apply_mode ?? 'both';
|
||||||
if (in_array($mode, ['insert', 'both'])) {
|
if (in_array($mode, ['insert', 'both'])) {
|
||||||
$applyInsert[$field] = $val;
|
$applyInsert[$targetField] = $val;
|
||||||
}
|
}
|
||||||
if (in_array($mode, ['update', 'both'])) {
|
if (in_array($mode, ['update', 'both'])) {
|
||||||
$applyUpdate[$field] = $val;
|
$applyUpdate[$targetField] = $val;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,26 @@
|
||||||
'whitelist' => [
|
'whitelist' => [
|
||||||
'contract' => ['reference', 'start_date', 'end_date', 'description'],
|
'contract' => ['reference', 'start_date', 'end_date', 'description'],
|
||||||
'client_case' => ['client_ref'],
|
'client_case' => ['client_ref'],
|
||||||
'client' => [],
|
'client' => [
|
||||||
'person' => ['full_name', 'first_name', 'last_name', 'nu'],
|
'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',
|
||||||
|
],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ public function definition(): array
|
||||||
return [
|
return [
|
||||||
'address' => $this->faker->streetAddress(),
|
'address' => $this->faker->streetAddress(),
|
||||||
'country' => 'SI',
|
'country' => 'SI',
|
||||||
|
'post_code' => $this->faker->postcode(),
|
||||||
|
'city' => $this->faker->city(),
|
||||||
'type_id' => AddressType::factory(),
|
'type_id' => AddressType::factory(),
|
||||||
'user_id' => User::factory(),
|
'user_id' => User::factory(),
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
import { useForm, router, usePage } from '@inertiajs/vue3';
|
||||||
import DialogModal from './DialogModal.vue';
|
import DialogModal from './DialogModal.vue';
|
||||||
import InputLabel from './InputLabel.vue';
|
import InputLabel from './InputLabel.vue';
|
||||||
import SectionTitle from './SectionTitle.vue';
|
import SectionTitle from './SectionTitle.vue';
|
||||||
|
|
@ -33,47 +34,47 @@ const emit = defineEmits(['close']);
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
emit('close');
|
emit('close');
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
errors.value = {};
|
errors.value = {};
|
||||||
}, 500);
|
try { form.clearErrors && form.clearErrors(); } catch {}
|
||||||
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = ref({
|
const form = useForm({
|
||||||
address: '',
|
address: '',
|
||||||
country: '',
|
country: '',
|
||||||
type_id: props.types[0].id,
|
post_code: '',
|
||||||
|
city: '',
|
||||||
|
type_id: props.types?.[0]?.id ?? null,
|
||||||
description: ''
|
description: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
form.value = {
|
form.address = '';
|
||||||
address: '',
|
form.country = '';
|
||||||
country: '',
|
form.post_code = '';
|
||||||
type_id: props.types[0].id,
|
form.city = '';
|
||||||
description: ''
|
form.type_id = props.types?.[0]?.id ?? null;
|
||||||
};
|
form.description = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
errors.value = {};
|
errors.value = {};
|
||||||
|
|
||||||
const data = await axios({
|
form.post(route('person.address.create', props.person), {
|
||||||
method: 'post',
|
preserveScroll: true,
|
||||||
url: route('person.address.create', props.person),
|
onSuccess: () => {
|
||||||
data: form.value
|
// Optimistically append from last created record in DB by refetch or expose via flash if needed.
|
||||||
}).then((response) => {
|
// For now, trigger a lightweight reload of person's addresses via a GET if you have an endpoint, else trust parent reactivity.
|
||||||
props.person.addresses.push(response.data.address);
|
|
||||||
|
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
|
|
||||||
close();
|
close();
|
||||||
resetForm();
|
form.reset();
|
||||||
|
},
|
||||||
}).catch((reason) => {
|
onError: (e) => {
|
||||||
errors.value = reason.response.data.errors;
|
errors.value = e || {};
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -81,23 +82,17 @@ const update = async () => {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
errors.value = {};
|
errors.value = {};
|
||||||
|
|
||||||
const data = await axios({
|
form.put(route('person.address.update', {person: props.person, address_id: props.id}), {
|
||||||
method: 'put',
|
preserveScroll: true,
|
||||||
url: route('person.address.update', {person: props.person, address_id: props.id}),
|
onSuccess: () => {
|
||||||
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;
|
processing.value = false;
|
||||||
|
|
||||||
close();
|
close();
|
||||||
resetForm();
|
form.reset();
|
||||||
|
},
|
||||||
}).catch((reason) => {
|
onError: (e) => {
|
||||||
errors.value = reason.response.data.errors;
|
errors.value = e || {};
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -108,12 +103,12 @@ watch(
|
||||||
console.log(props.edit)
|
console.log(props.edit)
|
||||||
props.person.addresses.filter((a) => {
|
props.person.addresses.filter((a) => {
|
||||||
if(a.id === props.id){
|
if(a.id === props.id){
|
||||||
form.value = {
|
form.address = a.address;
|
||||||
address: a.address,
|
form.country = a.country;
|
||||||
country: a.country,
|
form.post_code = a.post_code || a.postal_code || '';
|
||||||
type_id: a.type_id,
|
form.city = a.city || '';
|
||||||
description: a.description
|
form.type_id = a.type_id;
|
||||||
};
|
form.description = a.description;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|
@ -175,6 +170,28 @@ const callSubmit = () => {
|
||||||
|
|
||||||
<InputError v-if="errors.address !== undefined" v-for="err in errors.address" :message="err" />
|
<InputError v-if="errors.address !== undefined" v-for="err in errors.address" :message="err" />
|
||||||
</div>
|
</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">
|
<div class="col-span-6 sm:col-span-4">
|
||||||
<InputLabel for="cr_type" value="Tip"/>
|
<InputLabel for="cr_type" value="Tip"/>
|
||||||
<select
|
<select
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,197 @@
|
||||||
<template>
|
<script setup>
|
||||||
<div></div>
|
import { ref, watch } from "vue";
|
||||||
</template>
|
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>
|
const props = defineProps({
|
||||||
export default {
|
show: { type: Boolean, default: false },
|
||||||
name: "Test",
|
person: Object,
|
||||||
created() {},
|
types: Array,
|
||||||
data() {
|
id: { type: Number, default: 0 },
|
||||||
return {};
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
},
|
},
|
||||||
props: {},
|
onError: (e) => {
|
||||||
methods: {},
|
errors.value = e || {};
|
||||||
|
processing.value = false;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
</script>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { provide, ref, watch } from 'vue';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import PersonUpdateForm from './PersonUpdateForm.vue';
|
import PersonUpdateForm from './PersonUpdateForm.vue';
|
||||||
import AddressCreateForm from './AddressCreateForm.vue';
|
import AddressCreateForm from './AddressCreateForm.vue';
|
||||||
|
import AddressUpdateForm from './AddressUpdateForm.vue';
|
||||||
import PhoneCreateForm from './PhoneCreateForm.vue';
|
import PhoneCreateForm from './PhoneCreateForm.vue';
|
||||||
import EmailCreateForm from './EmailCreateForm.vue';
|
import EmailCreateForm from './EmailCreateForm.vue';
|
||||||
import EmailUpdateForm from './EmailUpdateForm.vue';
|
import EmailUpdateForm from './EmailUpdateForm.vue';
|
||||||
|
|
@ -74,8 +75,9 @@ const closeConfirm = () => { confirm.value.show = false; };
|
||||||
const getMainAddress = (adresses) => {
|
const getMainAddress = (adresses) => {
|
||||||
const addr = adresses.filter( a => a.type.id === 1 )[0] ?? '';
|
const addr = adresses.filter( a => a.type.id === 1 )[0] ?? '';
|
||||||
if( addr !== '' ){
|
if( addr !== '' ){
|
||||||
|
const tail = (addr.post_code && addr.city) ? `, ${addr.post_code} ${addr.city}` : '';
|
||||||
const country = addr.country !== '' ? ` - ${addr.country}` : '';
|
const country = addr.country !== '' ? ` - ${addr.country}` : '';
|
||||||
return addr.address !== '' ? addr.address + country : '';
|
return addr.address !== '' ? (addr.address + tail + country) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
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>
|
<button @click="openConfirm('address', address.id, address.address)"><TrashBinIcon size="md" css="text-red-600 hover:text-red-700" /></button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</CusTab>
|
</CusTab>
|
||||||
|
|
@ -335,6 +339,13 @@ const getTRRs = (p) => {
|
||||||
:id="editAddressId"
|
:id="editAddressId"
|
||||||
:edit="editAddress"
|
:edit="editAddress"
|
||||||
/>
|
/>
|
||||||
|
<AddressUpdateForm
|
||||||
|
:show="drawerAddAddress && editAddress"
|
||||||
|
@close="drawerAddAddress = false"
|
||||||
|
:person="person"
|
||||||
|
:types="types.address_types"
|
||||||
|
:id="editAddressId"
|
||||||
|
/>
|
||||||
|
|
||||||
<PhoneCreateForm
|
<PhoneCreateForm
|
||||||
:show="drawerAddPhone"
|
:show="drawerAddPhone"
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,49 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex items-center gap-3 pt-2">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -274,7 +317,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from "vue";
|
import { computed, reactive } from "vue";
|
||||||
import { useForm, Link, router } from "@inertiajs/vue3";
|
import { useForm, Link, router } from "@inertiajs/vue3";
|
||||||
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
||||||
|
|
||||||
|
|
@ -303,6 +346,8 @@ const form = useForm({
|
||||||
action_id: props.template.action_id ?? null,
|
action_id: props.template.action_id ?? null,
|
||||||
decision_id: props.template.decision_id ?? null,
|
decision_id: props.template.decision_id ?? null,
|
||||||
activity_note_template: props.template.activity_note_template || "",
|
activity_note_template: props.template.activity_note_template || "",
|
||||||
|
// meta will include custom_defaults on submit
|
||||||
|
meta: props.template.meta || {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const toggleForm = useForm({});
|
const toggleForm = useForm({});
|
||||||
|
|
@ -322,6 +367,20 @@ function handleActionChange() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function submit() {
|
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));
|
form.put(route("admin.document-templates.settings.update", props.template.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,4 +389,22 @@ function toggleActive() {
|
||||||
preserveScroll: true,
|
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>
|
</script>
|
||||||
|
|
|
||||||
62
tests/Feature/DocumentCustomTokensTest.php
Normal file
62
tests/Feature/DocumentCustomTokensTest.php
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
90
tests/Feature/DocumentCustomTokensTypesTest.php
Normal file
90
tests/Feature/DocumentCustomTokensTypesTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
tests/Feature/DocumentNestedEntitiesTest.php
Normal file
80
tests/Feature/DocumentNestedEntitiesTest.php
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user