Added the support for generating docs from template doc
This commit is contained in:
Binary file not shown.
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user