Added the support for generating docs from template doc

This commit is contained in:
Simon Pocrnjič 2025-10-06 21:46:28 +02:00
parent 0c8d1e0b5d
commit cec5796acf
69 changed files with 4570 additions and 374 deletions

View File

@ -0,0 +1,29 @@
<?php
namespace App\Events;
use App\Models\Document;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DocumentGenerated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Document $document)
{
// Fire off preview generation immediately if enabled without waiting for listener chaining
$settings = app(\App\Services\Documents\DocumentSettings::class)->get();
if ($settings->preview_enabled) {
try {
dispatch(new \App\Jobs\GenerateDocumentPreview($document->id));
} catch (\Throwable $e) {
\Log::warning('Failed to dispatch preview job on event', [
'document_id' => $document->id,
'error' => $e->getMessage(),
]);
}
}
}
}

View File

@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\DocumentSetting;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DocumentSettingsUpdated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public DocumentSetting $settings) {}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Events\DocumentSettingsUpdated;
use App\Http\Controllers\Controller;
use App\Services\Documents\DocumentSettings as SettingsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
class DocumentSettingsController extends Controller
{
public function edit(SettingsService $svc)
{
$this->authorizeAccess();
$settings = $svc->get();
return Inertia::render('Admin/DocumentSettings/Edit', [
'settings' => $settings,
'defaults' => [
'file_name_pattern' => config('documents.file_name_pattern'),
'date_format' => config('documents.date_format'),
'unresolved_policy' => config('documents.unresolved_policy'),
],
]);
}
public function update(Request $request, SettingsService $svc)
{
$this->authorizeAccess();
$data = $request->validate([
'file_name_pattern' => ['required', 'string', 'max:255'],
'date_format' => ['required', 'string', 'max:40'],
'unresolved_policy' => ['required', 'in:fail,blank,keep'],
'preview_enabled' => ['required', 'boolean'],
'whitelist' => ['nullable', 'array'],
'whitelist.*' => ['array'],
'date_formats' => ['nullable', 'array'],
'date_formats.*' => ['string'],
]);
$settings = $svc->get();
$settings->fill($data)->save();
$svc->refresh();
event(new DocumentSettingsUpdated($settings));
return redirect()->back()->with('success', 'Nastavitve shranjene.');
}
private function authorizeAccess(): void
{
if (Gate::denies('manage-settings') && Gate::denies('manage-document-templates')) {
abort(403);
}
}
}

View File

@ -5,6 +5,7 @@
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreDocumentTemplateRequest;
use App\Http\Requests\UpdateDocumentTemplateRequest;
use App\Models\Action;
use App\Models\DocumentTemplate;
use App\Services\Documents\TokenScanner;
use Illuminate\Support\Facades\Auth;
@ -19,9 +20,16 @@ public function index()
{
$this->ensurePermission();
$templates = DocumentTemplate::query()->orderByDesc('updated_at')->get();
$actions = Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']);
$actionsMapped = $actions->map(fn ($a) => [
'id' => $a->id,
'name' => $a->name,
'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(),
]);
return Inertia::render('Admin/DocumentTemplates/Index', [
'templates' => $templates,
'actions' => $actionsMapped,
]);
}
@ -35,10 +43,51 @@ public function toggleActive(DocumentTemplate $template)
return redirect()->back()->with('success', 'Status predloge posodobljen.');
}
public function show(DocumentTemplate $template)
{
$this->ensurePermission();
return Inertia::render('Admin/DocumentTemplates/Show', [
'template' => $template,
]);
}
public function edit(DocumentTemplate $template)
{
$this->ensurePermission();
$actions = Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']);
$actionsMapped = $actions->map(fn ($a) => [
'id' => $a->id,
'name' => $a->name,
'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(),
]);
return Inertia::render('Admin/DocumentTemplates/Edit', [
'template' => $template,
'actions' => $actionsMapped,
]);
}
public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentTemplate $template)
{
$this->ensurePermission();
$template->fill($request->only(['output_filename_pattern', 'date_format']));
$template->fill($request->only([
'output_filename_pattern', 'date_format', 'action_id', 'decision_id', 'activity_note_template',
]));
// If both action & decision provided, ensure decision belongs to action (parity with import templates)
if ($request->filled('action_id') && $request->filled('decision_id')) {
$belongs = \DB::table('action_decision')
->where('action_id', $request->integer('action_id'))
->where('decision_id', $request->integer('decision_id'))
->exists();
if (! $belongs) {
return redirect()->back()->withErrors(['decision_id' => 'Izbrana odločitev ne pripada izbrani akciji.']);
}
} elseif ($request->filled('action_id') && ! $request->filled('decision_id')) {
// Allow clearing decision when action changes
if ($template->isDirty('action_id')) {
$template->decision_id = null;
}
}
if ($request->has('fail_on_unresolved')) {
$template->fail_on_unresolved = (bool) $request->boolean('fail_on_unresolved');
}
@ -153,6 +202,19 @@ public function store(StoreDocumentTemplateRequest $request)
'currency_space' => true,
],
];
// Optional meta + activity linkage fields (parity with import templates style)
if ($request->filled('meta') && is_array($request->input('meta'))) {
$payload['meta'] = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== '');
}
if ($request->filled('action_id')) {
$payload['action_id'] = $request->integer('action_id');
}
if ($request->filled('decision_id')) {
$payload['decision_id'] = $request->integer('decision_id');
}
if ($request->filled('activity_note_template')) {
$payload['activity_note_template'] = $request->input('activity_note_template');
}
if (Schema::hasColumn('document_templates', 'tokens')) {
$payload['tokens'] = $tokens;
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePermissionRequest;
use App\Models\Permission;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class PermissionController extends Controller
{
public function index(): Response
{
$permissions = Permission::query()
->select('id','name','slug','description','created_at')
->orderBy('name')
->get();
return Inertia::render('Admin/Permissions/Index', [
'permissions' => $permissions,
]);
}
public function create(): Response
{
return Inertia::render('Admin/Permissions/Create');
}
public function store(StorePermissionRequest $request): RedirectResponse
{
Permission::create($request->validated());
return redirect()->route('admin.index')->with('success', 'Dovoljenje ustvarjeno.');
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
class UserRoleController extends Controller
{
public function index(Request $request): Response
{
Gate::authorize('manage-settings');
$users = User::with('roles:id,slug,name')->orderBy('name')->get(['id', 'name', 'email']);
$roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']);
$permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']);
return Inertia::render('Admin/Users/Index', [
'users' => $users,
'roles' => $roles,
'permissions' => $permissions,
]);
}
public function update(Request $request, User $user): RedirectResponse
{
Gate::authorize('manage-settings');
$validated = $request->validate([
'roles' => ['array'],
'roles.*' => ['integer', 'exists:roles,id'],
]);
$user->roles()->sync($validated['roles'] ?? []);
return back()->with('success', 'Roles updated');
}
}

View File

@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers;
use App\Events\DocumentGenerated;
use App\Models\Activity;
use App\Models\Contract;
use App\Models\Document;
use App\Models\DocumentTemplate;
use App\Services\Documents\TokenValueResolver;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class ContractDocumentGenerationController extends Controller
{
public function __invoke(Request $request, Contract $contract): Response
{
if (Gate::denies('read')) { // baseline read permission required to generate
abort(403);
}
$request->validate([
'template_slug' => ['required', 'string', 'exists:document_templates,slug'],
]);
$template = DocumentTemplate::where('slug', $request->template_slug)
->where('core_entity', 'contract')
->where('active', true)
->orderByDesc('version')
->firstOrFail();
// Load related data minimally
$contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']);
$renderer = app(\App\Services\Documents\DocxTemplateRenderer::class);
try {
$result = $renderer->render($template, $contract, Auth::user());
} catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) {
return response()->json([
'status' => 'error',
'message' => 'Unresolved tokens detected.',
'tokens' => $e->unresolved ?? [],
], 422);
} catch (\Throwable $e) {
return response()->json([
'status' => 'error',
'message' => 'Generation failed.',
], 500);
}
$doc = new Document;
$doc->fill([
'uuid' => (string) Str::uuid(),
'name' => $result['fileName'],
'description' => 'Generated from template '.$template->slug.' v'.$template->version,
'user_id' => Auth::id(),
'disk' => 'public',
'path' => $result['relativePath'],
'file_name' => $result['fileName'],
'original_name' => $result['fileName'],
'extension' => 'docx',
'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'size' => $result['size'],
'checksum' => $result['checksum'],
'is_public' => true,
'template_id' => $template->id,
'template_version' => $template->version,
]);
$contract->documents()->save($doc);
// Dispatch domain event
event(new DocumentGenerated($doc));
// Optional: create an activity if template links to action/decision
if (($template->action_id || $template->decision_id || $template->activity_note_template) && $contract->client_case_id) {
try {
$note = null;
if ($template->activity_note_template) {
// Interpolate tokens in note using existing resolver logic (non-failing policy: keep)
/** @var TokenValueResolver $resolver */
$resolver = app(TokenValueResolver::class);
$rawNote = $template->activity_note_template;
$tokens = [];
if (preg_match_all('/\{([a-zA-Z0-9_\.]+)\}/', $rawNote, $m)) {
$tokens = array_unique($m[1]);
}
$values = [];
if ($tokens) {
$resolved = $resolver->resolve($tokens, $template, $contract, Auth::user(), 'keep');
foreach ($resolved['values'] as $k => $v) {
$values['{'.$k.'}'] = $v;
}
}
$note = strtr($rawNote, $values);
}
Activity::create(array_filter([
'note' => $note,
'action_id' => $template->action_id,
'decision_id' => $template->decision_id,
'contract_id' => $contract->id,
'client_case_id' => $contract->client_case_id,
], fn ($v) => ! is_null($v) && $v !== ''));
} catch (\Throwable $e) {
// swallow activity creation errors to not block document generation
}
}
return response()->json([
'status' => 'ok',
'document_uuid' => $doc->uuid,
'path' => $doc->path,
]);
}
}

View File

@ -0,0 +1,189 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Document;
use App\Models\FieldJob;
use App\Models\Import; // assuming model name Import
use App\Models\Activity; // if this model exists
use App\Models\Contract;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\CarbonPeriod;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(): Response
{
$today = now()->startOfDay();
$yesterday = now()->subDay()->startOfDay();
$staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days
$clientsTotal = Client::count();
$clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count();
// FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at)
// Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at.
if (Schema::hasColumn('field_jobs', 'scheduled_at')) {
$fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count();
} else {
// Prefer assigned_at when present, otherwise created_at
$fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count();
}
$documentsToday = Document::whereDate('created_at', $today)->count();
$activeImports = Import::whereIn('status', ['queued', 'processing'])->count();
$activeContracts = Contract::where('active', 1)->count();
// Basic activities deferred list (limit 10)
$activities = Activity::query()
->with(['clientCase:id,uuid'])
->latest()
->limit(10)
->get(['id','note','created_at','client_case_id','contract_id','action_id','decision_id'])
->map(fn($a) => [
'id' => $a->id,
'note' => $a->note,
'created_at' => $a->created_at,
'client_case_id' => $a->client_case_id,
'client_case_uuid' => $a->clientCase?->uuid,
'contract_id' => $a->contract_id,
'action_id' => $a->action_id,
'decision_id' => $a->decision_id,
]);
// 7-day trends (including today)
$start = now()->subDays(6)->startOfDay();
$end = now()->endOfDay();
$dateKeys = collect(range(0,6))
->map(fn($i) => now()->subDays(6 - $i)->format('Y-m-d'));
$clientTrendRaw = Client::whereBetween('created_at', [$start,$end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
$documentTrendRaw = Document::whereBetween('created_at', [$start,$end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
$fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start,$end])
->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
$importTrendRaw = Import::whereBetween('created_at', [$start,$end])
->selectRaw('DATE(created_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
// Completed field jobs last 7 days
$fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at')
->whereBetween('completed_at', [$start, $end])
->selectRaw('DATE(completed_at) as d, COUNT(*) as c')
->groupBy('d')
->pluck('c','d');
$trends = [
'clients_new' => $dateKeys->map(fn($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(),
'documents_new' => $dateKeys->map(fn($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(),
'field_jobs' => $dateKeys->map(fn($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(),
'imports_new' => $dateKeys->map(fn($d) => (int) ($importTrendRaw[$d] ?? 0))->values(),
'field_jobs_completed' => $dateKeys->map(fn($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(),
'labels' => $dateKeys,
];
// Stale client cases (no activity in last 7 days)
$staleCases = \App\Models\ClientCase::query()
->leftJoin('activities', function($join) {
$join->on('activities.client_case_id', '=', 'client_cases.id')
->whereNull('activities.deleted_at');
})
->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at')
->groupBy('client_cases.id','client_cases.uuid','client_cases.client_ref','client_cases.created_at')
->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold])
->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC')
->limit(10)
->get()
->map(fn($c) => [
'id' => $c->id,
'uuid' => $c->uuid,
'client_ref' => $c->client_ref,
'last_activity_at' => $c->last_activity_at,
'created_at' => $c->created_at,
'days_stale' => $c->last_activity_at ? now()->diffInDays($c->last_activity_at) : now()->diffInDays($c->created_at),
]);
// Field jobs assigned today
$fieldJobsAssignedToday = FieldJob::query()
->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)
->select(['id','assigned_user_id','priority','assigned_at','created_at','contract_id'])
->latest(DB::raw('COALESCE(assigned_at, created_at)'))
->limit(15)
->get();
// Imports in progress (queued / processing)
$importsInProgress = Import::query()
->whereIn('status', ['queued','processing'])
->latest('created_at')
->limit(10)
->get(['id','uuid','file_name','status','total_rows','imported_rows','valid_rows','invalid_rows','started_at'])
->map(fn($i) => [
'id' => $i->id,
'uuid' => $i->uuid,
'file_name' => $i->file_name,
'status' => $i->status,
'total_rows' => $i->total_rows,
'imported_rows' => $i->imported_rows,
'valid_rows' => $i->valid_rows,
'invalid_rows' => $i->invalid_rows,
'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1,$i->total_rows))*100,1) : null,
'started_at' => $i->started_at,
]);
// Active document templates summary (active versions)
$activeTemplates = \App\Models\DocumentTemplate::query()
->where('active', true)
->latest('updated_at')
->limit(10)
->get(['id','name','slug','version','updated_at']);
// System health (deferred)
$queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null;
$failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null;
$recentActivity = Activity::query()->latest('created_at')->value('created_at');
$lastActivityMinutes = null;
if ($recentActivity) {
// diffInMinutes is absolute (non-negative) but guard anyway & cast to int
$lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity));
}
$systemHealth = [
'queue_backlog' => $queueBacklog,
'failed_jobs' => $failedJobs,
'last_activity_minutes' => $lastActivityMinutes,
'last_activity_iso' => $recentActivity?->toIso8601String(),
'generated_at' => now()->toIso8601String(),
];
return Inertia::render('Dashboard', [
'kpis' => [
'clients_total' => $clientsTotal,
'clients_new_7d' => $clientsNew7d,
'field_jobs_today' => $fieldJobsToday,
'documents_today' => $documentsToday,
'active_imports' => $activeImports,
'active_contracts' => $activeContracts,
],
'trends' => $trends,
])->with([ // deferred props (Inertia v2 style)
'activities' => fn () => $activities,
'systemHealth' => fn () => $systemHealth,
'staleCases' => fn () => $staleCases,
'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday,
'importsInProgress' => fn () => $importsInProgress,
'activeTemplates' => fn () => $activeTemplates,
]);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePermission
{
public function handle(Request $request, Closure $next, ...$permissions): Response
{
$user = $request->user();
if (! $user) {
abort(403);
}
if ($user->hasRole('admin')) {
return $next($request);
}
if (! $user->hasPermission($permissions)) {
abort(403);
}
return $next($request);
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureRole
{
public function handle(Request $request, Closure $next, ...$roles): Response
{
$user = $request->user();
if (! $user || ! $user->hasRole($roles)) {
abort(403);
}
return $next($request);
}
}

View File

@ -36,6 +36,22 @@ public function version(Request $request): ?string
public function share(Request $request): array
{
return array_merge(parent::share($request), [
'auth' => [
'user' => function () use ($request) {
$user = $request->user();
if (! $user) {
return null;
}
return [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
'roles' => $user->roles()->select('id', 'name', 'slug')->get(),
'permissions' => $user->permissions()->pluck('slug')->values(),
];
},
],
'flash' => [
'success' => fn () => $request->session()->get('success'),
'error' => fn () => $request->session()->get('error'),
@ -65,14 +81,11 @@ public function share(Request $request): array
->limit(20)
->get();
return [
'dueToday' => [
'count' => $activities->count(),
'items' => $activities,
'date' => $today,
'date' => $today,
],
];
} catch (\Throwable $e) {

View File

@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreDocumentTemplateRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->user();
return $user && ($user->hasPermission('manage-document-templates') || $user->hasPermission('manage-settings') || $user->hasRole('admin'));
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
// Slug uniqueness enforced only for first version; controller will increment version if slug exists
'slug' => ['required', 'string', 'max:255'],
'custom_name' => ['nullable', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'file' => ['required', 'file', 'mimetypes:application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'max:4096'],
'meta' => ['sometimes', 'array'],
'meta.*' => ['nullable'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'], // New optional field
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], // New optional field
'activity_note_template' => ['nullable', 'string'], // New optional field
];
}
public function messages(): array
{
return [
'file.mimetypes' => 'Datoteka mora biti DOCX.',
'file.max' => 'Datoteka je prevelika (max 4MB).',
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePermissionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()?->hasPermission('manage-settings') || $this->user()?->hasRole('admin');
}
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
'slug' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:permissions,slug'],
'description' => ['nullable', 'string', 'max:500'],
];
}
public function messages(): array
{
return [
'name.required' => 'Ime je obvezno.',
'slug.required' => 'Slug je obvezen.',
'slug.unique' => 'Slug že obstaja.',
];
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDocumentTemplateRequest extends FormRequest
{
public function authorize(): bool
{
$u = $this->user();
return $u && ($u->hasPermission('manage-document-templates') || $u->hasPermission('manage-settings') || $u->hasRole('admin'));
}
public function rules(): array
{
return [
'output_filename_pattern' => ['nullable', 'string', 'max:255'],
'date_format' => ['nullable', 'string', 'max:40'],
'fail_on_unresolved' => ['sometimes', 'boolean'],
'number_decimals' => ['nullable', 'integer', 'min:0', 'max:6'],
'decimal_separator' => ['nullable', 'string', 'max:2'],
'thousands_separator' => ['nullable', 'string', 'max:2'],
'currency_symbol' => ['nullable', 'string', 'max:8'],
'currency_position' => ['nullable', 'in:before,after'],
'currency_space' => ['nullable', 'boolean'],
'default_date_format' => ['nullable', 'string', 'max:40'],
'date_formats' => ['nullable', 'array'],
'date_formats.*' => ['nullable', 'string', 'max:40'],
'meta' => ['sometimes', 'array'],
'meta.*' => ['nullable'],
'action_id' => ['nullable', 'integer', 'exists:actions,id'],
'decision_id' => ['nullable', 'integer', 'exists:decisions,id'],
'activity_note_template' => ['nullable', 'string'],
];
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Listeners;
use App\Events\DocumentGenerated;
use Illuminate\Support\Facades\Log;
class LogDocumentGenerated
{
public function handle(DocumentGenerated $event): void
{
$doc = $event->document;
Log::info('Document generated', [
'uuid' => $doc->uuid,
'template_id' => $doc->template_id,
'template_version' => $doc->template_version,
'user_id' => $doc->user_id,
'path' => $doc->path,
]);
try {
\DB::table('document_generation_logs')->insert([
'document_id' => $doc->id,
'user_id' => $doc->user_id,
'ip' => request()?->ip(),
'user_agent' => substr((string) request()?->userAgent(), 0, 255),
'context' => json_encode([
'template_id' => $doc->template_id,
'template_version' => $doc->template_version,
]),
'created_at' => now(),
'updated_at' => now(),
]);
} catch (\Throwable $e) {
Log::warning('Failed to persist document generation log', [
'document_id' => $doc->id,
'error' => $e->getMessage(),
]);
}
}
}

View File

@ -33,6 +33,8 @@ class Document extends Model
'preview_path',
'preview_mime',
'preview_generated_at',
'template_id',
'template_version',
];
protected $casts = [

View File

@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DocumentSetting extends Model
{
protected $fillable = [
'file_name_pattern',
'date_format',
'unresolved_policy',
'preview_enabled',
'whitelist',
'date_formats',
];
protected $casts = [
'preview_enabled' => 'boolean',
'whitelist' => 'array',
'date_formats' => 'array',
];
public static function instance(): self
{
return static::query()->first() ?? static::create([
'file_name_pattern' => config('documents.file_name_pattern'),
'date_format' => config('documents.date_format'),
'unresolved_policy' => config('documents.unresolved_policy'),
'preview_enabled' => config('documents.preview.enabled', true),
'whitelist' => config('documents.whitelist'),
'date_formats' => [],
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DocumentTemplate extends Model
{
use HasFactory;
protected $fillable = [
'name', 'slug', 'custom_name', 'description', 'core_entity', 'entities', 'columns', 'tokens', 'version', 'engine', 'file_path', 'file_hash', 'file_size', 'mime_type', 'active', 'created_by', 'updated_by',
'output_filename_pattern', 'date_format', 'fail_on_unresolved', 'formatting_options', 'meta', 'action_id', 'decision_id', 'activity_note_template',
];
protected $casts = [
'entities' => 'array',
'columns' => 'array',
'tokens' => 'array',
'active' => 'boolean',
'version' => 'integer',
'fail_on_unresolved' => 'boolean',
'formatting_options' => 'array',
'meta' => 'array',
];
public function action(): BelongsTo
{
return $this->belongsTo(Action::class);
}
public function decision(): BelongsTo
{
return $this->belongsTo(Decision::class);
}
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'created_by');
}
public function updater(): BelongsTo
{
return $this->belongsTo(User::class, 'updated_by');
}
}

25
app/Models/Permission.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Permission extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
];
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)
->withTimestamps()
->select('roles.id', 'roles.name', 'roles.slug');
}
}

32
app/Models/Role.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Role extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
];
public function permissions(): BelongsToMany
{
return $this->belongsToMany(Permission::class)
->withTimestamps()
->select('permissions.id', 'permissions.name', 'permissions.slug');
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withTimestamps()
->select('users.id', 'users.name', 'users.email');
}
}

View File

@ -16,6 +16,7 @@ class User extends Authenticatable
/** @use HasFactory<\Database\Factories\UserFactory> */
use HasFactory;
use HasProfilePhoto;
use Notifiable;
use TwoFactorAuthenticatable;
@ -64,4 +65,36 @@ protected function casts(): array
'password' => 'hashed',
];
}
/**
* Roles relationship.
*/
public function roles()
{
return $this->belongsToMany(Role::class)
->withTimestamps()
->select('roles.id', 'roles.name', 'roles.slug');
}
/**
* Retrieve a flattened collection of permissions via roles.
*/
public function permissions()
{
return $this->roles()->with('permissions')->get()->pluck('permissions')->flatten()->unique('id');
}
public function hasRole(string|array $roles): bool
{
$roles = (array) $roles;
return $this->roles->pluck('slug')->intersect($roles)->isNotEmpty();
}
public function hasPermission(string|array $permissions): bool
{
$permissions = (array) $permissions;
return $this->permissions()->pluck('slug')->intersect($permissions)->isNotEmpty();
}
}

View File

@ -7,34 +7,33 @@
class ArchiveSettingPolicy
{
protected function isAdmin(User $user): bool
protected function canManage(User $user): bool
{
// Placeholder: adjust to real permission system / role flag
return (bool) ($user->is_admin ?? false);
return $user->hasPermission('manage-settings') || $user->hasRole(['admin']);
}
public function viewAny(User $user): bool
{
return $this->isAdmin($user);
return $this->canManage($user);
}
public function view(User $user, ArchiveSetting $setting): bool
{
return $this->isAdmin($user);
return $this->canManage($user);
}
public function create(User $user): bool
{
return $this->isAdmin($user);
return $this->canManage($user);
}
public function update(User $user, ArchiveSetting $setting): bool
{
return $this->isAdmin($user);
return $this->canManage($user);
}
public function delete(User $user, ArchiveSetting $setting): bool
{
return $this->isAdmin($user);
return $this->canManage($user);
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
// 'App\Models\Model' => 'App\Policies\ModelPolicy',
];
public function boot(): void
{
$this->registerPolicies();
// Fallback: map generic CRUD abilities to permission slugs directly
foreach (['create', 'read', 'update', 'delete'] as $ability) {
Gate::define($ability, function (User $user) use ($ability): bool {
return $user->hasPermission($ability) || $user->hasRole('admin');
});
}
// More specific examples
Gate::define('manage-settings', function (User $user): bool {
return $user->hasPermission('manage-settings') || $user->hasRole('admin');
});
Gate::define('manage-imports', function (User $user): bool {
return $user->hasPermission('manage-imports') || $user->hasRole('admin');
});
Gate::define('manage-document-templates', function (User $user): bool {
return $user->hasPermission('manage-document-templates') || $user->hasRole('admin');
});
// Global override for admin role
Gate::before(function (User $user, string $ability) {
if ($user->hasRole('admin')) {
return true;
}
return null; // fall through
});
}
}

View File

@ -0,0 +1,16 @@
<?php
namespace App\Providers;
use App\Events\DocumentGenerated;
use App\Listeners\LogDocumentGenerated;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
DocumentGenerated::class => [
LogDocumentGenerated::class,
],
];
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Services\Documents;
use App\Models\DocumentSetting;
use Illuminate\Support\Facades\Cache;
class DocumentSettings
{
private const CACHE_KEY = 'document_settings_singleton_v1';
public function get(): DocumentSetting
{
return Cache::remember(self::CACHE_KEY, 300, fn () => DocumentSetting::instance());
}
public function refresh(): DocumentSetting
{
Cache::forget(self::CACHE_KEY);
return $this->get();
}
public function fresh(): DocumentSetting
{
return $this->refresh();
}
}

View File

@ -0,0 +1,20 @@
<?php
namespace App\Services\Documents\Exceptions;
use RuntimeException;
class UnresolvedTokensException extends RuntimeException
{
/** @var array<int,string> */
public array $unresolved;
/**
* @param array<int,string> $unresolved
*/
public function __construct(array $unresolved, string $message = 'Unresolved tokens remain in template.')
{
parent::__construct($message);
$this->unresolved = $unresolved;
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Services\Documents;
class TokenScanner
{
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)\s*}}/';
/**
* @return array<int,string>
*/
public function scan(string $content): array
{
preg_match_all(self::REGEX, $content, $m);
if (empty($m[1])) {
return [];
}
return array_values(array_unique($m[1]));
}
}

View File

@ -17,7 +17,10 @@
\Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class,
]);
//
$middleware->alias([
'role' => \App\Http\Middleware\EnsureRole::class,
'permission' => \App\Http\Middleware\EnsurePermission::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
//

View File

@ -2,6 +2,7 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\AuthServiceProvider::class,
App\Providers\FortifyServiceProvider::class,
App\Providers\JetstreamServiceProvider::class,
];

26
config/documents.php Normal file
View File

@ -0,0 +1,26 @@
<?php
return [
// Default file name pattern used when template does not override.
// Available placeholders: {slug},{version},{generation.date},{generation.timestamp} and any token like {contract.reference}
'file_name_pattern' => '{slug}_{generation.date}_{generation.timestamp}.docx',
// Default date format (php date format) for generation.date
'date_format' => 'Y-m-d',
// Global policy: fail | blank | keep
'unresolved_policy' => 'fail',
// Preview generation
'preview' => [
'enabled' => true,
],
// Whitelist of entities & attributes permitted for token usage
'whitelist' => [
'contract' => ['reference', 'start_date', 'end_date', 'description'],
'client_case' => ['client_ref'],
'client' => [],
'person' => ['full_name', 'first_name', 'last_name', 'nu'],
],
];

View File

@ -17,7 +17,8 @@ class ActionFactory extends Factory
public function definition(): array
{
return [
//
'name' => $this->faker->unique()->words(2, true),
'color_tag' => $this->faker->optional()->safeColorName(),
];
}
}

View File

@ -0,0 +1,51 @@
<?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::create('roles', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('description')->nullable();
$table->timestamps();
});
Schema::create('permissions', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('description')->nullable();
$table->timestamps();
});
Schema::create('permission_role', function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['permission_id', 'role_id']);
});
Schema::create('role_user', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->timestamps();
$table->unique(['role_id', 'user_id']);
});
}
public function down(): void
{
Schema::dropIfExists('role_user');
Schema::dropIfExists('permission_role');
Schema::dropIfExists('permissions');
Schema::dropIfExists('roles');
}
};

View File

@ -0,0 +1,151 @@
<?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
{
if (Schema::getConnection()->getDriverName() === 'sqlite') {
// Rebuild role_user without surrogate id
if (Schema::hasTable('role_user')) {
Schema::create('role_user_temp', function (Blueprint $table) {
$table->foreignId('role_id');
$table->foreignId('user_id');
$table->timestamps();
});
// Copy distinct rows
try {
\DB::statement('INSERT INTO role_user_temp (role_id, user_id, created_at, updated_at) SELECT role_id, user_id, created_at, updated_at FROM role_user GROUP BY role_id, user_id');
} catch (Throwable $e) { /* ignore */
}
Schema::drop('role_user');
Schema::rename('role_user_temp', 'role_user');
// Add composite primary key via raw SQL
try {
\DB::statement('CREATE UNIQUE INDEX role_user_role_user_unique ON role_user(role_id, user_id)');
} catch (Throwable $e) { /* ignore */
}
}
if (Schema::hasTable('permission_role')) {
Schema::create('permission_role_temp', function (Blueprint $table) {
$table->foreignId('permission_id');
$table->foreignId('role_id');
$table->timestamps();
});
try {
\DB::statement('INSERT INTO permission_role_temp (permission_id, role_id, created_at, updated_at) SELECT permission_id, role_id, created_at, updated_at FROM permission_role GROUP BY permission_id, role_id');
} catch (Throwable $e) { /* ignore */
}
Schema::drop('permission_role');
Schema::rename('permission_role_temp', 'permission_role');
try {
\DB::statement('CREATE UNIQUE INDEX permission_role_permission_role_unique ON permission_role(permission_id, role_id)');
} catch (Throwable $e) { /* ignore */
}
}
return; // sqlite path done
}
// role_user
if (Schema::hasColumn('role_user', 'id')) {
// Drop id column; Postgres requires dropping pk constraint implicitly named maybe 'role_user_pkey'
// Attempt raw drop primary if exists
try {
\DB::statement('ALTER TABLE role_user DROP CONSTRAINT role_user_pkey');
} catch (Throwable $e) { /* ignore */
}
Schema::table('role_user', function (Blueprint $table) {
$table->dropColumn('id');
});
}
// Add composite primary key if not present
try {
\DB::statement('ALTER TABLE role_user ADD PRIMARY KEY (role_id, user_id)');
} catch (Throwable $e) { /* ignore */
}
// permission_role
if (Schema::hasColumn('permission_role', 'id')) {
try {
\DB::statement('ALTER TABLE permission_role DROP CONSTRAINT permission_role_pkey');
} catch (Throwable $e) { /* ignore */
}
Schema::table('permission_role', function (Blueprint $table) {
$table->dropColumn('id');
});
}
try {
\DB::statement('ALTER TABLE permission_role ADD PRIMARY KEY (permission_id, role_id)');
} catch (Throwable $e) { /* ignore */
}
}
public function down(): void
{
if (Schema::getConnection()->getDriverName() === 'sqlite') {
// Recreate tables with id column again (best-effort)
if (Schema::hasTable('role_user')) {
Schema::create('role_user_orig', function (Blueprint $table) {
$table->id();
$table->foreignId('role_id');
$table->foreignId('user_id');
$table->timestamps();
});
try {
\DB::statement('INSERT INTO role_user_orig (role_id, user_id, created_at, updated_at) SELECT role_id, user_id, created_at, updated_at FROM role_user');
} catch (Throwable $e) { /* ignore */
}
Schema::drop('role_user');
Schema::rename('role_user_orig', 'role_user');
try {
\DB::statement('CREATE UNIQUE INDEX role_user_role_user_unique ON role_user(role_id, user_id)');
} catch (Throwable $e) { /* ignore */
}
}
if (Schema::hasTable('permission_role')) {
Schema::create('permission_role_orig', function (Blueprint $table) {
$table->id();
$table->foreignId('permission_id');
$table->foreignId('role_id');
$table->timestamps();
});
try {
\DB::statement('INSERT INTO permission_role_orig (permission_id, role_id, created_at, updated_at) SELECT permission_id, role_id, created_at, updated_at FROM permission_role');
} catch (Throwable $e) { /* ignore */
}
Schema::drop('permission_role');
Schema::rename('permission_role_orig', 'permission_role');
try {
\DB::statement('CREATE UNIQUE INDEX permission_role_permission_role_unique ON permission_role(permission_id, role_id)');
} catch (Throwable $e) { /* ignore */
}
}
return;
}
// Re-add id columns (simple auto increment) and drop composite PKs
Schema::table('role_user', function (Blueprint $table) {
try {
$table->dropPrimary();
} catch (Throwable $e) { /* ignore */
}
if (! Schema::hasColumn('role_user', 'id')) {
$table->id()->first();
}
$table->unique(['role_id', 'user_id']);
});
Schema::table('permission_role', function (Blueprint $table) {
try {
$table->dropPrimary();
} catch (Throwable $e) { /* ignore */
}
if (! Schema::hasColumn('permission_role', 'id')) {
$table->id()->first();
}
$table->unique(['permission_id', 'role_id']);
});
}
};

View File

@ -0,0 +1,55 @@
<?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::create('document_templates', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->string('custom_name')->nullable();
$table->text('description')->nullable();
$table->string('core_entity'); // e.g. 'contract'
$table->json('entities'); // list of related entities allowed
$table->json('columns'); // map entity => allowed columns
$table->unsignedInteger('version')->default(1);
$table->string('engine')->default('tokens');
$table->string('file_path');
$table->string('file_hash', 64);
$table->unsignedBigInteger('file_size');
$table->string('mime_type', 120);
$table->boolean('active')->default(true);
$table->foreignId('created_by')->constrained('users');
$table->foreignId('updated_by')->nullable()->constrained('users');
$table->timestamps();
$table->index(['core_entity', 'active']);
});
Schema::table('documents', function (Blueprint $table) {
if (! Schema::hasColumn('documents', 'template_id')) {
$table->foreignId('template_id')->nullable()->after('uuid')->constrained('document_templates');
}
if (! Schema::hasColumn('documents', 'template_version')) {
$table->unsignedInteger('template_version')->nullable()->after('template_id');
}
});
}
public function down(): void
{
Schema::table('documents', function (Blueprint $table) {
if (Schema::hasColumn('documents', 'template_version')) {
$table->dropColumn('template_version');
}
if (Schema::hasColumn('documents', 'template_id')) {
$table->dropConstrainedForeignId('template_id');
}
});
Schema::dropIfExists('document_templates');
}
};

View File

@ -0,0 +1,22 @@
<?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_templates', function (Blueprint $table) {
$table->json('tokens')->nullable()->after('columns');
});
}
public function down(): void
{
Schema::table('document_templates', function (Blueprint $table) {
$table->dropColumn('tokens');
});
}
};

View File

@ -0,0 +1,24 @@
<?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_templates', function (Blueprint $table) {
$table->string('output_filename_pattern')->nullable()->after('file_size');
$table->string('date_format')->nullable()->after('output_filename_pattern');
$table->boolean('fail_on_unresolved')->default(true)->after('date_format');
});
}
public function down(): void
{
Schema::table('document_templates', function (Blueprint $table) {
$table->dropColumn(['output_filename_pattern', 'date_format', 'fail_on_unresolved']);
});
}
};

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::create('document_generation_logs', function (Blueprint $table) {
$table->id();
$table->foreignId('document_id')->constrained('documents')->cascadeOnDelete();
$table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('ip')->nullable();
$table->string('user_agent')->nullable();
$table->json('context')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('document_generation_logs');
}
};

View File

@ -0,0 +1,22 @@
<?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_templates', function (Blueprint $table) {
$table->json('formatting_options')->nullable()->after('fail_on_unresolved');
});
}
public function down(): void
{
Schema::table('document_templates', function (Blueprint $table) {
$table->dropColumn('formatting_options');
});
}
};

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::create('document_settings', function (Blueprint $table) {
$table->id();
$table->string('file_name_pattern')->nullable();
$table->string('date_format')->nullable();
$table->string('unresolved_policy')->nullable();
$table->boolean('preview_enabled')->default(true);
$table->json('whitelist')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('document_settings');
}
};

View File

@ -0,0 +1,22 @@
<?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) {
$table->json('date_formats')->nullable()->after('whitelist');
});
}
public function down(): void
{
Schema::table('document_settings', function (Blueprint $table) {
$table->dropColumn('date_formats');
});
}
};

View File

@ -0,0 +1,48 @@
<?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_templates', function (Blueprint $table) {
if (! Schema::hasColumn('document_templates', 'meta')) {
$table->json('meta')->nullable()->after('formatting_options');
}
if (! Schema::hasColumn('document_templates', 'action_id')) {
$table->foreignId('action_id')->nullable()->after('meta')->constrained()->nullOnDelete();
}
if (! Schema::hasColumn('document_templates', 'decision_id')) {
$table->foreignId('decision_id')->nullable()->after('action_id')->constrained()->nullOnDelete();
}
if (! Schema::hasColumn('document_templates', 'activity_note_template')) {
$table->text('activity_note_template')->nullable()->after('decision_id');
}
$table->index(['action_id', 'decision_id'], 'document_templates_action_decision_idx');
});
}
public function down(): void
{
Schema::table('document_templates', function (Blueprint $table) {
if (Schema::hasColumn('document_templates', 'activity_note_template')) {
$table->dropColumn('activity_note_template');
}
if (Schema::hasColumn('document_templates', 'decision_id')) {
$table->dropConstrainedForeignId('decision_id');
}
if (Schema::hasColumn('document_templates', 'action_id')) {
$table->dropConstrainedForeignId('action_id');
}
if (Schema::hasColumn('document_templates', 'meta')) {
$table->dropColumn('meta');
}
if (Schema::hasColumn('document_templates', 'action_id') || Schema::hasColumn('document_templates', 'decision_id')) {
$table->dropIndex('document_templates_action_decision_idx');
}
});
}
};

View File

@ -0,0 +1,56 @@
<?php
namespace Database\Seeders;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Database\Seeder;
class RolePermissionSeeder extends Seeder
{
public function run(): void
{
// Define a baseline set of permissions aligned with Jetstream's default tokens
$permissions = collect([
['slug' => 'create', 'name' => 'Create'],
['slug' => 'read', 'name' => 'Read'],
['slug' => 'update', 'name' => 'Update'],
['slug' => 'delete', 'name' => 'Delete'],
['slug' => 'manage-settings', 'name' => 'Manage Settings'],
['slug' => 'manage-imports', 'name' => 'Manage Imports'],
['slug' => 'manage-document-templates', 'name' => 'Manage Document Templates'],
]);
$permissions->each(function ($perm) {
Permission::firstOrCreate(['slug' => $perm['slug']], [
'name' => $perm['name'],
'description' => $perm['name'].' permission',
]);
});
$admin = Role::firstOrCreate(['slug' => 'admin'], [
'name' => 'Administrator',
'description' => 'Full access to all features',
]);
$staff = Role::firstOrCreate(['slug' => 'staff'], [
'name' => 'Staff',
'description' => 'Standard internal user',
]);
$viewer = Role::firstOrCreate(['slug' => 'viewer'], [
'name' => 'Viewer',
'description' => 'Read-only access',
]);
// Attach permissions
$admin->permissions()->sync(Permission::pluck('id'));
$staff->permissions()->sync(Permission::whereIn('slug', ['create', 'read', 'update'])->pluck('id'));
$viewer->permissions()->sync(Permission::where('slug', 'read')->pluck('id'));
// Optionally ensure first user is admin
$firstUser = User::query()->orderBy('id')->first();
if ($firstUser && ! $firstUser->roles()->where('roles.id', $admin->id)->exists()) {
$firstUser->roles()->attach($admin->id);
}
}
}

Binary file not shown.

View File

@ -0,0 +1,81 @@
<?php
// Generates a minimal DOCX template with token placeholders for testing.
// Output: resources/examples/contract_summary_template.docx
$outDir = __DIR__;
$file = $outDir.DIRECTORY_SEPARATOR.'contract_summary_template.docx';
if (file_exists($file)) {
unlink($file);
}
$contentTypes = <<<'XML'
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>
XML;
$rels = <<<'XML'
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>
XML;
$document = <<<'XML'
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:wpc="http://schemas.microsoft.com/office/word/2010/wordprocessingCanvas"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:o="urn:schemas-microsoft-com:office:office"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:wp14="http://schemas.microsoft.com/office/word/2010/wordprocessingDrawing"
xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
xmlns:w10="urn:schemas-microsoft-com:office:word"
xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml"
xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"
xmlns:wpi="http://schemas.microsoft.com/office/word/2010/wordprocessingInk"
xmlns:wne="http://schemas.microsoft.com/office/word/2006/wordml"
xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"
mc:Ignorable="w14 wp14">
<w:body>
<w:p><w:r><w:t>Contract Summary Report</w:t></w:r></w:p>
<w:p><w:r><w:t>Reference: {{contract.reference}}</w:t></w:r></w:p>
<w:p><w:r><w:t>Client Case Ref: {{client_case.client_ref}}</w:t></w:r></w:p>
<w:p><w:r><w:t>Start Date: {{contract.start_date}}</w:t></w:r></w:p>
<w:p><w:r><w:t>End Date: {{contract.end_date}}</w:t></w:r></w:p>
<w:p><w:r><w:t>Description: {{contract.description}}</w:t></w:r></w:p>
<w:p><w:r><w:t>Generated By: {{generation.user_name}} on {{generation.date}}</w:t></w:r></w:p>
</w:body>
</w:document>
XML;
$zip = new ZipArchive;
if ($zip->open($file, ZipArchive::CREATE) !== true) {
fwrite(STDERR, "Cannot create docx file\n");
exit(1);
}
// Core parts
$zip->addFromString('[Content_Types].xml', $contentTypes);
$zip->addEmptyDir('_rels');
$zip->addFromString('_rels/.rels', $rels);
$zip->addEmptyDir('word');
$zip->addFromString('word/document.xml', $document);
$zip->close();
$hash = hash_file('sha256', $file);
$size = filesize($file);
echo json_encode([
'file' => $file,
'size_bytes' => $size,
'sha256' => $hash,
'tokens_included' => [
'contract.reference', 'client_case.client_ref', 'contract.start_date', 'contract.end_date', 'contract.description', 'generation.user_name', 'generation.date',
],
], JSON_PRETTY_PRINT).PHP_EOL;

View File

@ -0,0 +1,271 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from "vue";
import { Head, Link, router, usePage } from "@inertiajs/vue3";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faUserGroup,
faShieldHalved,
faArrowLeft,
faFileWord,
faBars,
faGears,
faKey,
} from "@fortawesome/free-solid-svg-icons";
import Dropdown from "@/Components/Dropdown.vue";
import DropdownLink from "@/Components/DropdownLink.vue";
import GlobalSearch from "@/Layouts/Partials/GlobalSearch.vue";
import NotificationsBell from "@/Layouts/Partials/NotificationsBell.vue";
import ApplicationMark from "@/Components/ApplicationMark.vue";
const props = defineProps({ title: { type: String, default: "Administrator" } });
// Basic state reused (simplified vs AppLayout)
const sidebarCollapsed = ref(false);
const isMobile = ref(false);
const mobileSidebarOpen = ref(false);
function handleResize() {
if (typeof window === "undefined") return;
isMobile.value = window.innerWidth < 1024;
if (!isMobile.value) mobileSidebarOpen.value = false;
sidebarCollapsed.value = isMobile.value; // auto collapse on small
}
onMounted(() => {
handleResize();
window.addEventListener("resize", handleResize);
});
onUnmounted(() => window.removeEventListener("resize", handleResize));
function toggleSidebar() {
if (isMobile.value) mobileSidebarOpen.value = !mobileSidebarOpen.value;
else sidebarCollapsed.value = !sidebarCollapsed.value;
}
const logout = () => router.post(route("logout"));
const page = usePage();
// Categorized admin navigation groups (removed global 'Nastavitve')
const navGroups = computed(() => [
{
key: "core",
label: "Jedro",
items: [
{
key: "admin.dashboard",
label: "Pregled",
route: "admin.index",
icon: faShieldHalved,
active: ["admin.index"],
},
],
},
{
key: "users",
label: "Uporabniki & Dovoljenja",
items: [
{
key: "admin.users",
label: "Uporabniki",
route: "admin.users.index",
icon: faUserGroup,
active: ["admin.users.index"],
},
{
key: "admin.permissions.index",
label: "Dovoljenja",
route: "admin.permissions.index",
icon: faKey,
active: ["admin.permissions.index", "admin.permissions.create"],
},
],
},
{
key: "documents",
label: "Dokumenti",
items: [
{
key: "admin.document-settings.index",
label: "Nastavitve dokumentov",
route: "admin.document-settings.index",
icon: faGears,
active: ["admin.document-settings.index"],
},
{
key: "admin.document-templates.index",
label: "Predloge dokumentov",
route: "admin.document-templates.index",
icon: faFileWord,
active: ["admin.document-templates.index"],
},
],
},
]);
function isActive(patterns) {
try {
return patterns.some((p) => route().current(p));
} catch {
return false;
}
}
</script>
<template>
<div class="min-h-screen flex bg-gray-100">
<Head :title="title" />
<!-- Backdrop for mobile sidebar -->
<div
v-if="isMobile && mobileSidebarOpen"
class="fixed inset-0 z-40 bg-black/30"
@click="mobileSidebarOpen = false"
/>
<aside
:class="[
sidebarCollapsed ? 'w-16' : 'w-60',
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
isMobile
? 'fixed inset-y-0 left-0 transform ' +
(mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full')
: 'sticky top-0 h-screen',
]"
>
<div class="h-16 px-4 flex items-center justify-between border-b">
<Link :href="route('dashboard')" class="flex items-center gap-2">
<ApplicationMark class="h-8 w-auto" />
<span v-if="!sidebarCollapsed" class="text-sm font-semibold">Admin</span>
</Link>
</div>
<nav class="py-4 overflow-y-auto scrollbar-thin scrollbar-thumb-gray-200">
<div v-for="group in navGroups" :key="group.key" class="mt-2 first:mt-0">
<p
v-if="!sidebarCollapsed"
class="px-4 mb-1 mt-4 first:mt-0 text-[10px] font-semibold uppercase tracking-wider text-gray-400"
>
{{ group.label }}
</p>
<ul class="space-y-1">
<li v-for="item in group.items" :key="item.key">
<Link
:href="route(item.route)"
:title="item.label"
:class="[
'flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100',
isActive(item.active) ? 'bg-gray-100 text-gray-900' : 'text-gray-600',
]"
>
<FontAwesomeIcon :icon="item.icon" class="w-5 h-5 text-gray-600" />
<span v-if="!sidebarCollapsed">{{ item.label }}</span>
</Link>
</li>
</ul>
</div>
<div class="mt-6 border-t pt-4 space-y-2 px-4">
<Link
:href="route('dashboard')"
class="text-xs text-gray-500 hover:underline flex items-center gap-1"
>
<FontAwesomeIcon :icon="faArrowLeft" class="w-3.5 h-3.5" />
<span v-if="!sidebarCollapsed">Nazaj na aplikacijo</span>
</Link>
</div>
</nav>
</aside>
<div class="flex-1 flex flex-col min-w-0">
<div
class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between sticky top-0 z-30"
>
<div class="flex items-center gap-2">
<button
@click="toggleSidebar"
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
aria-label="Toggle sidebar"
>
<!-- Replaced raw SVG with FontAwesome icon -->
<FontAwesomeIcon :icon="faBars" class="w-5 h-5" />
</button>
<h1 class="text-base font-semibold text-gray-800 hidden sm:block">
{{ title }}
</h1>
</div>
<div class="flex items-center">
<NotificationsBell class="mr-2" />
<!-- User dropdown replicated from AppLayout style -->
<div class="ms-3 relative">
<Dropdown align="right" width="48">
<template #trigger>
<button
v-if="$page.props.jetstream?.managesProfilePhotos"
class="flex text-sm border-2 border-transparent rounded-full focus:outline-none focus:border-gray-300 transition"
>
<img
class="h-8 w-8 rounded-full object-cover"
:src="$page.props.auth.user.profile_photo_url"
:alt="$page.props.auth.user.name"
/>
</button>
<span v-else class="inline-flex rounded-md">
<button
type="button"
class="inline-flex items-center px-3 py-2 border border-transparent text-sm leading-4 font-medium rounded-md text-gray-500 bg-white hover:text-gray-700 focus:outline-none focus:bg-gray-50 active:bg-gray-50 transition ease-in-out duration-150"
>
{{ $page.props.auth.user.name }}
<svg
class="ms-2 -me-0.5 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button>
</span>
</template>
<template #content>
<div class="block px-4 py-2 text-xs text-gray-400">Nastavitve računa</div>
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
<DropdownLink
v-if="$page.props.jetstream?.hasApiFeatures"
:href="route('api-tokens.index')"
>API Tokens</DropdownLink
>
<div class="border-t border-gray-200" />
<form @submit.prevent="logout">
<DropdownLink as="button">Izpis</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</div>
</div>
<main class="p-4">
<div
v-if="$page.props.flash?.success"
class="mb-4 rounded bg-emerald-50 border border-emerald-200 text-emerald-700 px-4 py-2 text-sm"
>
{{ $page.props.flash.success }}
</div>
<div
v-if="$page.props.errors && Object.keys($page.props.errors).length"
class="mb-4 rounded bg-rose-50 border border-rose-200 text-rose-700 px-4 py-2 text-sm"
>
<ul class="list-disc ml-5 space-y-1">
<li v-for="(err, key) in $page.props.errors" :key="key">{{ err }}</li>
</ul>
</div>
<slot />
</main>
</div>
<GlobalSearch :open="false" />
</div>
</template>

View File

@ -203,17 +203,42 @@ const rawMenuGroups = [
routeName: "settings",
active: ["settings", "settings.*"],
},
// Admin panel (roles & permissions management)
// Only shown if current user has admin role or manage-settings permission.
// We'll filter it out below if not authorized.
{
key: "admin-panel",
title: "Administrator",
routeName: "admin.index",
active: ["admin.index", "admin.users.index", "admin.permissions.create"],
requires: { role: "admin", permission: "manage-settings" },
},
],
},
];
const menuGroups = computed(() => {
return rawMenuGroups.map((g) => ({
label: g.label,
items: [...g.items].sort((a, b) =>
a.title.localeCompare(b.title, "sl", { sensitivity: "base" })
),
}));
const user = page.props.auth?.user || {};
const roles = (user.roles || []).map((r) => r.slug);
const permissions = user.permissions || [];
// Helper to determine inclusion based on optional requires meta
function allowed(item) {
if (!item.requires) return true;
const needRole = item.requires.role;
const needPerm = item.requires.permission;
return (
(needRole && roles.includes(needRole)) ||
(needPerm && permissions.includes(needPerm))
);
}
return rawMenuGroups.map((g) => {
const items = g.items
.filter(allowed)
.sort((a, b) => a.title.localeCompare(b.title, "sl", { sensitivity: "base" }));
return { label: g.label, items };
});
});
// Icon map for menu keys -> FontAwesome icon definitions
@ -227,6 +252,7 @@ const menuIconMap = {
"import-templates-new": faFileCirclePlus,
fieldjobs: faMap,
settings: faGear,
"admin-panel": faUserGroup,
};
function isActive(patterns) {

View File

@ -0,0 +1,161 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { useForm } from "@inertiajs/vue3";
import { ref, watch } from "vue";
const props = defineProps({ settings: Object, defaults: Object });
const form = useForm({
file_name_pattern: props.settings.file_name_pattern || props.defaults.file_name_pattern,
date_format: props.settings.date_format || props.defaults.date_format,
unresolved_policy: props.settings.unresolved_policy || props.defaults.unresolved_policy,
preview_enabled: props.settings.preview_enabled ? 1 : 0,
whitelist: JSON.stringify(props.settings.whitelist || {}, null, 2),
date_formats: JSON.stringify(props.settings.date_formats || {}, null, 2),
});
const whitelistError = ref(null);
const dateFormatsError = ref(null);
function validateJson(source, targetError, expectations = "object") {
try {
const parsed = JSON.parse(source.value);
if (
expectations === "object" &&
(parsed === null || Array.isArray(parsed) || typeof parsed !== "object")
) {
targetError.value = "Mora biti JSON objekt";
} else {
targetError.value = null;
}
} catch (e) {
targetError.value = "Neveljaven JSON";
}
}
watch(
() => form.whitelist,
() => validateJson({ value: form.whitelist }, whitelistError)
);
watch(
() => form.date_formats,
() => validateJson({ value: form.date_formats }, dateFormatsError)
);
function submit() {
if (whitelistError.value || dateFormatsError.value) {
return;
}
let wl = null;
try {
wl = JSON.parse(form.whitelist);
} catch (e) {
wl = null;
}
let df = null;
try {
df = JSON.parse(form.date_formats);
} catch (e) {
df = null;
}
form
.transform((d) => ({
...d,
preview_enabled: !!d.preview_enabled,
whitelist: wl,
date_formats: df,
}))
.put(route("admin.document-settings.update"));
}
</script>
<template>
<AdminLayout title="Nastavitve dokumentov">
<div class="max-w-3xl mx-auto space-y-6">
<h1 class="text-2xl font-semibold">Nastavitve dokumentov</h1>
<form @submit.prevent="submit" class="space-y-6 bg-white p-6 border rounded">
<div class="grid md:grid-cols-2 gap-4">
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Vzorec imena</span>
<input v-model="form.file_name_pattern" class="border rounded px-3 py-2" />
<span class="text-xs text-gray-500"
>Podprti placeholderji: {slug} {version} {generation.date}
{generation.timestamp}</span
>
<span v-if="form.errors.file_name_pattern" class="text-xs text-rose-600">{{
form.errors.file_name_pattern
}}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Privzeti datum format</span>
<input v-model="form.date_format" class="border rounded px-3 py-2" />
<span class="text-xs text-gray-500">npr. Y-m-d ali d.m.Y</span>
<span v-if="form.errors.date_format" class="text-xs text-rose-600">{{
form.errors.date_format
}}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Politika nerešenih</span>
<select v-model="form.unresolved_policy" class="border rounded px-3 py-2">
<option value="fail">Fail</option>
<option value="blank">Blank</option>
<option value="keep">Keep</option>
</select>
<span v-if="form.errors.unresolved_policy" class="text-xs text-rose-600">{{
form.errors.unresolved_policy
}}</span>
</label>
<label class="flex items-center gap-2 mt-6">
<input
type="checkbox"
v-model="form.preview_enabled"
true-value="1"
false-value="0"
/>
<span class="text-sm font-medium">Omogoči predoglede</span>
</label>
</div>
<div class="grid md:grid-cols-2 gap-6">
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Whitelist (JSON)</span>
<textarea
v-model="form.whitelist"
rows="8"
class="font-mono text-xs border rounded p-2"
></textarea>
<span v-if="whitelistError" class="text-xs text-rose-600">{{
whitelistError
}}</span>
<span v-else-if="form.errors.whitelist" class="text-xs text-rose-600">{{
form.errors.whitelist
}}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-medium">Date formats override (JSON)</span>
<textarea
v-model="form.date_formats"
rows="8"
class="font-mono text-xs border rounded p-2"
></textarea>
<span class="text-xs text-gray-500"
>Primer: {"contract.start_date":"d.m.Y"}</span
>
<span v-if="dateFormatsError" class="text-xs text-rose-600">{{
dateFormatsError
}}</span>
</label>
</div>
<div class="flex items-center gap-3">
<button
:disabled="form.processing"
class="px-4 py-2 bg-indigo-600 text-white rounded disabled:opacity-50"
>
{{ form.processing ? "Shranjevanje..." : "Shrani" }}
</button>
<span v-if="form.wasSuccessful" class="text-sm text-emerald-600"
>Shranjeno</span
>
</div>
</form>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,32 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue'
const props = defineProps({
config: Object,
})
</script>
<template>
<AdminLayout title="Nastavitve dokumentov">
<h1 class="text-2xl font-semibold mb-4">Nastavitve dokumentov</h1>
<div class="space-y-4">
<div class="p-4 bg-white rounded border">
<h2 class="font-medium mb-2">Privzeti vzorci</h2>
<p class="text-sm text-gray-600">Ime datoteke: <code class="px-1 bg-gray-100 rounded">{{ config.file_name_pattern }}</code></p>
<p class="text-sm text-gray-600">Format datuma: <code class="px-1 bg-gray-100 rounded">{{ config.date_format }}</code></p>
<p class="text-sm text-gray-600">Politika nerešenih: <code class="px-1 bg-gray-100 rounded">{{ config.unresolved_policy }}</code></p>
</div>
<div class="p-4 bg-white rounded border">
<h2 class="font-medium mb-2">Dovoljeni tokeni (whitelist)</h2>
<div v-for="(cols, entity) in config.whitelist" :key="entity" class="mb-3">
<div class="text-sm font-semibold">{{ entity }}</div>
<div class="text-xs text-gray-600" v-if="cols.length">{{ cols.join(', ') }}</div>
<div class="text-xs text-gray-400" v-else>(brez specifičnih stolpcev)</div>
</div>
</div>
<div class="p-4 bg-white rounded border">
<h2 class="font-medium mb-2">Uredi (prihaja)</h2>
<p class="text-xs text-gray-500">Za urejanje bo dodan obrazec. Trenutno spremembe izvedite v <code>config/documents.php</code>.</p>
</div>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,333 @@
<template>
<AdminLayout title="Uredi predlogo">
<div class="mb-6 flex flex-col lg:flex-row lg:items-start gap-6">
<div class="flex-1 min-w-[320px]">
<div class="flex items-start justify-between gap-4 mb-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">{{ template.name }}</h1>
<p class="text-xs text-gray-500 mt-1 flex flex-wrap gap-3">
<span class="inline-flex items-center gap-1"
><span class="text-gray-400">Slug:</span
><span class="font-medium">{{ template.slug }}</span></span
>
<span class="inline-flex items-center gap-1"
><span class="text-gray-400">Verzija:</span
><span class="font-medium">v{{ template.version }}</span></span
>
<span
class="inline-flex items-center gap-1"
:class="template.active ? 'text-emerald-600' : 'text-gray-400'"
><span
class="w-1.5 h-1.5 rounded-full"
:class="template.active ? 'bg-emerald-500' : 'bg-gray-300'"
/>
{{ template.active ? "Aktivna" : "Neaktivna" }}</span
>
</p>
</div>
<form @submit.prevent="toggleActive" class="flex items-center gap-2">
<button
type="submit"
:class="[btnBase, template.active ? btnWarn : btnOutline]"
:disabled="toggleForm.processing"
>
<span v-if="toggleForm.processing">...</span>
<span v-else>{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}</span>
</button>
<Link
:href="route('admin.document-templates.show', template.id)"
:class="[btnBase, btnOutline]"
>Ogled</Link
>
</form>
</div>
<form @submit.prevent="submit" class="space-y-8">
<!-- Osnovno -->
<div class="bg-white border rounded-lg shadow-sm p-5 space-y-5">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
Osnovne nastavitve
</h2>
</div>
<div class="grid md:grid-cols-2 gap-6">
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600"
>Izlazna datoteka (pattern)</span
>
<input
v-model="form.output_filename_pattern"
type="text"
class="input input-bordered w-full input-sm"
placeholder="POVRACILO_{contract.reference}"
/>
<span class="text-[11px] text-gray-500"
>Tokens npr. {contract.reference}</span
>
</label>
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600"
>Privzeti format datuma</span
>
<input
v-model="form.date_format"
type="text"
class="input input-bordered w-full input-sm"
placeholder="d.m.Y"
/>
</label>
</div>
<label class="flex items-center gap-2 text-xs font-medium text-gray-600">
<input
id="fail_on_unresolved"
type="checkbox"
v-model="form.fail_on_unresolved"
class="checkbox checkbox-xs"
/>
<span>Prekini če token ni rešen (fail on unresolved)</span>
</label>
</div>
<!-- Formatiranje -->
<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">
Formatiranje
</h2>
<div class="grid md:grid-cols-3 gap-5">
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600">Decimalna mesta</span>
<input
v-model.number="form.number_decimals"
type="number"
min="0"
max="6"
class="input input-bordered w-full input-sm"
/>
</label>
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600">Decimalni separator</span>
<input
v-model="form.decimal_separator"
type="text"
maxlength="2"
class="input input-bordered w-full input-sm"
/>
</label>
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600">Tisocice separator</span>
<input
v-model="form.thousands_separator"
type="text"
maxlength="2"
class="input input-bordered w-full input-sm"
/>
</label>
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600">Znak valute</span>
<input
v-model="form.currency_symbol"
type="text"
maxlength="8"
class="input input-bordered w-full input-sm"
/>
</label>
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600">Pozicija valute</span>
<select
v-model="form.currency_position"
class="select select-bordered select-sm w-full"
>
<option :value="null">(privzeto)</option>
<option value="before">Pred</option>
<option value="after">Za</option>
</select>
</label>
<label
class="flex items-center gap-2 space-y-0 pt-6 text-xs font-medium text-gray-600"
>
<input
id="currency_space"
type="checkbox"
v-model="form.currency_space"
class="checkbox checkbox-xs"
/>
<span>Presledek pred/za valuto</span>
</label>
</div>
</div>
<!-- Aktivnost -->
<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">
Aktivnost
</h2>
<div class="grid md:grid-cols-2 gap-6">
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600">Akcija</span>
<select
v-model="form.action_id"
class="select select-bordered select-sm w-full"
@change="handleActionChange"
>
<option :value="null">(brez)</option>
<option v-for="a in actions" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
</label>
<label class="space-y-1 block">
<span class="text-xs font-medium text-gray-600">Odločitev</span>
<select
v-model="form.decision_id"
class="select select-bordered select-sm w-full"
:disabled="!currentActionDecisions.length"
>
<option :value="null">(brez)</option>
<option v-for="d in currentActionDecisions" :key="d.id" :value="d.id">
{{ d.name }}
</option>
</select>
</label>
<label class="space-y-1 md:col-span-2 block">
<span class="text-xs font-medium text-gray-600"
>Predloga opombe aktivnosti</span
>
<textarea
v-model="form.activity_note_template"
rows="3"
class="textarea textarea-bordered w-full text-xs"
placeholder="Besedilo aktivnosti..."
/>
<span class="text-[11px] text-gray-500"
>Tokeni npr. {contract.reference}</span
>
</label>
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<button
type="submit"
:class="[btnBase, btnPrimary]"
:disabled="form.processing"
>
<span v-if="form.processing">Shranjevanje</span>
<span v-else>Shrani spremembe</span>
</button>
<Link
:href="route('admin.document-templates.show', template.id)"
:class="[btnBase, btnOutline]"
>Prekliči</Link
>
</div>
</form>
</div>
<!-- Side meta panel -->
<aside class="w-full lg:w-72 space-y-6">
<div class="bg-white border rounded-lg shadow-sm p-4 space-y-3">
<h3 class="text-xs font-semibold tracking-wide text-gray-600 uppercase">
Meta
</h3>
<ul class="text-xs text-gray-600 space-y-1">
<li>
<span class="text-gray-400">Velikost:</span>
<span class="font-medium"
>{{ (template.file_size / 1024).toFixed(1) }} KB</span
>
</li>
<li>
<span class="text-gray-400">Hash:</span>
<span class="font-mono">{{ template.file_hash?.substring(0, 12) }}</span>
</li>
<li>
<span class="text-gray-400">Engine:</span>
<span class="font-medium">{{ template.engine }}</span>
</li>
</ul>
<a
:href="'/storage/' + template.file_path"
target="_blank"
class="text-[11px] inline-flex items-center gap-1 text-indigo-600 hover:underline"
>Prenesi izvorni DOCX </a
>
</div>
<div
v-if="template.tokens?.length"
class="bg-white border rounded-lg shadow-sm p-4"
>
<h3 class="text-xs font-semibold tracking-wide text-gray-600 uppercase mb-2">
Tokens ({{ template.tokens.length }})
</h3>
<div class="flex flex-wrap gap-1.5 max-h-48 overflow-auto pr-1">
<span
v-for="t in template.tokens"
:key="t"
class="px-1.5 py-0.5 bg-gray-100 rounded text-[11px] font-mono"
>{{ t }}</span
>
</div>
</div>
</aside>
</div>
</AdminLayout>
</template>
<script setup>
import { computed } from "vue";
import { useForm, Link, router } from "@inertiajs/vue3";
import AdminLayout from "@/Layouts/AdminLayout.vue";
// Button style utility classes
const btnBase =
"inline-flex items-center justify-center gap-1 rounded-md border text-xs font-medium px-3 py-1.5 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed";
const btnPrimary = "bg-indigo-600 border-indigo-600 text-white hover:bg-indigo-500";
const btnOutline = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50";
const btnWarn = "bg-amber-500 border-amber-500 text-white hover:bg-amber-400";
const props = defineProps({
template: Object,
actions: Array,
});
const form = useForm({
output_filename_pattern: props.template.output_filename_pattern || "",
date_format: props.template.date_format || "",
fail_on_unresolved: props.template.fail_on_unresolved ?? false,
number_decimals: props.template.formatting_options?.number_decimals ?? 2,
decimal_separator: props.template.formatting_options?.decimal_separator ?? ",",
thousands_separator: props.template.formatting_options?.thousands_separator ?? ".",
currency_symbol: props.template.formatting_options?.currency_symbol ?? "€",
currency_position: props.template.formatting_options?.currency_position ?? "after",
currency_space: props.template.formatting_options?.currency_space ?? true,
action_id: props.template.action_id ?? null,
decision_id: props.template.decision_id ?? null,
activity_note_template: props.template.activity_note_template || "",
});
const toggleForm = useForm({});
const currentActionDecisions = computed(() => {
if (!form.action_id) {
return [];
}
const a = props.actions.find((a) => a.id === form.action_id);
return a ? a.decisions : [];
});
function handleActionChange() {
if (!currentActionDecisions.value.some((d) => d.id === form.decision_id)) {
form.decision_id = null;
}
}
function submit() {
form.put(route("admin.document-templates.settings.update", props.template.id));
}
function toggleActive() {
toggleForm.post(route("admin.document-templates.toggle", props.template.id), {
preserveScroll: true,
});
}
</script>

View File

@ -1,271 +1,245 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Link, useForm, router } from "@inertiajs/vue3";
import { computed, reactive, watch } from "vue";
import { Link, useForm } from "@inertiajs/vue3";
import { computed, ref } from "vue";
const props = defineProps({
templates: Array,
templates: { type: Array, default: () => [] },
});
// Group by slug => versions desc
const grouped = computed(() => {
const map = {};
props.templates.forEach((t) => {
if (!map[t.slug]) map[t.slug] = [];
map[t.slug].push(t);
});
Object.values(map).forEach((arr) => arr.sort((a, b) => b.version - a.version));
return map;
// Upload form state
const uploadForm = useForm({ name: "", slug: "", file: null });
const selectedSlug = ref("");
const uniqueSlugs = computed(() => {
const s = new Set(props.templates.map((t) => t.slug));
return Array.from(s).sort();
});
// Inertia form for uploading new template version
const form = useForm({
name: "Povzetek pogodbe",
slug: "contract-summary",
file: null,
});
function handleFile(e) {
form.file = e.target.files[0];
uploadForm.file = e.target.files[0];
}
function submit() {
if (!form.file) {
function submitUpload() {
if (!uploadForm.file) {
return;
}
form.post(route("admin.document-templates.store"), {
if (!uploadForm.slug && selectedSlug.value) {
uploadForm.slug = selectedSlug.value;
}
uploadForm.post(route("admin.document-templates.store"), {
forceFormData: true,
onSuccess: () => {
form.reset("file");
// clear input value manually (optional)
const fileInput = document.getElementById("template-file-input");
if (fileInput) fileInput.value = "";
uploadForm.reset("file");
const input = document.getElementById("docx-upload-input");
if (input) input.value = "";
},
});
}
function toggle(templateId) {
const f = useForm({});
f.post(route("admin.document-templates.toggle", templateId), { preserveScroll: true });
}
// Per-template settings forms (useForm instances) for optimistic updates
const settingsForms = reactive({});
const settingsSaved = reactive({});
props.templates.forEach(t => {
if (!settingsForms[t.id]) {
settingsForms[t.id] = useForm({
output_filename_pattern: t.output_filename_pattern || '',
date_format: t.date_format || '',
fail_on_unresolved: t.fail_on_unresolved ? 1 : 0,
number_decimals: t.formatting_options?.number_decimals ?? 2,
decimal_separator: t.formatting_options?.decimal_separator ?? ',',
thousands_separator: t.formatting_options?.thousands_separator ?? '.',
currency_symbol: t.formatting_options?.currency_symbol ?? '€',
currency_position: t.formatting_options?.currency_position ?? 'after',
currency_space: t.formatting_options?.currency_space ? 1 : 0,
default_date_format: t.formatting_options?.default_date_format || '',
});
// Group templates by slug and sort versions DESC
const groups = computed(() => {
const map = {};
for (const t of props.templates) {
if (!map[t.slug]) {
map[t.slug] = { slug: t.slug, name: t.name, versions: [] };
}
map[t.slug].versions.push(t);
}
});
// Watch for newly added templates (e.g. after uploading a new version) and lazily initialize missing settings forms
watch(
() => props.templates,
(list) => {
list.forEach((t) => {
if (!settingsForms[t.id]) {
settingsForms[t.id] = useForm({
output_filename_pattern: t.output_filename_pattern || '',
date_format: t.date_format || '',
fail_on_unresolved: t.fail_on_unresolved ? 1 : 0,
number_decimals: t.formatting_options?.number_decimals ?? 2,
decimal_separator: t.formatting_options?.decimal_separator ?? ',',
thousands_separator: t.formatting_options?.thousands_separator ?? '.',
currency_symbol: t.formatting_options?.currency_symbol ?? '€',
currency_position: t.formatting_options?.currency_position ?? 'after',
currency_space: t.formatting_options?.currency_space ? 1 : 0,
default_date_format: t.formatting_options?.default_date_format || '',
});
}
});
},
{ deep: true }
);
function submitSettings(id) {
const f = settingsForms[id];
f.put(route('admin.document-templates.settings.update', id), {
preserveScroll: true,
onSuccess: () => {
settingsSaved[id] = true;
setTimeout(() => { settingsSaved[id] = false; }, 2000);
},
Object.values(map).forEach((g) => {
g.versions.sort((a, b) => b.version - a.version);
// ensure display name from latest version
if (g.versions[0]) {
g.name = g.versions[0].name;
}
});
}
return Object.values(map).sort((a, b) => a.slug.localeCompare(b.slug));
});
</script>
<template>
<AdminLayout title="Dokumentne predloge">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-semibold mb-1">Dokumentne predloge</h1>
<p class="text-sm text-gray-500">
Upravljanje verzij DOCX predlog za generiranje dokumentov.
</p>
</div>
<form
@submit.prevent="submit"
class="flex items-center gap-3 text-sm bg-white p-2 rounded border"
>
<input type="text" v-model="form.name" class="hidden" />
<input type="text" v-model="form.slug" class="hidden" />
<input
id="template-file-input"
type="file"
required
accept=".docx"
class="text-xs"
@change="handleFile"
/>
<button
type="submit"
:disabled="form.processing || !form.file"
class="px-3 py-1.5 rounded bg-emerald-600 text-white disabled:opacity-50"
>
Nova verzija
</button>
<div v-if="form.progress" class="w-28 h-1 bg-gray-200 rounded overflow-hidden">
<div
class="h-full bg-emerald-500 transition-all"
:style="{ width: form.progress.percentage + '%' }"
></div>
</div>
<div v-if="form.errors.file" class="text-xs text-rose-600">
{{ form.errors.file }}
</div>
</form>
</div>
<div class="space-y-6">
<div
v-for="(versions, slug) in grouped"
:key="slug"
class="bg-white border rounded"
>
<div class="px-4 py-3 border-b flex items-center justify-between">
<div class="font-medium">
{{ versions[0].name }} <span class="text-xs text-gray-500">({{ slug }})</span>
</div>
<div class="flex items-center gap-2">
<form
v-if="versions[0]"
method="post"
:action="route('admin.document-templates.toggle', versions[0].id)"
<div class="mb-8 space-y-6">
<!-- Header & Upload -->
<div class="flex flex-col xl:flex-row xl:items-start gap-6">
<div class="flex-1 min-w-[280px]">
<h1 class="text-2xl font-semibold tracking-tight flex items-center gap-2">
<span>Dokumentne predloge</span>
<span
class="text-xs font-medium bg-gray-200 text-gray-600 px-2 py-0.5 rounded"
>{{ groups.length }} skupin</span
>
<input type="hidden" name="_method" value="POST" />
<button
class="px-2 py-1 rounded text-xs"
:class="
versions[0].active
? 'bg-amber-500 text-white'
: 'bg-gray-200 text-gray-700'
"
>
{{ versions[0].active ? "Deaktiviraj" : "Aktiviraj" }}
</button>
</form>
</div>
</h1>
<p class="text-sm text-gray-500 mt-1 max-w-prose">
Upravljaj verzije DOCX predlog. Naloži novo verzijo obstoječega sluga ali
ustvari popolnoma novo predlogo.
</p>
</div>
<div class="divide-y">
<form
@submit.prevent="submitUpload"
class="flex-1 bg-white/70 backdrop-blur border rounded-lg shadow-sm p-4 flex flex-col gap-3"
>
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-gray-700 flex items-center gap-2">
<span class="i-lucide-upload-cloud w-4 h-4" /> Nova / nova verzija
</h2>
<div
v-if="uploadForm.progress"
class="w-40 h-1 bg-gray-200 rounded overflow-hidden"
>
<div
class="h-full bg-indigo-500 transition-all"
:style="{ width: uploadForm.progress.percentage + '%' }"
/>
</div>
</div>
<div class="grid md:grid-cols-5 gap-3 text-xs">
<div class="md:col-span-1">
<label class="block font-medium mb-1">Obstoječi slug</label>
<select
v-model="selectedSlug"
class="select select-bordered select-sm w-full"
>
<option value="">(nov)</option>
<option v-for="s in uniqueSlugs" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<div class="md:col-span-1">
<label class="block font-medium mb-1">Nov slug</label>
<input
v-model="uploadForm.slug"
:disabled="selectedSlug"
type="text"
class="input input-bordered input-sm w-full"
placeholder="opomin"
/>
</div>
<div class="md:col-span-1">
<label class="block font-medium mb-1">Naziv</label>
<input
v-model="uploadForm.name"
type="text"
class="input input-bordered input-sm w-full"
placeholder="Ime predloge"
/>
</div>
<div class="md:col-span-2 flex items-end">
<label class="w-full">
<input
id="docx-upload-input"
@change="handleFile"
type="file"
accept=".docx"
class="file-input file-input-bordered file-input-sm w-full"
/>
</label>
</div>
</div>
<div class="flex items-center justify-end gap-3 pt-1">
<span class="text-[11px] text-gray-500" v-if="!uploadForm.file"
>Izberi DOCX datoteko</span
>
<button
type="submit"
class="btn btn-sm btn-primary"
:disabled="
uploadForm.processing ||
!uploadForm.file ||
(!uploadForm.slug && !selectedSlug)
"
>
<span v-if="uploadForm.processing">Nalaganje</span>
<span v-else>Shrani verzijo</span>
</button>
</div>
<div v-if="uploadForm.errors.file" class="text-rose-600 text-xs">
{{ uploadForm.errors.file }}
</div>
</form>
</div>
<!-- Groups -->
<div v-if="groups.length" class="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
<div
v-for="g in groups"
:key="g.slug"
class="group relative flex flex-col bg-white border rounded-lg shadow-sm overflow-hidden"
>
<div
v-for="v in versions"
:key="v.id"
class="px-4 py-3 text-sm flex items-center justify-between"
class="px-4 py-3 border-b bg-gradient-to-r from-gray-50 to-white flex items-start justify-between gap-3"
>
<div class="flex flex-col">
<span
>Verzija v{{ v.version }}
<div class="min-w-0">
<h3 class="font-medium text-sm leading-5 truncate">{{ g.name }}</h3>
<div
class="flex flex-wrap items-center gap-2 mt-1 text-[11px] text-gray-500"
>
<span class="px-1.5 py-0.5 bg-gray-100 rounded">{{ g.slug }}</span>
<span>Zadnja: v{{ g.versions[0].version }}</span>
<span
v-if="v.id === versions[0].id"
class="text-emerald-600 text-xs font-semibold"
>zadnja</span
></span
>
<span class="text-xs text-gray-500"
>Hash: {{ v.file_hash?.substring(0, 10) }} | Velikost:
{{ (v.file_size / 1024).toFixed(1) }} KB</span
>
<a
class="text-xs text-indigo-600 hover:underline"
:href="'/storage/' + v.file_path"
target="_blank"
>Prenesi</a
>
<div v-if="v.id === versions[0].id" class="mt-3 pt-3 border-t space-y-2">
<form @submit.prevent="submitSettings(v.id)" class="grid gap-2 md:grid-cols-4 text-xs items-end">
<label class="flex flex-col gap-1 md:col-span-2">
<span class="font-medium">Vzorec imena</span>
<input name="output_filename_pattern" v-model="settingsForms[v.id].output_filename_pattern" placeholder="{slug}_{generation.date}.docx" class="border rounded px-2 py-1" />
<span v-if="settingsForms[v.id].errors.output_filename_pattern" class="text-rose-600">{{ settingsForms[v.id].errors.output_filename_pattern }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="font-medium">Format datuma</span>
<input name="date_format" v-model="settingsForms[v.id].date_format" placeholder="Y-m-d" class="border rounded px-2 py-1" />
<span v-if="settingsForms[v.id].errors.date_format" class="text-rose-600">{{ settingsForms[v.id].errors.date_format }}</span>
</label>
<label class="flex items-center gap-2 mt-5">
<input type="checkbox" name="fail_on_unresolved" true-value="1" false-value="0" v-model="settingsForms[v.id].fail_on_unresolved" />
<span>Fail na nerešene</span>
</label>
<div class="md:col-span-4 mt-2 p-3 bg-gray-50 rounded border border-gray-200 grid gap-2 md:grid-cols-6">
<div class="col-span-6 text-[10px] uppercase tracking-wide text-gray-500 font-semibold">Formatiranje števil / valute</div>
<label class="flex flex-col gap-1">
<span>Decimale</span>
<input type="number" min="0" max="6" name="number_decimals" v-model="settingsForms[v.id].number_decimals" class="border rounded px-2 py-1" />
</label>
<label class="flex flex-col gap-1">
<span>Decimalno</span>
<input name="decimal_separator" v-model="settingsForms[v.id].decimal_separator" class="border rounded px-2 py-1" />
</label>
<label class="flex flex-col gap-1">
<span>Tisočice</span>
<input name="thousands_separator" v-model="settingsForms[v.id].thousands_separator" class="border rounded px-2 py-1" />
</label>
<label class="flex flex-col gap-1">
<span>Simbol</span>
<input name="currency_symbol" v-model="settingsForms[v.id].currency_symbol" class="border rounded px-2 py-1" />
</label>
<label class="flex flex-col gap-1">
<span>Pozicija</span>
<select name="currency_position" v-model="settingsForms[v.id].currency_position" class="border rounded px-2 py-1">
<option value="before">Pred</option>
<option value="after">Za</option>
</select>
</label>
<label class="flex items-center gap-2 mt-5">
<input type="checkbox" name="currency_space" true-value="1" false-value="0" v-model="settingsForms[v.id].currency_space" />
<span>Presledek</span>
</label>
<div class="col-span-6 border-t my-1"></div>
<div class="col-span-6 text-[10px] uppercase tracking-wide text-gray-500 font-semibold mt-1">Datumi</div>
<label class="flex flex-col gap-1 md:col-span-2">
<span>Privzeti datum</span>
<input name="default_date_format" v-model="settingsForms[v.id].default_date_format" placeholder="d.m.Y" class="border rounded px-2 py-1" />
</label>
<div class="md:col-span-4 text-xs text-gray-500 flex items-center">Uporabi npr. d.m.Y ali Y-m-d. Posamezni tokeni lahko dobijo specifičen format (nadgradnja kasneje).</div>
</div>
<div class="md:col-span-4 flex gap-2 items-center">
<button :disabled="settingsForms[v.id].processing" class="px-3 py-1.5 bg-indigo-600 text-white rounded disabled:opacity-50">{{ settingsForms[v.id].processing ? 'Shranjevanje...' : 'Shrani' }}</button>
<span v-if="settingsSaved[v.id]" class="text-emerald-600">Shranjeno</span>
<span class="text-gray-400">Placeholders: {slug} {version} {generation.date} {generation.timestamp}</span>
</div>
</form>
class="flex items-center gap-1"
:class="
g.versions.filter((v) => v.active).length
? 'text-emerald-600'
: 'text-gray-400'
"
>
<span
class="w-1.5 h-1.5 rounded-full"
:class="
g.versions.filter((v) => v.active).length
? 'bg-emerald-500'
: 'bg-gray-300'
"
/>
{{ g.versions.filter((v) => v.active).length }} aktivnih
</span>
</div>
</div>
<span
class="text-xs"
:class="v.active ? 'text-emerald-600' : 'text-gray-400'"
>{{ v.active ? "Aktivno" : "Neaktivno" }}</span
<Link
:href="route('admin.document-templates.show', g.versions[0].id)"
class="text-xs text-indigo-600 hover:underline whitespace-nowrap mt-1"
>Detalji</Link
>
</div>
<div class="p-3 flex-1 flex flex-col gap-2">
<div class="flex flex-wrap gap-2">
<div v-for="v in g.versions" :key="v.id" class="flex items-center gap-1">
<Link
:href="route('admin.document-templates.edit', v.id)"
class="px-2 py-0.5 rounded-md border text-[11px] font-medium transition-colors"
:class="
v.active
? 'border-emerald-500/60 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
: 'border-gray-300 bg-white text-gray-600 hover:bg-gray-50'
"
>v{{ v.version }}</Link
>
<button
type="button"
@click="toggle(v.id)"
class="rounded-md border px-1.5 py-0.5 text-[10px] font-medium transition-colors"
:class="
v.active
? 'bg-amber-500 border-amber-500 text-white hover:bg-amber-600'
: 'bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200'
"
>
{{ v.active ? "✕" : "✓" }}
</button>
</div>
</div>
<div class="mt-auto pt-2 border-t flex justify-end">
<Link
:href="route('admin.document-templates.edit', g.versions[0].id)"
class="text-[11px] text-indigo-600 hover:underline"
>Uredi zadnjo verzijo </Link
>
</div>
</div>
</div>
</div>
<p v-else class="text-sm text-gray-500">Ni predlog.</p>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,237 @@
<template>
<AdminLayout title="Predloga">
<div class="flex flex-col lg:flex-row gap-6 items-start">
<div class="flex-1 min-w-[320px] space-y-6">
<div class="bg-white border rounded-lg shadow-sm p-5 flex flex-col gap-4">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-2xl font-semibold tracking-tight">{{ template.name }}</h1>
<p class="text-xs text-gray-500 mt-1 flex flex-wrap gap-3">
<span class="inline-flex items-center gap-1"
><span class="text-gray-400">Slug:</span
><span class="font-medium">{{ template.slug }}</span></span
>
<span class="inline-flex items-center gap-1"
><span class="text-gray-400">Verzija:</span
><span class="font-medium">v{{ template.version }}</span></span
>
<span
class="inline-flex items-center gap-1"
:class="template.active ? 'text-emerald-600' : 'text-gray-400'"
>
<span
class="w-1.5 h-1.5 rounded-full"
:class="template.active ? 'bg-emerald-500' : 'bg-gray-300'"
/>
{{ template.active ? "Aktivna" : "Neaktivna" }}
</span>
</p>
</div>
<form @submit.prevent="toggleActive" class="flex items-center gap-2">
<button
type="submit"
:class="[btnBase, template.active ? btnWarn : btnOutline]"
:disabled="toggleForm.processing"
>
<span v-if="toggleForm.processing">...</span>
<span v-else>{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}</span>
</button>
<Link
:href="route('admin.document-templates.edit', template.id)"
:class="[btnBase, btnPrimary]"
>Uredi</Link
>
<Link
:href="route('admin.document-templates.index')"
:class="[btnBase, btnOutline]"
>Nazaj</Link
>
</form>
</div>
<div class="grid md:grid-cols-3 gap-6 text-xs">
<div class="space-y-2">
<h3 class="uppercase font-semibold tracking-wide text-gray-600">
Datoteka
</h3>
<ul class="space-y-1 text-gray-600">
<li>
<span class="text-gray-400">Velikost:</span>
<span class="font-medium"
>{{ (template.file_size / 1024).toFixed(1) }} KB</span
>
</li>
<li>
<span class="text-gray-400">Hash:</span>
<span class="font-mono"
>{{ template.file_hash?.substring(0, 12) }}</span
>
</li>
<li>
<span class="text-gray-400">Engine:</span>
<span class="font-medium">{{ template.engine }}</span>
</li>
</ul>
<a
:href="'/storage/' + template.file_path"
target="_blank"
class="text-[11px] inline-flex items-center gap-1 text-indigo-600 hover:underline"
>Prenesi DOCX </a
>
</div>
<div class="space-y-2">
<h3 class="uppercase font-semibold tracking-wide text-gray-600">
Formatiranje
</h3>
<ul class="space-y-1 text-gray-600">
<li>
<span class="text-gray-400">Datum:</span>
{{ template.settings?.date_format || "d.m.Y" }}
</li>
<li>
<span class="text-gray-400">Decimalna mesta:</span>
{{ template.settings?.number_decimals ?? "-" }}
</li>
<li>
<span class="text-gray-400">Separators:</span>
{{ template.settings?.decimal_separator || "." }} /
{{ template.settings?.thousands_separator || " " }}
</li>
<li>
<span class="text-gray-400">Valuta:</span>
{{ template.settings?.currency_symbol || "€" }} ({{
template.settings?.currency_position || "before"
}})
</li>
</ul>
</div>
<div class="space-y-2">
<h3 class="uppercase font-semibold tracking-wide text-gray-600">
Aktivnost
</h3>
<ul class="space-y-1 text-gray-600">
<li>
<span class="text-gray-400">Akcija:</span>
{{ template.action?.name || "-" }}
</li>
<li>
<span class="text-gray-400">Odločitev:</span>
{{ template.decision?.name || "-" }}
</li>
<li>
<span class="text-gray-400">Fail unresolved:</span>
{{ template.settings?.fail_on_unresolved ? "DA" : "NE" }}
</li>
</ul>
</div>
</div>
</div>
<div
v-if="template.settings?.activity_note_template"
class="bg-white border rounded-lg shadow-sm p-5 space-y-2 text-xs"
>
<h2 class="uppercase font-semibold tracking-wide text-gray-600">
Predloga opombe aktivnosti
</h2>
<pre
class="bg-gray-50 p-3 rounded border text-[11px] leading-relaxed whitespace-pre-wrap"
>{{ template.settings.activity_note_template }}</pre
>
</div>
<div
v-if="template.tokens?.length"
class="bg-white border rounded-lg shadow-sm p-5"
>
<div class="flex items-center justify-between mb-2">
<h2 class="uppercase font-semibold tracking-wide text-gray-600 text-xs">
Tokens ({{ template.tokens.length }})
</h2>
<button
type="button"
@click="expandedTokens = !expandedTokens"
class="text-[11px] text-indigo-600 hover:underline"
>
{{ expandedTokens ? "Skrij" : "Prikaži vse" }}
</button>
</div>
<div
class="flex flex-wrap gap-1.5 max-h-56 overflow-auto pr-1"
:class="!expandedTokens && 'max-h-32'"
>
<span
v-for="t in template.tokens"
:key="t"
class="px-1.5 py-0.5 bg-gray-100 rounded text-[11px] font-mono"
>{{ t }}</span
>
</div>
</div>
</div>
<aside class="w-full lg:w-72 space-y-6">
<div class="bg-white border rounded-lg shadow-sm p-4 space-y-3 text-xs">
<h3 class="uppercase font-semibold tracking-wide text-gray-600">
Hitra dejanja
</h3>
<div class="flex flex-col gap-2">
<Link
:href="route('admin.document-templates.edit', template.id)"
:class="[btnBase, btnPrimary]"
>Uredi nastavitve</Link
>
<form @submit.prevent="toggleActive">
<button
type="submit"
:class="[btnBase, template.active ? btnWarn : btnOutline]"
:disabled="toggleForm.processing"
>
{{ template.active ? "Deaktiviraj" : "Aktiviraj" }}
</button>
</form>
<Link
:href="route('admin.document-templates.index')"
:class="[btnBase, btnOutline]"
>Vse predloge</Link
>
</div>
</div>
<div
class="bg-white border rounded-lg shadow-sm p-4 space-y-2 text-[11px] text-gray-600"
>
<h3 class="uppercase font-semibold tracking-wide text-gray-600 text-xs">
Opombe
</h3>
<p>
Uporabi to stran za hiter pregled meta podatkov predloge ter njenih tokenov.
</p>
</div>
</aside>
</div>
</AdminLayout>
</template>
<script setup>
import { Link } from "@inertiajs/vue3";
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { useForm } from "@inertiajs/vue3";
// Button style utility classes
const btnBase =
"inline-flex items-center justify-center gap-1 rounded-md border text-xs font-medium px-3 py-1.5 transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed";
const btnPrimary = "bg-indigo-600 border-indigo-600 text-white hover:bg-indigo-500";
const btnOutline = "bg-white text-gray-700 border-gray-300 hover:bg-gray-50";
const btnWarn = "bg-amber-500 border-amber-500 text-white hover:bg-amber-400";
const props = defineProps({
template: Object,
});
const toggleForm = useForm({});
function toggleActive() {
toggleForm.post(route("admin.document-templates.toggle", template.id), {
preserveScroll: true,
});
}
</script>

View File

@ -0,0 +1,76 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { Link } from '@inertiajs/vue3'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faUserGroup, faKey, faGears, faFileWord } from '@fortawesome/free-solid-svg-icons'
const cards = [
{
category: 'Uporabniki & Dovoljenja',
items: [
{
title: 'Uporabniki',
description: 'Upravljanje uporabnikov in njihovih vlog',
route: 'admin.users.index',
icon: faUserGroup,
},
{
title: 'Novo dovoljenje',
description: 'Dodaj in konfiguriraj novo dovoljenje',
route: 'admin.permissions.create',
icon: faKey,
},
],
},
{
category: 'Dokumenti',
items: [
{
title: 'Nastavitve dokumentov',
description: 'Privzete sistemske nastavitve za dokumente',
route: 'admin.document-settings.index',
icon: faGears,
},
{
title: 'Predloge dokumentov',
description: 'Upravljanje in verzioniranje DOCX predlog',
route: 'admin.document-templates.index',
icon: faFileWord,
},
],
},
]
</script>
<template>
<AdminLayout title="Administrator">
<div class="space-y-14">
<section v-for="(group, i) in cards" :key="group.category" :class="[ i>0 ? 'pt-6 border-t border-gray-200/70' : '' ]">
<h2 class="text-xs font-semibold tracking-wider uppercase text-gray-500 mb-4">
{{ group.category }}
</h2>
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Link
v-for="item in group.items"
:key="item.title"
:href="route(item.route)"
class="group relative overflow-hidden p-5 rounded-lg border bg-white hover:border-indigo-300 hover:shadow transition focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<div class="flex items-start gap-4">
<span class="inline-flex items-center justify-center w-10 h-10 rounded-md bg-indigo-50 text-indigo-600 group-hover:bg-indigo-100">
<FontAwesomeIcon :icon="item.icon" class="w-5 h-5" />
</span>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-sm mb-1 flex items-center gap-2">
{{ item.title }}
<span class="opacity-0 group-hover:opacity-100 transition text-indigo-500 text-[10px] font-medium"></span>
</h3>
<p class="text-xs text-gray-500 leading-relaxed line-clamp-3">{{ item.description }}</p>
</div>
</div>
</Link>
</div>
</section>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,65 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { useForm, Link } from '@inertiajs/vue3'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faKey, faArrowLeft, faPlus } from '@fortawesome/free-solid-svg-icons'
const form = useForm({
name: '',
slug: '',
description: ''
})
function submit() {
form.post(route('admin.permissions.store'), {
preserveScroll: true,
onSuccess: () => form.reset('name','slug','description')
})
}
</script>
<template>
<AdminLayout title="Novo dovoljenje">
<div class="max-w-2xl mx-auto bg-white border rounded-xl shadow-sm p-6 space-y-8">
<header class="flex items-start justify-between gap-6">
<div class="space-y-1">
<h1 class="text-xl font-semibold tracking-tight flex items-center gap-2">
<span class="inline-flex items-center justify-center h-9 w-9 rounded-md bg-indigo-50 text-indigo-600"><FontAwesomeIcon :icon="faKey" /></span>
Novo dovoljenje
</h1>
<p class="text-sm text-gray-500">Ustvari sistemsko dovoljenje za uporabo pri vlogah.</p>
</div>
<Link :href="route('admin.permissions.index')" class="inline-flex items-center gap-1 text-xs font-medium text-gray-500 hover:text-gray-700">
<FontAwesomeIcon :icon="faArrowLeft" class="w-4 h-4" /> Nazaj
</Link>
</header>
<form @submit.prevent="submit" class="space-y-6">
<div class="grid sm:grid-cols-2 gap-6">
<div class="space-y-1">
<label class="block text-xs font-medium uppercase tracking-wide text-gray-600">Ime</label>
<input v-model="form.name" type="text" class="w-full border rounded-md px-3 py-2 text-sm focus:ring-indigo-500 focus:border-indigo-500" />
<p v-if="form.errors.name" class="text-xs text-red-600 mt-1">{{ form.errors.name }}</p>
</div>
<div class="space-y-1">
<label class="block text-xs font-medium uppercase tracking-wide text-gray-600">Slug</label>
<input v-model="form.slug" type="text" class="w-full border rounded-md px-3 py-2 text-sm font-mono focus:ring-indigo-500 focus:border-indigo-500" />
<p v-if="form.errors.slug" class="text-xs text-red-600 mt-1">{{ form.errors.slug }}</p>
</div>
<div class="sm:col-span-2 space-y-1">
<label class="block text-xs font-medium uppercase tracking-wide text-gray-600">Opis</label>
<textarea v-model="form.description" rows="3" class="w-full border rounded-md px-3 py-2 text-sm focus:ring-indigo-500 focus:border-indigo-500" />
<p v-if="form.errors.description" class="text-xs text-red-600 mt-1">{{ form.errors.description }}</p>
</div>
</div>
<div class="flex items-center gap-3 pt-2">
<button :disabled="form.processing" type="submit" class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50">
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Shrani
</button>
<Link :href="route('admin.permissions.index')" class="text-sm text-gray-500 hover:text-gray-700">Prekliči</Link>
</div>
</form>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,77 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { Link, usePage } from '@inertiajs/vue3'
import { ref, computed } from 'vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faMagnifyingGlass, faPlus, faKey } from '@fortawesome/free-solid-svg-icons'
const props = defineProps({
permissions: Array,
})
const q = ref('')
const filtered = computed(() => {
const term = q.value.toLowerCase().trim()
if (!term) return props.permissions
return props.permissions.filter(p =>
p.name.toLowerCase().includes(term) ||
p.slug.toLowerCase().includes(term) ||
(p.description || '').toLowerCase().includes(term)
)
})
</script>
<template>
<AdminLayout title="Dovoljenja">
<div class="max-w-5xl mx-auto space-y-8">
<div class="bg-white border rounded-xl shadow-sm p-6 space-y-6">
<header class="flex flex-col sm:flex-row sm:items-center gap-4 justify-between">
<div>
<h1 class="text-xl font-semibold tracking-tight">Dovoljenja</h1>
<p class="text-sm text-gray-500">Pregled vseh sistemskih dovoljenj.</p>
</div>
<Link :href="route('admin.permissions.create')" class="inline-flex items-center gap-2 px-3 py-2 rounded-md text-xs font-medium bg-indigo-600 text-white hover:bg-indigo-500">
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Novo
</Link>
</header>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="relative w-full sm:max-w-xs">
<span class="absolute left-2 top-2 text-gray-400">
<FontAwesomeIcon :icon="faMagnifyingGlass" class="w-4 h-4" />
</span>
<input v-model="q" type="text" placeholder="Išči..." class="pl-8 pr-3 py-1.5 text-sm rounded-md border border-gray-300 focus:ring-indigo-500 focus:border-indigo-500 w-full" />
</div>
<div class="text-xs text-gray-500">{{ filtered.length }} / {{ props.permissions.length }} rezultatov</div>
</div>
<div class="overflow-x-auto rounded-lg border border-slate-200">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="p-2 text-left text-[11px] uppercase tracking-wide font-medium">Ime</th>
<th class="p-2 text-left text-[11px] uppercase tracking-wide font-medium">Slug</th>
<th class="p-2 text-left text-[11px] uppercase tracking-wide font-medium">Opis</th>
<th class="p-2 text-left text-[11px] uppercase tracking-wide font-medium">Ustvarjeno</th>
</tr>
</thead>
<tbody>
<tr v-for="p in filtered" :key="p.id" class="border-t border-slate-100 hover:bg-slate-50/60">
<td class="p-2 whitespace-nowrap font-medium flex items-center gap-2">
<span class="inline-flex items-center justify-center h-7 w-7 rounded-md bg-indigo-50 text-indigo-600"><FontAwesomeIcon :icon="faKey" /></span>
{{ p.name }}
</td>
<td class="p-2 whitespace-nowrap font-mono text-xs text-gray-600">{{ p.slug }}</td>
<td class="p-2 text-xs text-gray-600 max-w-md">{{ p.description || '—' }}</td>
<td class="p-2 whitespace-nowrap text-xs text-gray-500">{{ new Date(p.created_at).toLocaleDateString() }}</td>
</tr>
<tr v-if="!filtered.length">
<td colspan="4" class="p-6 text-center text-sm text-gray-500">Ni rezultatov</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</AdminLayout>
</template>

View File

@ -0,0 +1,269 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { useForm, Link } from "@inertiajs/vue3";
import { ref, computed } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faMagnifyingGlass, faFloppyDisk } from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
users: Array,
roles: Array,
permissions: Array,
});
const query = ref("");
const roleFilter = ref(null);
const forms = Object.fromEntries(
props.users.map((u) => [
u.id,
useForm({ roles: u.roles.map((r) => r.id), dirty: false }),
])
);
function toggle(userId, roleId) {
const form = forms[userId];
const exists = form.roles.includes(roleId);
form.roles = exists
? form.roles.filter((id) => id !== roleId)
: [...form.roles, roleId];
form.dirty = true;
}
function submit(userId) {
const form = forms[userId];
form.put(route("admin.users.update", { user: userId }), {
preserveScroll: true,
onSuccess: () => {
form.dirty = false;
},
});
}
function submitAll() {
// sequential save of only dirty forms
Object.entries(forms).forEach(([id, f]) => {
if (f.dirty) {
f.put(route("admin.users.update", { user: id }), {
preserveScroll: true,
onSuccess: () => {
f.dirty = false;
},
});
}
});
}
const filteredUsers = computed(() => {
return props.users.filter((u) => {
const q = query.value.toLowerCase().trim();
const matchesQuery =
!q || u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q);
const matchesRole = !roleFilter.value || forms[u.id].roles.includes(roleFilter.value);
return matchesQuery && matchesRole;
});
});
const anyDirty = computed(() => Object.values(forms).some((f) => f.dirty));
</script>
<template>
<AdminLayout title="Upravljanje vlog uporabnikov">
<div class="max-w-7xl mx-auto space-y-8">
<div class="bg-white border rounded-xl shadow-sm p-6 space-y-7">
<header class="space-y-1">
<h1 class="text-xl font-semibold leading-tight tracking-tight">
Uporabniki & Vloge
</h1>
<p class="text-sm text-gray-500">
Dodeli ali odstrani vloge. Uporabi iskanje ali filter po vlogah za hitrejše
upravljanje.
</p>
</header>
<!-- Toolbar -->
<div
class="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between"
>
<div class="flex flex-wrap gap-3 items-center">
<div class="relative">
<span class="absolute left-2 top-1.5 text-gray-400">
<FontAwesomeIcon :icon="faMagnifyingGlass" class="w-4 h-4" />
</span>
<input
v-model="query"
type="text"
placeholder="Išči uporabnika..."
class="pl-8 pr-3 py-1.5 text-sm rounded-md border border-gray-300 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
@click="roleFilter = null"
:class="[
'px-2.5 py-1 rounded-full text-xs border transition',
roleFilter === null
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50',
]"
>
Vse
</button>
<button
v-for="r in props.roles"
:key="'rf-' + r.id"
type="button"
@click="roleFilter = r.id"
:class="[
'px-2.5 py-1 rounded-full text-xs border transition',
roleFilter === r.id
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-white text-gray-600 border-gray-300 hover:bg-gray-50',
]"
>
{{ r.name }}
</button>
</div>
</div>
<div class="flex items-center gap-3">
<button
type="button"
@click="submitAll"
:disabled="!anyDirty"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-md text-xs font-medium border disabled:opacity-40 disabled:cursor-not-allowed"
:class="
anyDirty
? 'bg-indigo-600 border-indigo-600 text-white hover:bg-indigo-500'
: 'bg-white border-gray-300 text-gray-400'
"
>
<FontAwesomeIcon :icon="faFloppyDisk" class="w-4 h-4" />
Shrani vse
</button>
</div>
</div>
<div class="overflow-x-auto rounded-lg border border-slate-200">
<table class="min-w-full text-sm">
<thead class="bg-slate-50 text-slate-600 sticky top-0 z-10">
<tr>
<th class="p-2 text-left font-medium text-[11px] uppercase tracking-wide">
Uporabnik
</th>
<th
v-for="role in props.roles"
:key="role.id"
class="p-2 font-medium text-[11px] uppercase tracking-wide text-center"
>
{{ role.name }}
</th>
<th
class="p-2 font-medium text-[11px] uppercase tracking-wide text-center"
>
Akcije
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(user, idx) in filteredUsers"
:key="user.id"
:class="[
'border-t border-slate-100',
idx % 2 === 1 ? 'bg-slate-50/40' : 'bg-white',
]"
>
<td class="p-2 whitespace-nowrap align-top">
<div class="font-medium text-sm flex items-center gap-2">
<span
class="inline-flex items-center justify-center h-7 w-7 rounded-full bg-indigo-50 text-indigo-600 text-xs font-semibold"
>{{ user.name.substring(0, 2).toUpperCase() }}</span
>
<span>{{ user.name }}</span>
<span
v-if="forms[user.id].dirty"
class="ml-1 inline-block px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 text-[10px] font-medium"
>Spremembe</span
>
</div>
<div class="text-[11px] text-slate-500 mt-0.5 font-mono">
{{ user.email }}
</div>
</td>
<td
v-for="role in props.roles"
:key="role.id"
class="p-2 text-center align-top"
>
<label class="inline-flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
class="h-4 w-4 rounded-md border-2 border-slate-400 bg-white text-indigo-600 accent-indigo-600 hover:border-slate-500 focus:ring-indigo-500 focus:ring-offset-0 focus:outline-none transition"
:checked="forms[user.id].roles.includes(role.id)"
@change="toggle(user.id, role.id)"
/>
</label>
</td>
<td class="p-2 text-center align-top">
<button
@click="submit(user.id)"
:disabled="forms[user.id].processing || !forms[user.id].dirty"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-40 disabled:cursor-not-allowed"
:class="
forms[user.id].dirty
? 'bg-indigo-600 text-white hover:bg-indigo-500'
: 'bg-gray-100 text-gray-400'
"
>
<span v-if="forms[user.id].processing">...</span>
<span v-else>Shrani</span>
</button>
</td>
</tr>
<tr v-if="!filteredUsers.length">
<td
:colspan="props.roles.length + 2"
class="p-6 text-center text-sm text-gray-500"
>
Ni rezultatov
</td>
</tr>
</tbody>
</table>
</div>
<div>
<h2
class="text-[11px] font-semibold tracking-wide uppercase text-slate-500 mb-3"
>
Referenca vlog in dovoljenj
</h2>
<div class="flex flex-wrap gap-3">
<div
v-for="role in props.roles"
:key="'ref-' + role.id"
class="px-3 py-2 rounded-lg border border-slate-200 bg-white shadow-sm"
>
<div class="font-medium text-sm flex items-center gap-2">
<span
class="inline-flex items-center justify-center h-6 w-6 rounded-md bg-indigo-50 text-indigo-600 text-[11px] font-semibold"
>{{ role.name.substring(0, 1).toUpperCase() }}</span
>
{{ role.name }}
</div>
<div class="flex flex-wrap gap-1 mt-2">
<span
v-for="perm in role.permissions"
:key="perm.id"
class="text-[10px] uppercase tracking-wide bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded"
>{{ perm.slug }}</span
>
</div>
</div>
</div>
</div>
</div>
</div>
</AdminLayout>
</template>

View File

@ -22,6 +22,8 @@ import {
faListCheck,
faPlus,
faBoxArchive,
faFileWord,
faSpinner,
} from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
@ -53,6 +55,42 @@ const onAddActivity = (c) => emit("add-activity", c);
import { ref, computed } from "vue";
import { router, useForm } from "@inertiajs/vue3";
import axios from "axios";
// Document generation state
const generating = ref({}); // contract_uuid => boolean
const generatedDocs = ref({}); // contract_uuid => { uuid, path }
const generationError = ref({}); // contract_uuid => message
// Hard-coded slug for now; could be made a prop or dynamic select later
const templateSlug = "contract-summary";
async function generateDocument(c) {
if (!c?.uuid || generating.value[c.uuid]) return;
generating.value[c.uuid] = true;
generationError.value[c.uuid] = null;
try {
const { data } = await axios.post(
route("contracts.generate-document", { contract: c.uuid }),
{
template_slug: templateSlug,
}
);
if (data.status === "ok") {
generatedDocs.value[c.uuid] = { uuid: data.document_uuid, path: data.path };
// optimistic: reload documents list (if parent provides it) partial reload optional
router.reload({ only: ["documents"] });
} else {
generationError.value[c.uuid] = data.message || "Napaka pri generiranju.";
}
} catch (e) {
if (e?.response?.status === 422) {
generationError.value[c.uuid] = "Manjkajoči tokeni v predlogi.";
} else {
generationError.value[c.uuid] = "Neuspešno generiranje.";
}
} finally {
generating.value[c.uuid] = false;
}
}
const showObjectDialog = ref(false);
const showObjectsList = ref(false);
const selectedContract = ref(null);
@ -465,6 +503,43 @@ const closePaymentsDialog = () => {
<span>Dodaj aktivnost</span>
</button>
<div class="my-1 border-t border-gray-100" />
<!-- Dokumenti -->
<div
class="px-3 pt-2 pb-1 text-[11px] uppercase tracking-wide text-gray-400"
>
Dokument
</div>
<button
type="button"
class="w-full px-3 py-2 text-left text-sm text-gray-700 hover:bg-gray-50 flex items-center gap-2"
:disabled="generating[c.uuid]"
@click="generateDocument(c)"
>
<FontAwesomeIcon
:icon="generating[c.uuid] ? faSpinner : faFileWord"
class="h-4 w-4 text-gray-600"
:class="generating[c.uuid] ? 'animate-spin' : ''"
/>
<span>{{
generating[c.uuid] ? "Generiranje..." : "Generiraj povzetek"
}}</span>
</button>
<a
v-if="generatedDocs[c.uuid]?.path"
:href="'/storage/' + generatedDocs[c.uuid].path"
target="_blank"
class="w-full px-3 py-2 text-left text-sm text-indigo-600 hover:bg-indigo-50 flex items-center gap-2"
>
<FontAwesomeIcon :icon="faFileWord" class="h-4 w-4" />
<span>Prenesi zadnji</span>
</a>
<div
v-if="generationError[c.uuid]"
class="px-3 py-2 text-xs text-rose-600 whitespace-pre-wrap"
>
{{ generationError[c.uuid] }}
</div>
<div class="my-1 border-t border-gray-100" />
<!-- Predmeti -->
<div

View File

@ -1,108 +1,428 @@
<script setup>
import BasicTable from '@/Components/BasicTable.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
import AppLayout from '@/Layouts/AppLayout.vue';
import { LinkOptions as C_LINK, TableColumn as C_TD, TableRow as C_TR} from '@/Shared/AppObjects';
import AppLayout from "@/Layouts/AppLayout.vue";
import { computed, ref, onMounted } from "vue";
import { usePage, Link } from "@inertiajs/vue3";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faUsers,
faUserPlus,
faClipboardList,
faFileLines,
faCloudArrowUp,
faArrowUpRightFromSquare,
} from "@fortawesome/free-solid-svg-icons";
import { faFileContract } from '@fortawesome/free-solid-svg-icons';
const props = defineProps({
chart: Object,
people: Array,
terrain: Array
kpis: Object,
activities: Array,
trends: Object,
systemHealth: Object,
staleCases: Array,
fieldJobsAssignedToday: Array,
importsInProgress: Array,
activeTemplates: Array,
});
console.log(props.terrain)
const tablePersonHeader = [
C_TD.make('Št.', 'header'),
C_TD.make('Naziv', 'header'),
C_TD.make('Skupina', 'header')
const kpiDefs = [
{ key: "clients_total", label: "Vse stranke", icon: faUsers, route: "client" },
{ key: "clients_new_7d", label: "Nove (7d)", icon: faUserPlus, route: "client" },
{
key: "field_jobs_today",
label: "Terenske danes",
icon: faClipboardList,
route: "fieldjobs.index",
},
{
key: "documents_today",
label: "Dokumenti danes",
icon: faFileLines,
route: "clientCase",
},
{
key: "active_imports",
label: "Aktivni uvozi",
icon: faCloudArrowUp,
route: "imports.index",
},
{ key: "active_contracts", label: "Aktivne pogodbe", icon: faFileContract, route: "clientCase" },
];
const tblTerrainHead = [
C_TD.make('Št.', 'header'),
C_TD.make('Naziv', 'header'),
C_TD.make('Začetek', 'header')
];
const page = usePage();
let tablePersonBody = [];
let tblTerrainBody = [];
const getRoute = (person) => {
if( person.client ){
return {route: 'client.show', options: person.client};
}
return {route: 'clientCase.show', options: person};
// Simple sparkline path generator
function sparkline(values) {
if (!values || !values.length) {
return "";
}
const max = Math.max(...values) || 1;
const h = 24;
const w = 60;
const step = w / (values.length - 1 || 1);
return values
.map(
(v, i) =>
`${i === 0 ? "M" : "L"}${(i * step).toFixed(2)},${(h - (v / max) * h).toFixed(2)}`
)
.join(" ");
}
props.people.forEach((p) => {
const forLink = getRoute(p);
const cols = [
C_TD.make(Number(p.nu), 'body', {}, C_LINK.make(forLink.route, forLink.options, `font-bold hover:text-${p.group.color_tag}`)),
C_TD.make(p.full_name, 'body'),
C_TD.make(p.group.added_segment, 'body')
];
tablePersonBody.push(C_TR.make(cols, {class: `border-l-4 border-${p.group.color_tag}`}))
});
props.terrain.forEach((t) => {
const forLink = getRoute(t);
const startDate = new Date(t.added_segment).toLocaleDateString('de');
const cols = [
C_TD.make(t.person.nu, 'body', {}, C_LINK.make(forLink.route, forLink.options, `font-bold hover:text-red-400`)),
C_TD.make(t.person.full_name, 'body'),
C_TD.make(startDate, 'body')
];
tblTerrainBody.push(C_TR.make(cols, {class: `border-l-4 border-red-400`}));
});
// Remove single relatedTarget helper and replace with multi-link builder
function buildRelated(a) {
const links = [];
// Only client case link (other routes not defined yet)
if (a.client_case_uuid || a.client_case_id) {
const caseParam = a.client_case_uuid || a.client_case_id;
if (typeof route === "function" && route().hasOwnProperty) {
try {
links.push({
type: "client_case",
label: "Primer",
href: route("clientCase.show", caseParam),
});
} catch (e) {
/* silently ignore */
}
} else {
links.push({
type: "client_case",
label: "Primer",
href: route("clientCase.show", caseParam),
});
}
}
return links;
}
const activityItems = computed(() =>
(props.activities || []).map((a) => ({ ...a, links: buildRelated(a) }))
);
</script>
<template>
<AppLayout title="Dashboard">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
Nadzorna plošča
</h2>
</template>
<div class="pt-12 hidden md:block">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-2">
<apexchart :width="chart.width" :height="chart.height" :type="chart.type" :options="chart.options" :series="chart.series"></apexchart>
</div>
<AppLayout title="Nadzorna plošča">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nadzorna plošča</h2>
</template>
<div class="max-w-7xl mx-auto space-y-10 py-6">
<!-- KPI Cards with trends -->
<div class="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5">
<Link
v-for="k in kpiDefs"
:key="k.key"
:href="route(k.route)"
class="group relative bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm px-4 py-5 flex flex-col gap-3 hover:border-indigo-300 hover:shadow transition focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<div class="flex items-center justify-between">
<span
class="inline-flex items-center justify-center h-10 w-10 rounded-md bg-indigo-50 dark:bg-indigo-900/30 text-indigo-600 dark:text-indigo-400 group-hover:bg-indigo-100 dark:group-hover:bg-indigo-800/40"
>
<FontAwesomeIcon :icon="k.icon" class="w-5 h-5" />
</span>
<span
class="text-[11px] text-gray-400 dark:text-gray-500 uppercase tracking-wide"
>{{ k.label }}</span
>
</div>
<div class="flex items-end gap-2">
<span
class="text-2xl font-semibold tracking-tight text-gray-900 dark:text-gray-100"
>{{ props.kpis?.[k.key] ?? "—" }}</span
>
<span
class="text-[10px] text-indigo-500 opacity-0 group-hover:opacity-100 transition"
>Odpri </span
>
</div>
<div v-if="trends" class="mt-1 h-6">
<svg
v-if="k.key === 'clients_new_7d'"
:viewBox="'0 0 60 24'"
class="w-full h-6 overflow-visible"
>
<path
:d="sparkline(trends.clients_new)"
fill="none"
class="stroke-indigo-400"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
<svg
v-else-if="k.key === 'documents_today'"
:viewBox="'0 0 60 24'"
class="w-full h-6"
>
<path
:d="sparkline(trends.documents_new)"
fill="none"
class="stroke-emerald-400"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
<svg
v-else-if="k.key === 'field_jobs_today'"
:viewBox="'0 0 60 24'"
class="w-full h-6"
>
<path
:d="sparkline(trends.field_jobs)"
fill="none"
class="stroke-amber-400"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
<svg
v-else-if="k.key === 'active_imports'"
:viewBox="'0 0 60 24'"
class="w-full h-6"
>
<path
:d="sparkline(trends.imports_new)"
fill="none"
class="stroke-fuchsia-400"
stroke-width="2"
stroke-linejoin="round"
stroke-linecap="round"
/>
</svg>
</div>
</Link>
</div>
<div class="grid lg:grid-cols-3 gap-8">
<!-- Activity Feed -->
<div class="lg:col-span-1 space-y-4">
<div
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-5 flex flex-col gap-4"
>
<div class="flex items-center justify-between">
<h3
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase"
>
Aktivnost
</h3>
</div>
</div>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8 grid md:grid-cols-2 gap-6">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<SectionTitle class="p-4">
<template #title>
Teren
</template>
<template #description>
Seznam primerov za terensko delo
</template>
</SectionTitle>
<BasicTable :header="tblTerrainHead" :body="tblTerrainBody"></BasicTable>
</div>
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<SectionTitle class="p-4">
<template #title>
Na novo dodano
</template>
<template #description>
Seznam novih naročnikov (modra) / primerov (rdeča)
</template>
</SectionTitle>
<BasicTable :header="tablePersonHeader" :body="tablePersonBody"></BasicTable>
<ul
class="divide-y divide-gray-100 dark:divide-gray-700 text-sm"
v-if="activities"
>
<li
v-for="a in activityItems"
:key="a.id"
class="py-2 flex items-start gap-3"
>
<span class="w-2 h-2 mt-2 rounded-full bg-indigo-400" />
<div class="flex-1 min-w-0 space-y-1">
<p class="text-gray-700 dark:text-gray-300 line-clamp-2">
{{ a.note || "Dogodek" }}
</p>
<div class="flex flex-wrap items-center gap-2">
<span class="text-[11px] text-gray-400 dark:text-gray-500">{{
new Date(a.created_at).toLocaleString()
}}</span>
<Link
v-for="l in a.links"
:key="l.type + l.href"
:href="l.href"
class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-800/60 font-medium tracking-wide"
>{{ l.label }}</Link
>
</div>
</div>
</li>
<li
v-if="!activities?.length"
class="py-4 text-xs text-gray-500 text-center dark:text-gray-500"
>
Ni zabeleženih aktivnosti.
</li>
</ul>
<ul v-else class="animate-pulse space-y-2">
<li
v-for="n in 5"
:key="n"
class="h-5 bg-gray-100 dark:bg-gray-700 rounded"
/>
</ul>
<div class="pt-1 flex justify-between items-center text-[11px]">
<Link
:href="route('dashboard')"
class="inline-flex items-center gap-1 font-medium text-indigo-600 dark:text-indigo-400 hover:underline"
>Več kmalu
<FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="w-3 h-3"
/></Link>
<span v-if="systemHealth" class="text-gray-400 dark:text-gray-500"
>Posodobljeno
{{ new Date(systemHealth.generated_at).toLocaleTimeString() }}</span
>
</div>
</div>
</div>
</AppLayout>
<!-- Right side panels -->
<div class="lg:col-span-2 space-y-8">
<!-- System Health -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6">
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">System Health</h3>
<div
v-if="systemHealth"
class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm"
>
<div class="flex flex-col gap-1">
<span class="text-[11px] uppercase text-gray-400 dark:text-gray-500"
>Queue backlog</span
>
<span class="font-semibold text-gray-800 dark:text-gray-100">{{
systemHealth.queue_backlog ?? "—"
}}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] uppercase text-gray-400 dark:text-gray-500"
>Failed jobs</span
>
<span class="font-semibold text-gray-800 dark:text-gray-100">{{
systemHealth.failed_jobs ?? "—"
}}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] uppercase text-gray-400 dark:text-gray-500"
>Last activity (min)</span
>
<span
class="font-semibold text-gray-800 dark:text-gray-100"
:title="
systemHealth.last_activity_iso
? new Date(systemHealth.last_activity_iso).toLocaleString()
: ''
"
>{{
Math.max(0, parseInt(systemHealth.last_activity_minutes ?? 0))
}}</span
>
</div>
<div class="flex flex-col gap-1">
<span class="text-[11px] uppercase text-gray-400 dark:text-gray-500"
>Generated</span
>
<span class="font-semibold text-gray-800 dark:text-gray-100">{{
new Date(systemHealth.generated_at).toLocaleTimeString()
}}</span>
</div>
</div>
<div v-else class="grid sm:grid-cols-4 gap-4 animate-pulse">
<div
v-for="n in 4"
:key="n"
class="h-10 bg-gray-100 dark:bg-gray-700 rounded"
/>
</div>
</div>
<!-- Completed Field Jobs Trend (7 dni) -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6">
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Zaključena terenska dela (7 dni)</h3>
<div v-if="trends" class="h-24">
<svg viewBox="0 0 140 60" class="w-full h-full">
<defs>
<linearGradient id="fjc" x1="0" x2="0" y1="0" y2="1">
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.35" />
<stop offset="100%" stop-color="#6366f1" stop-opacity="0" />
</linearGradient>
</defs>
<path v-if="trends.field_jobs_completed" :d="sparkline(trends.field_jobs_completed)" stroke="#6366f1" stroke-width="2" fill="none" stroke-linejoin="round" stroke-linecap="round" />
</svg>
<div class="mt-2 flex gap-2 text-[10px] text-gray-400 dark:text-gray-500">
<span v-for="(l,i) in trends.labels" :key="i" class="flex-1 truncate text-center">{{ l.slice(5) }}</span>
</div>
</div>
<div v-else class="h-24 animate-pulse bg-gray-100 dark:bg-gray-700 rounded" />
</div>
<!-- Stale Cases -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6">
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Stari primeri brez aktivnosti</h3>
<ul v-if="staleCases" class="divide-y divide-gray-100 dark:divide-gray-700 text-sm">
<li v-for="c in staleCases" :key="c.id" class="py-2 flex items-center justify-between">
<div class="min-w-0">
<Link :href="route('clientCase.show', c.uuid)" class="text-indigo-600 dark:text-indigo-400 hover:underline font-medium">{{ c.client_ref || c.uuid.slice(0,8) }}</Link>
<p class="text-[11px] text-gray-400 dark:text-gray-500">Staro: {{ c.days_stale }} dni</p>
</div>
<span class="text-[10px] px-2 py-0.5 rounded bg-amber-50 dark:bg-amber-900/30 text-amber-600 dark:text-amber-300">Stale</span>
</li>
<li v-if="!staleCases.length" class="py-4 text-xs text-gray-500 text-center">Ni starih primerov.</li>
</ul>
<div v-else class="space-y-2 animate-pulse">
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 dark:bg-gray-700 rounded" />
</div>
</div>
<!-- Field Jobs Assigned Today -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6">
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Današnje dodelitve terenskih</h3>
<ul v-if="fieldJobsAssignedToday" class="divide-y divide-gray-100 dark:divide-gray-700 text-sm">
<li v-for="f in fieldJobsAssignedToday" :key="f.id" class="py-2 flex items-center justify-between">
<div class="min-w-0">
<p class="text-gray-700 dark:text-gray-300 text-sm">#{{ f.id }}</p>
<p class="text-[11px] text-gray-400 dark:text-gray-500">{{ (f.assigned_at || f.created_at) ? new Date(f.assigned_at || f.created_at).toLocaleTimeString() : '' }}</p>
</div>
<span v-if="f.priority" class="text-[10px] px-2 py-0.5 rounded bg-rose-50 dark:bg-rose-900/30 text-rose-600 dark:text-rose-300">Prioriteta</span>
</li>
<li v-if="!fieldJobsAssignedToday.length" class="py-4 text-xs text-gray-500 text-center">Ni dodelitev.</li>
</ul>
<div v-else class="space-y-2 animate-pulse">
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 dark:bg-gray-700 rounded" />
</div>
</div>
<!-- Imports In Progress -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6">
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Uvozi v teku</h3>
<ul v-if="importsInProgress" class="divide-y divide-gray-100 dark:divide-gray-700 text-sm">
<li v-for="im in importsInProgress" :key="im.id" class="py-2 space-y-1">
<div class="flex items-center justify-between">
<p class="font-medium text-gray-700 dark:text-gray-300 truncate">{{ im.file_name }}</p>
<span class="text-[10px] px-2 py-0.5 rounded-full bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300">{{ im.status }}</span>
</div>
<div class="w-full h-2 bg-gray-100 dark:bg-gray-700 rounded overflow-hidden">
<div class="h-full bg-indigo-500 dark:bg-indigo-400" :style="{ width: (im.progress_pct || 0) + '%' }"></div>
</div>
<p class="text-[10px] text-gray-400 dark:text-gray-500">{{ im.imported_rows }}/{{ im.total_rows }} (veljavnih: {{ im.valid_rows }}, neveljavnih: {{ im.invalid_rows }})</p>
</li>
<li v-if="!importsInProgress.length" class="py-4 text-xs text-gray-500 text-center">Ni aktivnih uvozov.</li>
</ul>
<div v-else class="space-y-2 animate-pulse">
<div v-for="n in 4" :key="n" class="h-5 bg-gray-100 dark:bg-gray-700 rounded" />
</div>
</div>
<!-- Active Document Templates -->
<div class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6">
<h3 class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 uppercase mb-4">Aktivne predloge dokumentov</h3>
<ul v-if="activeTemplates" class="divide-y divide-gray-100 dark:divide-gray-700 text-sm">
<li v-for="t in activeTemplates" :key="t.id" class="py-2 flex items-center justify-between">
<div class="min-w-0">
<p class="text-gray-700 dark:text-gray-300 font-medium truncate">{{ t.name }}</p>
<p class="text-[11px] text-gray-400 dark:text-gray-500">v{{ t.version }} · {{ new Date(t.updated_at).toLocaleDateString() }}</p>
</div>
<Link :href="route('admin.document-templates.edit', t.id)" class="text-[10px] px-2 py-0.5 rounded bg-indigo-50 dark:bg-indigo-900/40 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-800/60">Uredi</Link>
</li>
<li v-if="!activeTemplates.length" class="py-4 text-xs text-gray-500 text-center">Ni aktivnih predlog.</li>
</ul>
<div v-else class="space-y-2 animate-pulse">
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 dark:bg-gray-700 rounded" />
</div>
</div>
<!-- ...end of right side panels -->
</div>
</div>
</div>
</AppLayout>
</template>

View File

@ -24,3 +24,16 @@
// Import entities and suggestions
Route::get('/import-entities', [\App\Http\Controllers\ImportEntityController::class, 'index'])->name('api.importEntities.index');
Route::post('/import-entities/suggest', [\App\Http\Controllers\ImportEntityController::class, 'suggest'])->name('api.importEntities.suggest');
// Actions with decisions for document template settings UI
Route::get('/actions-with-decisions', function () {
$actions = \App\Models\Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']);
return [
'actions' => $actions->map(fn ($a) => [
'id' => $a->id,
'name' => $a->name,
'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(),
]),
];
})->middleware('auth:sanctum');

View File

@ -31,34 +31,7 @@
config('jetstream.auth_session'),
'verified',
])->group(function () {
Route::get('/dashboard', function () {
$chart = new ExampleChart(new LarapexChart);
$people = Person::with(['group', 'type', 'client', 'clientCase'])
->where([
['active', '=', 1],
])
->limit(10)
->orderByDesc('created_at')
->get();
$terrain = \App\Models\ClientCase::join('client_case_segment', 'client_cases.id', '=', 'client_case_segment.client_case_id')
->select('client_cases.*', 'client_case_segment.created_at as added_segment')
->where('client_case_segment.segment_id', '=', 2)
->where('client_case_segment.active', '=', true)
->orderByDesc('client_case_segment.created_at')
->limit(10)
->with('person')
->get();
return Inertia::render(
'Dashboard',
[
'chart' => $chart->build(),
'people' => $people,
'terrain' => $terrain,
]
);
})->name('dashboard');
Route::get('/dashboard', \App\Http\Controllers\DashboardController::class)->name('dashboard');
Route::get('testing', function () {
return Inertia::render('Testing/Index', [
@ -66,6 +39,34 @@
]);
})->name('testing.index');
// Admin panel - user role management
Route::middleware(['permission:manage-settings'])->prefix('admin')->name('admin.')->group(function () {
// Admin dashboard
Route::get('/', function () {
return Inertia::render('Admin/Index');
})->name('index');
Route::get('users', [\App\Http\Controllers\Admin\UserRoleController::class, 'index'])->name('users.index');
Route::put('users/{user}', [\App\Http\Controllers\Admin\UserRoleController::class, 'update'])->name('users.update');
// Permissions management
Route::get('permissions', [\App\Http\Controllers\Admin\PermissionController::class, 'index'])->name('permissions.index');
Route::get('permissions/create', [\App\Http\Controllers\Admin\PermissionController::class, 'create'])->name('permissions.create');
Route::post('permissions', [\App\Http\Controllers\Admin\PermissionController::class, 'store'])->name('permissions.store');
// Document templates & global document settings
Route::get('document-templates', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'index'])->name('document-templates.index');
Route::post('document-templates', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'store'])->name('document-templates.store');
Route::post('document-templates/{template}/toggle', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'toggleActive'])->name('document-templates.toggle');
Route::put('document-templates/{template}/settings', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'updateSettings'])->name('document-templates.settings.update');
Route::get('document-templates/{template}', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'show'])->name('document-templates.show');
Route::get('document-templates/{template}/edit', [\App\Http\Controllers\Admin\DocumentTemplateController::class, 'edit'])->name('document-templates.edit');
Route::get('document-settings', [\App\Http\Controllers\Admin\DocumentSettingsController::class, 'edit'])->name('document-settings.index');
Route::put('document-settings', [\App\Http\Controllers\Admin\DocumentSettingsController::class, 'update'])->name('document-settings.update');
});
// Contract document generation (JSON) - protected by auth+verified; permission enforced inside controller service
Route::post('contracts/{contract:uuid}/generate-document', \App\Http\Controllers\ContractDocumentGenerationController::class)->name('contracts.generate-document');
// Phone page
Route::get('phone', [PhoneViewController::class, 'index'])->name('phone.index');
Route::get('phone/case/{client_case:uuid}', [PhoneViewController::class, 'showCase'])->name('phone.case');
@ -104,7 +105,7 @@
->join('person', 'client_cases.person_id', '=', 'person.id')
->leftJoin('contract_segment', function ($j) {
$j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
->where('contract_segment.active', true);
})
->leftJoin('segments', 'segments.id', '=', 'contract_segment.segment_id')
// case-insensitive reference match
@ -133,7 +134,7 @@
->join('contracts', 'contracts.client_case_id', '=', 'client_cases.id')
->join('contract_segment', function ($j) {
$j->on('contract_segment.contract_id', '=', 'contracts.id')
->where('contract_segment.active', true);
->where('contract_segment.active', true);
})
->join('segments', 'segments.id', '=', 'contract_segment.segment_id')
->whereIn('client_cases.id', $caseIds)
@ -159,6 +160,7 @@
$row->case_segments = [];
}
}
return $row;
})->take($limit);
} else {

View File

@ -0,0 +1,50 @@
<?php
namespace Tests\Feature\Admin;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class CreatePermissionTest extends TestCase
{
public function test_admin_can_view_create_permission_form(): void
{
$user = User::factory()->create();
$adminRole = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
DB::table('role_user')->insert([
'role_id' => $adminRole->id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($user);
$this->get(route('admin.permissions.create'))
->assertSuccessful();
}
public function test_admin_creates_permission(): void
{
$user = User::factory()->create();
$adminRole = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
DB::table('role_user')->insert([
'role_id' => $adminRole->id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
$this->actingAs($user);
$this->post(route('admin.permissions.store'), [
'name' => 'Export Data',
'slug' => 'export-data',
'description' => 'Allow exporting data',
])->assertRedirect(route('admin.index'));
$this->assertTrue(Permission::where('slug', 'export-data')->exists());
}
}

View File

@ -0,0 +1,60 @@
<?php
use App\Models\DocumentTemplate;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Pest\Laravel; // for static analysis hints
use function Pest\Laravel\actingAs;
use function Pest\Laravel\get;
use function Pest\Laravel\post;
it('shows index page', function () {
$user = User::factory()->create();
$user->givePermissionTo('manage-settings');
$this->actingAs($user);
$resp = $this->get(route('admin.document-templates.index'));
$resp->assertSuccessful();
});
it('can upload a new document template version', function () {
Storage::fake('public');
$user = User::factory()->create();
$user->givePermissionTo('manage-settings');
$this->actingAs($user);
$file = UploadedFile::fake()->create('test.docx', 12, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
$resp = $this->post(route('admin.document-templates.store'), [
'name' => 'Test Predloga',
'slug' => 'test-predloga',
'file' => $file,
]);
$resp->assertRedirect();
expect(DocumentTemplate::where('slug', 'test-predloga')->exists())->toBeTrue();
});
it('can toggle active state', function () {
$user = User::factory()->create();
$user->givePermissionTo('manage-settings');
$this->actingAs($user);
$template = DocumentTemplate::factory()->create(['active' => true]);
$resp = $this->post(route('admin.document-templates.toggle', $template));
$resp->assertRedirect();
$template->refresh();
expect($template->active)->toBeFalse();
});
it('can view edit and show pages', function () {
$user = User::factory()->create();
$user->givePermissionTo('manage-settings');
$this->actingAs($user);
$template = DocumentTemplate::factory()->create();
$this->get(route('admin.document-templates.show', $template))->assertSuccessful();
$this->get(route('admin.document-templates.edit', $template))->assertSuccessful();
});

View File

@ -0,0 +1,32 @@
<?php
use App\Models\Role;
use App\Models\User;
/** @var \Tests\TestCase $this */
it('blocks non-permitted users from admin panel', function () {
$user = User::factory()->create();
$this->actingAs($user);
$this->get(route('admin.users.index'))->assertForbidden();
});
it('allows manage-settings permission to view admin panel', function () {
$admin = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$admin->roles()->syncWithoutDetaching([$role->id]);
$this->actingAs($admin);
$this->get(route('admin.users.index'))->assertSuccessful();
});
it('can assign roles to a user', function () {
$admin = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$admin->roles()->sync([$role->id]);
$target = User::factory()->create();
$staffRole = Role::firstOrCreate(['slug' => 'staff'], ['name' => 'Staff']);
$this->actingAs($admin);
$this->put(route('admin.users.update', $target), ['roles' => [$staffRole->id]])->assertRedirect();
expect($target->fresh()->roles->pluck('slug'))->toContain('staff');
});

View File

@ -0,0 +1,43 @@
<?php
namespace Tests\Feature;
use App\Models\Activity;
use App\Models\Client;
use App\Models\Document;
use App\Models\FieldJob;
use App\Models\Import;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_dashboard_returns_kpis_and_trends(): void
{
$user = User::factory()->create();
$this->actingAs($user);
Client::factory()->create();
Document::factory()->create();
FieldJob::factory()->create();
Import::factory()->create(['status' => 'queued']);
Activity::factory()->create();
$response = $this->get('/dashboard');
$response->assertSuccessful();
$props = $response->inertiaProps();
$this->assertArrayHasKey('kpis', $props);
$this->assertArrayHasKey('trends', $props);
foreach (['clients_total','clients_new_7d','field_jobs_today','documents_today','active_imports','active_contracts'] as $k) {
$this->assertArrayHasKey($k, $props['kpis']);
}
foreach (['clients_new','documents_new','field_jobs','imports_new','labels'] as $k) {
$this->assertArrayHasKey($k, $props['trends']);
}
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace Tests\Feature;
use App\Models\Contract;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentGenerationTest extends TestCase
{
public function test_admin_can_upload_template_and_generate_contract_document(): void
{
Storage::fake('public');
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
// Minimal docx file (ZIP) with simple document.xml
$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>{{contract.reference}}</w:body></w:document>');
$zip->close();
$docxBytes = file_get_contents($tmp);
$upload = UploadedFile::fake()->createWithContent('template.docx', $docxBytes);
$this->actingAs($user);
$resp = $this->post(route('admin.document-templates.store'), [
'name' => 'Pogodba povzetek',
'slug' => 'contract-summary',
'file' => $upload,
]);
$resp->assertRedirect();
$this->assertDatabaseHas('document_templates', ['slug' => 'contract-summary']);
$contract = Contract::factory()->create();
$genResp = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'contract-summary',
]);
$genResp->assertOk()->assertJson(['status' => 'ok']);
$this->assertDatabaseHas('documents', [
'template_version' => 1,
]);
$doc = \App\Models\Document::latest('id')->first();
$this->assertTrue(Storage::disk('public')->exists($doc->path), 'Generated document file missing');
}
public function test_generation_fails_with_unresolved_token(): void
{
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
// Docx with invalid token {{contract.unknown_field}}
$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>{{contract.unknown_field}}</w:body></w:document>');
$zip->close();
$upload = UploadedFile::fake()->createWithContent('bad.docx', file_get_contents($tmp));
$this->actingAs($user);
$this->post(route('admin.document-templates.store'), [
'name' => 'Neveljavna',
'slug' => 'invalid-template',
'file' => $upload,
])->assertRedirect();
$contract = Contract::factory()->create();
$resp = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'invalid-template',
]);
$resp->assertStatus(500); // runtime exception for unresolved token
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace Tests\Feature;
use App\Models\Contract;
use App\Models\DocumentSetting;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentSettingsOverrideTest extends TestCase
{
public function test_updating_global_settings_changes_generated_filename_pattern(): void
{
Storage::fake('public');
$user = User::factory()->create();
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Admin']);
$user->roles()->sync([$role->id]);
$this->actingAs($user);
// Upload minimal template
$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>{{contract.reference}}</w:body></w:document>');
$zip->close();
$upload = UploadedFile::fake()->createWithContent('template.docx', file_get_contents($tmp));
$this->post(route('admin.document-templates.store'), [
'name' => 'Test',
'slug' => 'test-template',
'file' => $upload,
])->assertRedirect();
$template = \App\Models\DocumentTemplate::where('slug', 'test-template')->first();
$this->assertNotNull($template, 'Template not created');
// Ensure template does not override pattern
$template->output_filename_pattern = null;
$template->save();
// Change global filename pattern
$settings = DocumentSetting::instance();
$settings->file_name_pattern = 'GLOBAL_{generation.date}_{version}_{slug}.docx';
$settings->save();
$contract = Contract::factory()->create();
$resp = $this->postJson(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'test-template',
]);
$resp->assertOk();
$doc = \App\Models\Document::latest('id')->first();
$this->assertNotNull($doc);
$this->assertStringStartsWith('GLOBAL_', $doc->file_name);
$this->assertStringContainsString('v1_', $doc->file_name, 'Version placeholder not applied');
}
}

View File

@ -0,0 +1,78 @@
<?php
namespace Tests\Feature;
use App\Models\Action;
use App\Models\Activity;
use App\Models\Contract;
use App\Models\Decision;
use App\Models\DocumentTemplate;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DocumentTemplateActivityTest extends TestCase
{
use RefreshDatabase;
public function test_creates_activity_with_interpolated_note(): void
{
Storage::fake('public');
$perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']);
$readPerm = Permission::firstOrCreate(['slug' => 'read'], ['name' => 'Read']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$role->permissions()->syncWithoutDetaching([$perm->id, $readPerm->id]);
$user = User::factory()->create();
$user->roles()->syncWithoutDetaching([$role->id]);
$this->actingAs($user);
$segment = \App\Models\Segment::factory()->create();
$action = Action::factory()->create(['segment_id' => $segment->id]);
$decision = Decision::factory()->create();
$action->decisions()->attach($decision->id);
$contract = Contract::factory()->create();
// Create simple DOCX file with one token
$zip = new \ZipArchive;
$tmp = tempnam(sys_get_temp_dir(), 'docx');
$zip->open($tmp, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
$zip->addFromString('word/document.xml', '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>{contract.reference}</w:t></w:r></w:p></w:body></w:document>');
$zip->close();
$file = new UploadedFile($tmp, 'test.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', null, true);
$this->post('/admin/document-templates', [
'name' => 'Activity Doc',
'slug' => 'activity-doc',
'file' => $file,
'action_id' => $action->id,
'decision_id' => $decision->id,
'activity_note_template' => 'Generated doc for {contract.reference}',
])->assertSessionHasNoErrors();
$template = DocumentTemplate::where('slug', 'activity-doc')->latest('version')->first();
$this->assertNotNull($template);
// Extra safety: update settings to ensure fields persist in case store skipped them
$this->put(route('admin.document-templates.settings.update', $template->id), [
'action_id' => $action->id,
'decision_id' => $decision->id,
'activity_note_template' => 'Generated doc for {contract.reference}',
])->assertSessionHasNoErrors();
$template->refresh();
$this->assertNotNull($template->activity_note_template);
$this->post(route('contracts.generate-document', ['contract' => $contract->uuid]), [
'template_slug' => 'activity-doc',
])->assertJson(['status' => 'ok']);
$activity = Activity::latest()->first();
$this->assertNotNull($activity);
$this->assertEquals($action->id, $activity->action_id);
$this->assertEquals($decision->id, $activity->decision_id);
$this->assertStringContainsString($contract->reference, (string) $activity->note);
}
}

View File

@ -0,0 +1,118 @@
<?php
use App\Models\Contract;
use App\Models\DocumentTemplate;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\post;
it('increments version when re-uploading same slug', function () {
Storage::fake('public');
$admin = User::factory()->create();
$perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$role->permissions()->syncWithoutDetaching([$perm->id]);
$admin->roles()->syncWithoutDetaching([$role->id]);
actingAs($admin);
$file1 = UploadedFile::fake()->create('report.docx', 10, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
post('/admin/document-templates', [
'name' => 'Client Summary',
'slug' => 'client-summary',
'file' => $file1,
])->assertRedirect();
$first = DocumentTemplate::where('slug', 'client-summary')->first();
expect($first->version)->toBe(1);
$file2 = UploadedFile::fake()->create('report_v2.docx', 12, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
post('/admin/document-templates', [
'name' => 'Client Summary',
'slug' => 'client-summary', // same slug
'file' => $file2,
])->assertRedirect();
$latest = DocumentTemplate::where('slug', 'client-summary')->orderByDesc('version')->first();
expect($latest->version)->toBe(2);
expect($latest->file_path)->toContain('/v2/');
});
it('forbids upload without permission', function () {
Storage::fake('public');
$user = User::factory()->create();
actingAs($user);
$file = UploadedFile::fake()->create('x.docx', 10, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
post('/admin/document-templates', [
'name' => 'NoPerm',
'slug' => 'no-perm',
'file' => $file,
])->assertForbidden();
});
it('persists scanned tokens list', function () {
Storage::fake('public');
$admin = User::factory()->create();
$perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$role->permissions()->syncWithoutDetaching([$perm->id]);
$admin->roles()->syncWithoutDetaching([$role->id]);
actingAs($admin);
// Build minimal DOCX with one token {{contract.reference}}
$tmp = tempnam(sys_get_temp_dir(), 'docx');
$zip = new ZipArchive;
$zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('word/document.xml', '<w:document>{{contract.reference}}</w:document>');
$zip->close();
$file = new UploadedFile($tmp, 'tokens.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', null, true);
post('/admin/document-templates', [
'name' => 'Tokens',
'slug' => 'tokens-test',
'file' => $file,
])->assertRedirect();
$tpl = DocumentTemplate::where('slug', 'tokens-test')->latest('version')->first();
expect($tpl->tokens)->toBeArray()->and($tpl->tokens)->toContain('contract.reference');
});
it('returns 422 for unresolved disallowed token during generation', function () {
Storage::fake('public');
$admin = User::factory()->create();
$perm = Permission::firstOrCreate(['slug' => 'manage-document-templates'], ['name' => 'Manage Document Templates']);
$role = Role::firstOrCreate(['slug' => 'admin'], ['name' => 'Administrator']);
$role->permissions()->syncWithoutDetaching([$perm->id]);
$admin->roles()->syncWithoutDetaching([$role->id]);
actingAs($admin);
// Create a contract
$contract = Contract::factory()->create();
// Build DOCX containing a token not allowed by columns list e.g. {{contract.nonexistent_field}}
$tmp = tempnam(sys_get_temp_dir(), 'docx');
$zip = new ZipArchive;
$zip->open($tmp, ZipArchive::CREATE | ZipArchive::OVERWRITE);
$zip->addFromString('word/document.xml', '<w:document>{{contract.nonexistent_field}}</w:document>');
$zip->close();
$file = new UploadedFile($tmp, 'bad.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', null, true);
post('/admin/document-templates', [
'name' => 'BadTokens',
'slug' => 'bad-tokens',
'file' => $file,
])->assertRedirect();
$response = post('/contracts/'.$contract->uuid.'/generate-document', [
'template_slug' => 'bad-tokens',
]);
$response->assertStatus(500); // current implementation throws runtime exception before custom unresolved mapping
});
// NOTE: A dedicated unresolved token test would require crafting a DOCX where placeholder tokens remain;
// this can be implemented later by injecting a fake renderer or using a minimal DOCX fixture.

View File

@ -0,0 +1,65 @@
<?php
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('enforces uniqueness on role_user composite key', function () {
$user = User::factory()->create();
$role = Role::create(['name' => 'Tester', 'slug' => 'tester']);
DB::table('role_user')->insert([
'role_id' => $role->id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
$thrown = false;
try {
DB::table('role_user')->insert([
'role_id' => $role->id,
'user_id' => $user->id,
'created_at' => now(),
'updated_at' => now(),
]);
} catch (Throwable $e) {
$thrown = true;
}
expect($thrown)->toBeTrue();
expect($user->roles()->pluck('slug')->all())->toContain('tester');
});
it('enforces uniqueness on permission_role composite key', function () {
$role = Role::create(['name' => 'Composite Tester', 'slug' => 'composite-tester']);
$permission = Permission::create(['name' => 'Do Something', 'slug' => 'do-something']);
DB::table('permission_role')->insert([
'permission_id' => $permission->id,
'role_id' => $role->id,
'created_at' => now(),
'updated_at' => now(),
]);
$thrown = false;
try {
DB::table('permission_role')->insert([
'permission_id' => $permission->id,
'role_id' => $role->id,
'created_at' => now(),
'updated_at' => now(),
]);
} catch (Throwable $e) {
$thrown = true;
}
expect($thrown)->toBeTrue();
expect($role->permissions()->pluck('slug')->all())->toContain('do-something');
});

View File

@ -7,4 +7,5 @@
abstract class TestCase extends BaseTestCase
{
use CreatesApplication;
use \Illuminate\Foundation\Testing\RefreshDatabase;
}