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
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
+76
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>
@@ -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>
@@ -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>
+269
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>