Mager updated

This commit is contained in:
Simon Pocrnjič
2025-09-27 17:45:55 +02:00
parent d17e34941b
commit 7227c888d4
74 changed files with 6339 additions and 342 deletions
@@ -1,6 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import Drawer from './Drawer.vue';
import DialogModal from './DialogModal.vue';
import InputLabel from './InputLabel.vue';
import SectionTitle from './SectionTitle.vue';
import TextInput from './TextInput.vue';
@@ -133,7 +133,7 @@ const callSubmit = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@@ -193,5 +193,5 @@ const callSubmit = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
+44 -35
View File
@@ -1,6 +1,6 @@
<script setup>
import { FwbButton, FwbModal, FwbTable, FwbTableBody, FwbTableCell, FwbTableHead, FwbTableHeadCell, FwbTableRow } from 'flowbite-vue';
import Drawer from './Drawer.vue';
import { FwbButton, FwbTable, FwbTableBody, FwbTableCell, FwbTableHead, FwbTableHeadCell, FwbTableRow } from 'flowbite-vue';
import DialogModal from './DialogModal.vue';
import { useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import TextInput from './TextInput.vue';
@@ -63,7 +63,6 @@ const update = () => {
onSuccess: () => {
closeEditor();
formUpdate.reset();
console.log('ssss')
},
preserveScroll: true
});
@@ -99,38 +98,48 @@ const remove = () => {
</script>
<template>
<div class="relative overflow-x-auto">
<FwbTable hoverable>
<FwbTableHead>
<FwbTableHeadCell v-for="h in header">{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor"></FwbTableHeadCell>
<FwbTableHeadCell v-else />
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="(row, key, parent_index) in body" :class="row.options.class" >
<FwbTableCell v-for="col in row.cols">
<a v-if="col.link !== undefined" :class="col.link.css" :href="route(col.link.route, col.link.options)">{{ col.data }}</a>
<span v-else>{{ col.data }}</span>
</FwbTableCell>
<FwbTableCell v-if="editor">
<fwb-button class="mr-1" size="sm" color="default" @click="openEditor(row.options.ref, row.options.editable)" outline>Edit</fwb-button>
<fwb-button size="sm" color="red" @click="showModal(row.options.ref.val, row.options.title)" outline>Remove</fwb-button>
</FwbTableCell>
<FwbTableCell v-else />
</FwbTableRow>
</FwbTableBody>
</FwbTable>
<div>
<!-- Header -->
<div v-if="title || description" class="mb-4">
<h2 v-if="title" class="text-lg font-semibold text-gray-900">{{ title }}</h2>
<p v-if="description" class="mt-1 text-sm text-gray-600">{{ description }}</p>
</div>
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur supports-[backdrop-filter]:bg-gray-50/80 border-b border-gray-200 shadow-sm">
<FwbTableHeadCell v-for="(h, hIndex) in header" :key="hIndex" class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3 first:pl-6 last:pr-6">{{ h.data }}</FwbTableHeadCell>
<FwbTableHeadCell v-if="editor" class="w-px text-gray-700 py-3"></FwbTableHeadCell>
<FwbTableHeadCell v-else class="w-px text-gray-700 py-3" />
</FwbTableHead>
<FwbTableBody>
<FwbTableRow v-for="(row, key, parent_index) in body" :key="key" :class="row.options.class">
<FwbTableCell v-for="(col, colIndex) in row.cols" :key="colIndex" class="align-middle">
<a v-if="col.link !== undefined" :class="col.link.css" :href="route(col.link.route, col.link.options)">{{ col.data }}</a>
<span v-else>{{ col.data }}</span>
</FwbTableCell>
<FwbTableCell v-if="editor" class="text-right whitespace-nowrap">
<fwb-button class="mr-2" size="sm" color="default" @click="openEditor(row.options.ref, row.options.editable)" outline>Edit</fwb-button>
<fwb-button size="sm" color="red" @click="showModal(row.options.ref.val, row.options.title)" outline>Remove</fwb-button>
</FwbTableCell>
<FwbTableCell v-else />
</FwbTableRow>
</FwbTableBody>
</FwbTable>
<div v-if="!body || body.length === 0" class="p-6 text-center text-sm text-gray-500">No records found.</div>
</div>
</div>
<Drawer
<DialogModal
v-if="editor"
:show="drawerUpdateForm"
@close="drawerUpdateForm = false"
maxWidth="xl"
>
<template #title>Update {{ options.editor_data.title }}</template>
<template #content>
<form @submit.prevent="update">
<div v-for="e in options.editor_data.form.el" class="col-span-6 sm:col-span-4 mb-4">
<form @submit.prevent="update" class="pt-2">
<div v-for="(e, eIndex) in options.editor_data.form.el" :key="eIndex" class="col-span-6 sm:col-span-4 mb-4">
<InputLabel :for="e.id" :value="e.label"/>
<TextInput
v-if="e.type === 'text'"
@@ -138,20 +147,20 @@ const remove = () => {
:ref="e.ref"
type="text"
:autocomplete="e.autocomplete"
class="mt-1 block w-full"
class="mt-1 block w-full text-sm"
v-model="formUpdate[e.bind]"
/>
<select
v-else-if="e.type === 'select'"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm"
class="block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm text-sm"
:id="e.id"
:ref="e.ref"
v-model="formUpdate[e.bind]"
>
<option v-for="op in e.selectOptions" :value="op.val">{{ op.desc }}</option>
<option v-for="(op, opIndex) in e.selectOptions" :key="opIndex" :value="op.val">{{ op.desc }}</option>
</select>
</div>
<div class="flex justify-end mt-4">
<div class="flex justify-end mt-6 gap-3">
<ActionMessage :on="formUpdate.recentlySuccessful" class="me-3">
Saved.
</ActionMessage>
@@ -161,7 +170,7 @@ const remove = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
<Modal
v-if="editor"
@@ -170,12 +179,12 @@ const remove = () => {
maxWidth="sm"
>
<form @submit.prevent="remove">
<div class="p-3">
<div class="text-lg text-center py-2 mb-4">
<div class="p-6">
<div class="text-base font-medium text-center py-2 mb-4 text-gray-900">
Remove {{ options.editor_data.title }} <b>{{ modalRemoveTitle }}</b>?
</div>
<div class="flex justify-between">
<div class="flex justify-between items-center">
<SecondaryButton type="button" @click="closeModal">
Cancel
</SecondaryButton>
@@ -0,0 +1,116 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
import PrimaryButton from '@/Components/PrimaryButton.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
import ActionMessage from '@/Components/ActionMessage.vue'
import InputLabel from '@/Components/InputLabel.vue'
import TextInput from '@/Components/TextInput.vue'
import { useForm } from '@inertiajs/vue3'
import { ref, watch } from 'vue'
const props = defineProps({
show: { type: Boolean, default: false },
postUrl: { type: String, required: true },
})
const emit = defineEmits(['close', 'uploaded'])
const MAX_SIZE = 25 * 1024 * 1024 // 25MB
const ALLOWED_EXTS = ['doc','docx','pdf','txt','csv','xls','xlsx','jpeg','jpg','png']
const form = useForm({
name: '',
description: '',
file: null,
is_public: false,
})
const localError = ref('')
watch(() => props.show, (v) => {
if (!v) return
localError.value = ''
})
const onFileChange = (e) => {
localError.value = ''
const f = e.target.files?.[0]
if (!f) { form.file = null; return }
const ext = (f.name.split('.').pop() || '').toLowerCase()
if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Unsupported file type. Allowed: ' + ALLOWED_EXTS.join(', ')
e.target.value = ''
form.file = null
return
}
if (f.size > MAX_SIZE) {
localError.value = 'File is too large. Maximum size is 25MB.'
e.target.value = ''
form.file = null
return
}
form.file = f
if (!form.name) {
form.name = f.name.replace(/\.[^.]+$/, '')
}
}
const submit = () => {
localError.value = ''
if (!form.file) {
localError.value = 'Please choose a file.'
return
}
const ext = (form.file.name.split('.').pop() || '').toLowerCase()
if (!ALLOWED_EXTS.includes(ext)) {
localError.value = 'Unsupported file type. Allowed: ' + ALLOWED_EXTS.join(', ')
return
}
if (form.file.size > MAX_SIZE) {
localError.value = 'File is too large. Maximum size is 25MB.'
return
}
form.post(props.postUrl, {
forceFormData: true,
onSuccess: () => {
emit('uploaded')
close()
form.reset()
},
})
}
const close = () => emit('close')
</script>
<template>
<DialogModal :show="props.show" @close="close" maxWidth="lg">
<template #title>Dodaj dokument</template>
<template #content>
<div class="space-y-4">
<div>
<InputLabel for="doc_name" value="Name" />
<TextInput id="doc_name" class="mt-1 block w-full" v-model="form.name" />
</div>
<div>
<InputLabel for="doc_desc" value="Description" />
<textarea id="doc_desc" class="mt-1 block w-full border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 rounded-md shadow-sm" rows="3" v-model="form.description"></textarea>
</div>
<div>
<InputLabel for="doc_file" value="File (max 25MB)" />
<input id="doc_file" type="file" class="mt-1 block w-full" @change="onFileChange" accept=".doc,.docx,.pdf,.txt,.csv,.xls,.xlsx,.jpeg,.jpg,.png" />
<div v-if="localError" class="text-sm text-red-600 mt-1">{{ localError }}</div>
</div>
<label class="inline-flex items-center gap-2 text-sm">
<input type="checkbox" v-model="form.is_public" class="rounded" />
Public
</label>
</div>
</template>
<template #footer>
<div class="flex items-center gap-3">
<ActionMessage :on="form.recentlySuccessful">Uploaded.</ActionMessage>
<SecondaryButton type="button" @click="close">Cancel</SecondaryButton>
<PrimaryButton :disabled="form.processing" @click="submit">Upload</PrimaryButton>
</div>
</template>
</DialogModal>
</template>
@@ -0,0 +1,26 @@
<script setup>
import DialogModal from '@/Components/DialogModal.vue'
import SecondaryButton from '@/Components/SecondaryButton.vue'
const props = defineProps({
show: { type: Boolean, default: false },
src: { type: String, default: '' },
title: { type: String, default: 'Document' }
})
const emit = defineEmits(['close'])
</script>
<template>
<DialogModal :show="props.show" @close="$emit('close')" maxWidth="4xl">
<template #title>{{ props.title }}</template>
<template #content>
<div class="h-[70vh]">
<iframe v-if="props.src" :src="props.src" class="w-full h-full rounded border" />
<div v-else class="text-sm text-gray-500">No document to display.</div>
</div>
</template>
<template #footer>
<SecondaryButton type="button" @click="$emit('close')">Close</SecondaryButton>
</template>
</DialogModal>
</template>
+139
View File
@@ -0,0 +1,139 @@
<script setup>
import { FwbTable, FwbTableHead, FwbTableHeadCell, FwbTableBody, FwbTableRow, FwbTableCell, FwbBadge } from 'flowbite-vue'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faFilePdf, faFileWord, faFileExcel, faFileLines, faFileImage, faFile, faCircleInfo } from '@fortawesome/free-solid-svg-icons'
import { ref } from 'vue'
const props = defineProps({
documents: { type: Array, default: () => [] },
viewUrlBuilder: { type: Function, default: null },
})
const emit = defineEmits(['view'])
const formatSize = (bytes) => {
if (bytes == null) return '-'
const thresh = 1024
if (Math.abs(bytes) < thresh) return bytes + ' B'
const units = ['KB', 'MB', 'GB', 'TB']
let u = -1
do { bytes /= thresh; ++u } while (Math.abs(bytes) >= thresh && u < units.length - 1)
return bytes.toFixed(1) + ' ' + units[u]
}
const extFrom = (doc) => {
let ext = (doc?.extension || '').toLowerCase()
if (!ext && doc?.original_name) {
const parts = String(doc.original_name).toLowerCase().split('.')
if (parts.length > 1) ext = parts.pop()
}
// derive from mime
if (!ext && doc?.mime_type) {
const mime = String(doc.mime_type).toLowerCase()
if (mime.includes('pdf')) ext = 'pdf'
else if (mime.includes('word') || mime.includes('msword') || mime.includes('doc')) ext = 'docx'
else if (mime.includes('excel') || mime.includes('sheet')) ext = 'xlsx'
else if (mime.includes('csv')) ext = 'csv'
else if (mime.startsWith('image/')) ext = 'img'
else if (mime.includes('text')) ext = 'txt'
}
return ext
}
const fileTypeInfo = (doc) => {
const ext = extFrom(doc)
const mime = (doc?.mime_type || '').toLowerCase()
switch (ext) {
case 'pdf':
return { icon: faFilePdf, color: 'text-red-600', label: 'PDF' }
case 'doc':
case 'docx':
return { icon: faFileWord, color: 'text-blue-600', label: (ext || 'DOCX').toUpperCase() }
case 'xls':
case 'xlsx':
return { icon: faFileExcel, color: 'text-green-600', label: (ext || 'XLSX').toUpperCase() }
case 'csv':
// treat CSV as spreadsheet-like
return { icon: faFileExcel, color: 'text-emerald-600', label: 'CSV' }
case 'txt':
return { icon: faFileLines, color: 'text-slate-600', label: 'TXT' }
case 'jpg':
case 'jpeg':
case 'png':
case 'img':
return { icon: faFileImage, color: 'text-fuchsia-600', label: (ext === 'img' ? 'IMG' : (ext || 'IMG').toUpperCase()) }
default:
if (mime.startsWith('image/')) return { icon: faFileImage, color: 'text-fuchsia-600', label: 'IMG' }
return { icon: faFile, color: 'text-gray-600', label: (ext || 'FILE').toUpperCase() }
}
}
const hasDesc = (doc) => {
const d = doc?.description
return typeof d === 'string' && d.trim().length > 0
}
const expandedDescKey = ref(null)
const rowKey = (doc, i) => doc?.uuid ?? i
const toggleDesc = (doc, i) => {
const key = rowKey(doc, i)
expandedDescKey.value = expandedDescKey.value === key ? null : key
}
</script>
<template>
<div class="relative overflow-x-auto rounded-lg border border-gray-200 bg-white shadow-sm">
<FwbTable hoverable striped class="text-sm">
<FwbTableHead class="sticky top-0 z-10 bg-gray-50/90 backdrop-blur border-b border-gray-200 shadow-sm">
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Name</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Type</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Size</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Added</FwbTableHeadCell>
<FwbTableHeadCell class="uppercase text-xs font-semibold tracking-wide text-gray-700 py-3">Drugo</FwbTableHeadCell>
<FwbTableHeadCell class="w-px" />
</FwbTableHead>
<FwbTableBody>
<template v-for="(doc, i) in documents" :key="doc.uuid || i">
<FwbTableRow>
<FwbTableCell>
<div class="flex items-center gap-2">
<button type="button" class="text-indigo-600 hover:underline" @click="$emit('view', doc)">{{ doc.original_name || doc.name }}</button>
<FwbBadge v-if="doc.is_public" type="green">Public</FwbBadge>
</div>
</FwbTableCell>
<FwbTableCell>
<div class="flex items-center gap-2">
<FontAwesomeIcon :icon="fileTypeInfo(doc).icon" :class="['h-5 w-5', fileTypeInfo(doc).color]" />
<span class="text-gray-700">{{ fileTypeInfo(doc).label }}</span>
</div>
</FwbTableCell>
<FwbTableCell>{{ formatSize(doc.size) }}</FwbTableCell>
<FwbTableCell>{{ new Date(doc.created_at).toLocaleString() }}</FwbTableCell>
<FwbTableCell class="text-center">
<button
class="inline-flex items-center justify-center h-8 w-8 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed"
:disabled="!hasDesc(doc)"
:title="hasDesc(doc) ? 'Pokaži opis' : 'Ni opisa'"
type="button"
@click="toggleDesc(doc, i)"
>
<FontAwesomeIcon :icon="faCircleInfo" class="h-4 w-4 text-gray-700" />
</button>
</FwbTableCell>
<FwbTableCell class="text-right whitespace-nowrap">
<!-- future actions: download/delete -->
</FwbTableCell>
</FwbTableRow>
<!-- Expanded description row directly below the item -->
<FwbTableRow :key="'desc-' + (doc.uuid || i)" v-if="expandedDescKey === rowKey(doc, i)">
<FwbTableCell :colspan="6" class="bg-gray-50">
<div class="px-4 py-3 text-sm text-gray-700 whitespace-pre-wrap border-l-2 border-indigo-400">
{{ doc.description }}
</div>
</FwbTableCell>
</FwbTableRow>
</template>
</FwbTableBody>
</FwbTable>
<div v-if="!documents || documents.length === 0" class="p-6 text-center text-sm text-gray-500">No documents.</div>
</div>
</template>
+3 -3
View File
@@ -1,5 +1,5 @@
<script setup>
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
@@ -62,7 +62,7 @@ const updatePerson = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@@ -135,5 +135,5 @@ const updatePerson = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
+3 -3
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import Drawer from './Drawer.vue';
import DialogModal from './DialogModal.vue';
import InputLabel from './InputLabel.vue';
import SectionTitle from './SectionTitle.vue';
import TextInput from './TextInput.vue';
@@ -123,7 +123,7 @@ const submit = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@@ -189,5 +189,5 @@ const submit = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
+246 -209
View File
@@ -1,12 +1,10 @@
<script setup>
import { ref } from 'vue';
import { onMounted, onUnmounted, ref, watch, computed } from 'vue';
import { Head, Link, router, usePage } from '@inertiajs/vue3';
import ApplicationMark from '@/Components/ApplicationMark.vue';
import Banner from '@/Components/Banner.vue';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import NavLink from '@/Components/NavLink.vue';
import ResponsiveNavLink from '@/Components/ResponsiveNavLink.vue';
import Breadcrumbs from '@/Components/Breadcrumbs.vue';
import GlobalSearch from './Partials/GlobalSearch.vue';
@@ -14,13 +12,100 @@ const props = defineProps({
title: String,
});
// Collapsible sidebar state (persisted when user explicitly toggles)
const sidebarCollapsed = ref(false);
const hasSavedSidebarPref = ref(false);
// Mobile off-canvas state
const isMobile = ref(false);
const mobileSidebarOpen = ref(false);
function applyAutoCollapse() {
if (typeof window === 'undefined') return;
isMobile.value = window.innerWidth < 1024; // Tailwind lg breakpoint
sidebarCollapsed.value = isMobile.value;
}
function handleResize() {
if (typeof window !== 'undefined') {
isMobile.value = window.innerWidth < 1024;
if (!isMobile.value) mobileSidebarOpen.value = false; // close drawer when switching to desktop
}
if (!hasSavedSidebarPref.value) applyAutoCollapse();
}
onMounted(() => {
try {
const saved = localStorage.getItem('sidebarCollapsed');
if (saved !== null) {
hasSavedSidebarPref.value = true;
sidebarCollapsed.value = saved === '1';
} else {
applyAutoCollapse();
}
} catch {}
window.addEventListener('resize', handleResize);
});
onUnmounted(() => window.removeEventListener('resize', handleResize));
watch(sidebarCollapsed, (v) => {
if (!hasSavedSidebarPref.value) return; // don't persist auto behavior
try { localStorage.setItem('sidebarCollapsed', v ? '1' : '0'); } catch {}
});
const showingNavigationDropdown = ref(false);
// Global search modal state
const searchOpen = ref(false);
const openSearch = () => (searchOpen.value = true);
const closeSearch = () => (searchOpen.value = false);
// Keyboard shortcut: Ctrl+K / Cmd+K to open search
function onKeydown(e) {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
openSearch();
}
if (e.key === 'Escape' && mobileSidebarOpen.value) {
mobileSidebarOpen.value = false;
}
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
function toggleSidebar() {
hasSavedSidebarPref.value = true; // user explicitly chose
sidebarCollapsed.value = !sidebarCollapsed.value;
}
function toggleMobileSidebar() {
mobileSidebarOpen.value = !mobileSidebarOpen.value;
}
function handleSidebarToggleClick() {
if (isMobile.value) toggleMobileSidebar();
else toggleSidebar();
}
const logout = () => {
router.post(route('logout'));
};
// Flash toast notifications
const page = usePage();
const flash = computed(() => page.props.flash || {});
const showToast = ref(false);
const toastMessage = ref('');
const toastType = ref('success');
watch(
() => [flash.value.success, flash.value.error, flash.value.warning, flash.value.info],
([s, e, w, i]) => {
const message = s || e || w || i;
const type = s ? 'success' : e ? 'error' : w ? 'warning' : i ? 'info' : null;
if (message && type) {
toastMessage.value = message;
toastType.value = type;
showToast.value = true;
// auto-hide after 3s
setTimeout(() => (showToast.value = false), 3000);
}
},
{ immediate: true }
);
</script>
<template>
@@ -29,228 +114,180 @@ const logout = () => {
<Banner />
<div class="min-h-screen bg-gray-100">
<nav class="bg-white border-b border-gray-100">
<!-- Primary Navigation Menu -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex">
<!-- Logo -->
<div class="shrink-0 flex items-center">
<Link :href="route('dashboard')">
<ApplicationMark class="block h-9 w-auto" />
</Link>
</div>
<div class="min-h-screen bg-gray-100 flex">
<!-- Mobile backdrop -->
<div v-if="isMobile && mobileSidebarOpen" class="fixed inset-0 z-40 bg-black/30" @click="mobileSidebarOpen=false"></div>
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('dashboard')" :active="route().current('dashboard')">
Nadzorna plošča
</NavLink>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('client')" :active="route().current('client') || route().current('client.*')">
Naročniki
</NavLink>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('clientCase')" :active="route().current('clientCase') || route().current('clientCase.*')">
Primeri
</NavLink>
</div>
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<NavLink :href="route('settings')" :active="route().current('settings') || route().current('settings.*')">
Nastavitve
</NavLink>
</div>
<div class="hidden space-x-8 sm:-my-px sm:items-center sm:ms-10 sm:flex">
<GlobalSearch />
</div>
</div>
<div class="hidden sm:flex sm:items-center sm:ms-6">
<!-- Settings Dropdown -->
<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>
<!-- Account Management -->
<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" />
<!-- Authentication -->
<form @submit.prevent="logout">
<DropdownLink as="button">
Izpis
</DropdownLink>
</form>
</template>
</Dropdown>
</div>
</div>
<!-- Hamburger -->
<div class="-me-2 flex items-center sm:hidden">
<button class="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:bg-gray-100 focus:text-gray-500 transition duration-150 ease-in-out" @click="showingNavigationDropdown = ! showingNavigationDropdown">
<svg
class="h-6 w-6"
stroke="currentColor"
fill="none"
viewBox="0 0 24 24"
>
<path
:class="{'hidden': showingNavigationDropdown, 'inline-flex': ! showingNavigationDropdown }"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
:class="{'hidden': ! showingNavigationDropdown, 'inline-flex': showingNavigationDropdown }"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- Sidebar -->
<aside :class="[
sidebarCollapsed ? 'w-16' : 'w-64',
'bg-white border-r border-gray-200 transition-all duration-200 z-50',
// Off-canvas behavior on mobile
isMobile ? 'fixed inset-y-0 left-0 transform ' + (mobileSidebarOpen ? 'translate-x-0' : '-translate-x-full') : 'relative translate-x-0'
]">
<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">Teren</span>
</Link>
</div>
<nav class="py-4">
<ul class="space-y-1">
<li>
<Link :href="route('dashboard')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('dashboard') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nadzorna plošča">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955a1.125 1.125 0 011.592 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.5c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v4.5h4.125c.621 0 1.125-.504 1.125-1.125V9.75" />
</svg>
<span v-if="!sidebarCollapsed">Nadzorna plošča</span>
</Link>
</li>
<li>
<Link :href="route('client')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('client') || route().current('client.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Naročniki">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 003.745-.479 3.375 3.375 0 00-6.49-1.072M15 19.128V18a4.5 4.5 0 00-4.5-4.5H8.25A4.5 4.5 0 003.75 18v1.128M15 19.128V21m0-1.872V21M6.75 7.5a3 3 0 116 0 3 3 0 01-6 0z" />
</svg>
<span v-if="!sidebarCollapsed">Naročniki</span>
</Link>
</li>
<li>
<Link :href="route('clientCase')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('clientCase') || route().current('clientCase.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Primeri">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-6a2.25 2.25 0 00-2.25-2.25H8.25A2.25 2.25 0 006 8.25v7.5A2.25 2.25 0 008.25 18h9a2.25 2.25 0 002.25-2.25z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M9 9h6m-6 3h6m-6 3h3" />
</svg>
<span v-if="!sidebarCollapsed">Primeri</span>
</Link>
</li>
<li>
<Link :href="route('imports.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', (route().current('imports.index') || route().current('imports.*')) ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Uvozi">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M7.5 10.5L12 6l4.5 4.5M12 6v12" />
</svg>
<span v-if="!sidebarCollapsed">Uvozi</span>
</Link>
</li>
<li>
<Link :href="route('importTemplates.index')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('importTemplates.index') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Uvozne predloge">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 4.5h5.25l1.5 2.25H20.25A1.5 1.5 0 0121.75 8.25v9A2.25 2.25 0 0119.5 19.5H4.5A2.25 2.25 0 012.25 17.25V6A1.5 1.5 0 013.75 4.5z" />
</svg>
<span v-if="!sidebarCollapsed">Uvozne predloge</span>
</Link>
</li>
<li>
<Link :href="route('importTemplates.create')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('importTemplates.create') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nova uvozna predloga">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span v-if="!sidebarCollapsed">Nova uvozna predloga</span>
</Link>
</li>
<li>
<Link :href="route('settings')" :class="['flex items-center gap-3 px-4 py-2 text-sm hover:bg-gray-100', route().current('settings') || route().current('settings.*') ? 'bg-gray-100 text-gray-900' : 'text-gray-600']" title="Nastavitve">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.343 3.94c.09-.542.56-.94 1.11-.94h1.093c.55 0 1.02.398 1.11.94l.149.894c.07.424.384.764.78.93l.8.334c.486.203.682.78.4 1.223l-.5.805c-.214.343-.17.784.108 1.09l.596.654c.36.395.37 1.002.024 1.41l-.657.76c-.285.33-.347.79-.158 1.182l.3.65c.216.468-.02 1.02-.507 1.21l-.89.345c-.4.155-.68.52-.74.94l-.12.89c-.08.55-.54.96-1.09.96h-1.09c-.55 0-1.01-.41-1.09-.96l-.12-.89c-.06-.42-.34-.785-.74-.94l-.89-.345c-.49-.19-.72-.74-.507-1.21l.3-.65c.19-.392.127-.852-.158-1.182l-.657-.76a1.125 1.125 0 01.033-1.58l.596-.654c.278-.306.322-.747.108-1.09l-.5-.805c-.282-.443-.086-1.02.4-1.223l.8-.334c.396-.166.71-.506.78-.93l.149-.894zM12 15.75a3.75 3.75 0 100-7.5 3.75 3.75 0 000 7.5z" />
</svg>
<span v-if="!sidebarCollapsed">Nastavitve</span>
</Link>
</li>
</ul>
</nav>
</aside>
<!-- Responsive Navigation Menu -->
<div :class="{'block': showingNavigationDropdown, 'hidden': ! showingNavigationDropdown}" class="sm:hidden">
<div class="pt-2 pb-3 space-y-1">
<ResponsiveNavLink :href="route('dashboard')" :active="route().current('dashboard')">
Nadzorna plošča
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('client')" :active="route().current('client')">
Naročniki
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('clientCase')" :active="route().current('clientCase')">
Primeri
</ResponsiveNavLink>
<ResponsiveNavLink :href="route('settings')" :active="route().current('settings')">
Nastavitve
</ResponsiveNavLink>
<!-- Main column -->
<div class="flex-1 flex flex-col min-w-0">
<!-- Top bar -->
<div class="h-16 bg-white border-b border-gray-100 px-4 flex items-center justify-between">
<div class="flex items-center gap-2">
<!-- Sidebar toggle -->
<button
@click="handleSidebarToggleClick()"
class="inline-flex items-center justify-center w-9 h-9 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100"
:title="sidebarCollapsed ? 'Razširi meni' : 'Skrči meni'"
aria-label="Toggle sidebar"
>
<!-- Hamburger (Bars) icon -->
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
</svg>
</button>
<!-- Search trigger -->
<button @click="openSearch" class="inline-flex items-center gap-2 px-3 py-2 text-sm rounded-md border border-gray-200 text-gray-500 hover:text-gray-700 hover:border-gray-300">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-4.35-4.35m0 0A7.5 7.5 0 1010.5 18.5a7.5 7.5 0 006.15-1.85z" />
</svg>
<span class="hidden sm:inline">Globalni iskalnik</span>
<kbd class="hidden sm:inline ml-2 text-[10px] px-1.5 py-0.5 rounded border bg-gray-50">Ctrl K</kbd>
</button>
</div>
<!-- Responsive Settings Options -->
<div class="pt-4 pb-1 border-t border-gray-200">
<div class="flex items-center px-4">
<div v-if="$page.props.jetstream.managesProfilePhotos" class="shrink-0 me-3">
<img class="h-10 w-10 rounded-full object-cover" :src="$page.props.auth.user.profile_photo_url" :alt="$page.props.auth.user.name">
</div>
<div>
<div class="font-medium text-base text-gray-800">
{{ $page.props.auth.user.name }}
</div>
<div class="font-medium text-sm text-gray-500">
{{ $page.props.auth.user.email }}
</div>
</div>
</div>
<div class="flex items-center">
<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>
<div class="mt-3 space-y-1">
<ResponsiveNavLink :href="route('profile.show')" :active="route().current('profile.show')">
Profil
</ResponsiveNavLink>
<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>
<ResponsiveNavLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')" :active="route().current('api-tokens.index')">
API Tokens
</ResponsiveNavLink>
<template #content>
<div class="block px-4 py-2 text-xs text-gray-400">Nastavitve računa</div>
<!-- Authentication -->
<form method="POST" @submit.prevent="logout">
<ResponsiveNavLink as="button">
Izpis
</ResponsiveNavLink>
</form>
<DropdownLink :href="route('profile.show')">Profil</DropdownLink>
<DropdownLink v-if="$page.props.jetstream.hasApiFeatures" :href="route('api-tokens.index')">API Tokens</DropdownLink>
<!-- Team Management -->
<template v-if="$page.props.jetstream.hasTeamFeatures">
<div class="border-t border-gray-200" />
<div class="block px-4 py-2 text-xs text-gray-400">
Manage Team
</div>
<!-- Team Settings -->
<ResponsiveNavLink :href="route('teams.show', $page.props.auth.user.current_team)" :active="route().current('teams.show')">
Team Settings
</ResponsiveNavLink>
<ResponsiveNavLink v-if="$page.props.jetstream.canCreateTeams" :href="route('teams.create')" :active="route().current('teams.create')">
Create New Team
</ResponsiveNavLink>
<!-- Team Switcher -->
<template v-if="$page.props.auth.user.all_teams.length > 1">
<div class="border-t border-gray-200" />
<div class="block px-4 py-2 text-xs text-gray-400">
Switch Teams
</div>
<template v-for="team in $page.props.auth.user.all_teams" :key="team.id">
<form @submit.prevent="switchToTeam(team)">
<ResponsiveNavLink as="button">
<div class="flex items-center">
<svg v-if="team.id == $page.props.auth.user.current_team_id" class="me-2 h-5 w-5 text-green-400" 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="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>{{ team.name }}</div>
</div>
</ResponsiveNavLink>
</form>
</template>
<form @submit.prevent="logout">
<DropdownLink as="button">Izpis</DropdownLink>
</form>
</template>
</template>
</Dropdown>
</div>
</div>
</div>
</nav>
<!-- Page Heading -->
<header v-if="$slots.header" class="bg-white shadow">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8">
<Breadcrumbs :breadcrumbs="$page.props.breadcrumbs"></Breadcrumbs>
</div>
</header>
<!-- Page Heading -->
<header v-if="$slots.header" class="bg-white border-b shadow-sm">
<div class="max-w-7xl mx-auto py-4 px-4 sm:px-6 lg:px-8 space-y-2">
<Breadcrumbs v-if="$page.props.breadcrumbs && $page.props.breadcrumbs.length" :breadcrumbs="$page.props.breadcrumbs" />
<slot name="header" />
</div>
</header>
<!-- Page Content -->
<main>
<slot />
</main>
<!-- Page Content -->
<main class="p-4">
<slot />
</main>
</div>
</div>
<!-- Global Search Modal -->
<GlobalSearch :open="searchOpen" @update:open="(v)=>searchOpen=v" />
<!-- Simple Toast -->
<transition name="fade">
<div
v-if="showToast"
class="fixed bottom-4 right-4 z-[100] px-4 py-3 rounded shadow-lg text-white"
:class="{
'bg-emerald-600': toastType==='success',
'bg-red-600': toastType==='error',
'bg-amber-500': toastType==='warning',
'bg-blue-600': toastType==='info',
}"
>
{{ toastMessage }}
</div>
</transition>
</div>
</template>
+73 -54
View File
@@ -1,72 +1,91 @@
<script setup>
import { FwbInput, FwbListGroup, FwbListGroupItem } from 'flowbite-vue';
import { FwbInput } from 'flowbite-vue';
import axios from 'axios';
import { debounce } from 'lodash';
import { SearchIcon } from '@/Utilities/Icons';
import { ref, watch } from 'vue';
import Dropdown from '@/Components/Dropdown.vue';
import DropdownLink from '@/Components/DropdownLink.vue';
import { onMounted, onUnmounted, ref, watch } from 'vue';
import { Link } from '@inertiajs/vue3';
const props = defineProps({
css: String
open: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open']);
const query = ref('');
const result = ref([]);
const result = ref({ clients: [], client_cases: [] });
const isOpen = ref(props.open);
watch(() => props.open, (v) => { isOpen.value = v; if (v) focusInput(); });
watch(isOpen, (v) => emit('update:open', v));
const searching = debounce((value) => {
axios.get(
route('search'),
{
params: {
query: value,
limit: 5,
tag: ''
}
}
)
.then(function(res) {
result.value = res.data
list.value = false;
console.log(res);
})
.catch(function(error){
console.log(error)
})
.finally(function(){
if (!value || !value.trim()) { result.value = { clients: [], client_cases: [] }; return; }
axios.get(route('search'), { params: { query: value, limit: 8, tag: '' } })
.then(res => { result.value = res.data; })
.catch(() => {})
}, 250);
});
}, 300);
watch(
() => query.value,
(val) => searching(val)
);
watch(() => query.value, (val) => searching(val));
const inputWrap = ref(null);
const focusInput = () => setTimeout(() => inputWrap.value?.querySelector('input')?.focus(), 0);
function onKeydown(e) {
if (e.key === 'Escape') { isOpen.value = false; }
}
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => window.removeEventListener('keydown', onKeydown));
</script>
<template>
<Dropdown align="left" :contentClasses="['py-1 bg-white lg:w-60']">
<template #trigger>
<fwb-input
v-model="query"
placeholder="Iskalnik..."
size="sm"
class="lg:w-60"
>
<template #prefix>
<SearchIcon />
</template>
<teleport to="body">
<transition name="fade">
<div v-if="isOpen" class="fixed inset-0 z-50">
<!-- Backdrop -->
<div class="absolute inset-0 bg-black/30" @click="isOpen = false"></div>
</fwb-input>
</template>
<template #content>
<div class="block px-4 py-2 text-xs text-gray-400">Naročnik</div>
<!-- Dialog (click outside closes) -->
<div class="absolute inset-0 flex items-start sm:items-start justify-center p-4 pt-8 sm:pt-16" @click.self="isOpen = false">
<div class="w-full max-w-2xl bg-white rounded-lg shadow-xl overflow-hidden">
<div class="p-3 border-b" ref="inputWrap">
<FwbInput v-model="query" placeholder="Išči po naročnikih in primerih..." size="md" class="w-full">
<template #prefix>
<SearchIcon />
</template>
</FwbInput>
</div>
<div class="max-h-[60vh] overflow-auto">
<div v-if="!query" class="p-6 text-sm text-gray-500">Začni tipkati za iskanje. Namig: pritisni Ctrl+K kjerkoli.</div>
<div v-else>
<div class="px-4 py-2 text-xs text-gray-500">Naročniki</div>
<ul>
<li v-for="client in result.clients" :key="client.client_uuid">
<Link :href="route('client.show', {uuid: client.client_uuid})" class="block px-4 py-2 hover:bg-gray-50" @click="isOpen=false">
{{ client.full_name }}
</Link>
</li>
</ul>
<DropdownLink v-for="client in result.clients" :href="route('client.show', {uuid: client.client_uuid})">{{ client.full_name }}</DropdownLink>
<div class="border-t border-gray-200" />
<div class="block px-4 py-2 text-xs text-gray-400">Cases</div>
<DropdownLink v-for="clientcase in result.client_cases" :href="route('clientCase.show', {uuid: clientcase.case_uuid})">{{ clientcase.full_name }}</DropdownLink>
</template>
</Dropdown>
</template>
<div class="px-4 py-2 mt-2 text-xs text-gray-500">Primeri</div>
<ul>
<li v-for="clientcase in result.client_cases" :key="clientcase.case_uuid">
<Link :href="route('clientCase.show', {uuid: clientcase.case_uuid})" class="block px-4 py-2 hover:bg-gray-50" @click="isOpen=false">
{{ clientcase.full_name }}
</Link>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</transition>
</teleport>
<!-- no inline trigger here; AppLayout provides the button and opens this modal -->
</template>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity .15s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
@@ -1,7 +1,7 @@
<script setup>
import ActionMessage from '@/Components/ActionMessage.vue';
import BasicButton from '@/Components/buttons/BasicButton.vue';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
@@ -87,7 +87,7 @@ const store = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@@ -161,5 +161,5 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
@@ -1,6 +1,6 @@
<script setup>
import ActionMessage from '@/Components/ActionMessage.vue';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
@@ -48,7 +48,7 @@ const storeContract = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@@ -97,6 +97,6 @@ const storeContract = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
+52 -1
View File
@@ -8,6 +8,11 @@ import ContractDrawer from "./Partials/ContractDrawer.vue";
import ContractTable from "./Partials/ContractTable.vue";
import ActivityDrawer from "./Partials/ActivityDrawer.vue";
import ActivityTable from "./Partials/ActivityTable.vue";
import DocumentsTable from "@/Components/DocumentsTable.vue";
import DocumentUploadDialog from "@/Components/DocumentUploadDialog.vue";
import DocumentViewerDialog from "@/Components/DocumentViewerDialog.vue";
import { classifyDocument } from "@/Services/documents";
import { router } from '@inertiajs/vue3';
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
import Pagination from "@/Components/Pagination.vue";
@@ -18,9 +23,32 @@ const props = defineProps({
activities: Object,
contract_types: Array,
actions: Array,
types: Object
types: Object,
documents: Array
});
const showUpload = ref(false);
const openUpload = () => { showUpload.value = true; };
const closeUpload = () => { showUpload.value = false; };
const onUploaded = () => {
// Refresh page data to include the new document
router.reload({ only: ['documents'] });
};
const viewer = ref({ open: false, src: '', title: '' });
const openViewer = (doc) => {
const kind = classifyDocument(doc)
if (kind === 'preview') {
const url = route('clientCase.document.view', { client_case: props.client_case.uuid, document: doc.uuid })
viewer.value = { open: true, src: url, title: doc.original_name || doc.name };
} else {
const url = route('clientCase.document.download', { client_case: props.client_case.uuid, document: doc.uuid })
// immediate download: navigate to URL
window.location.href = url
}
};
const closeViewer = () => { viewer.value.open = false; viewer.value.src = ''; };
const clientDetails = ref(true);
//Drawer add new contract
@@ -131,6 +159,29 @@ const hideClietnDetails = () => {
</div>
</div>
</div>
<!-- Documents section -->
<div class="pt-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
<div class="mx-auto max-w-4x1">
<div class="flex justify-between p-4">
<SectionTitle>
<template #title>Dokumenti</template>
</SectionTitle>
<FwbButton @click="openUpload">Dodaj</FwbButton>
</div>
<DocumentsTable :documents="documents" @view="openViewer" />
</div>
</div>
</div>
</div>
<DocumentUploadDialog
:show="showUpload"
@close="closeUpload"
@uploaded="onUploaded"
:post-url="route('clientCase.document.store', client_case)"
/>
<DocumentViewerDialog :show="viewer.open" :src="viewer.src" :title="viewer.title" @close="closeViewer" />
<div class="pt-12 pb-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4">
+3 -3
View File
@@ -8,7 +8,7 @@ import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
import { Link, useForm } from '@inertiajs/vue3';
import ActionMessage from '@/Components/ActionMessage.vue';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import Pagination from '@/Components/Pagination.vue';
import SearchInput from '@/Components/SearchInput.vue';
@@ -117,7 +117,7 @@ const storeClient = () => {
</div>
</div>
</AppLayout>
<Drawer
<DialogModal
:show="drawerCreateClient"
@close="drawerCreateClient = false"
>
@@ -250,5 +250,5 @@ const storeClient = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
@@ -1,5 +1,5 @@
<script setup>
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import InputLabel from '@/Components/InputLabel.vue';
import TextInput from '@/Components/TextInput.vue';
@@ -61,7 +61,7 @@ const storeCase = () => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close">
@@ -197,5 +197,5 @@ const storeCase = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
@@ -1,6 +1,6 @@
<script setup>
import ActionMessage from '@/Components/ActionMessage.vue';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import InputLabel from '@/Components/InputLabel.vue';
import PrimaryButton from '@/Components/PrimaryButton.vue';
import SectionTitle from '@/Components/SectionTitle.vue';
@@ -42,7 +42,7 @@ onMounted(() => {
</script>
<template>
<Drawer
<DialogModal
:show="show"
@close="close"
>
@@ -114,5 +114,5 @@ onMounted(() => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
+537
View File
@@ -0,0 +1,537 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, watch, computed } from 'vue';
import { useForm, router } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
const props = defineProps({
templates: Array,
clients: Array,
});
const hasHeader = ref(true);
const detected = ref({ columns: [], delimiter: ',', has_header: true });
const importId = ref(null);
const templateApplied = ref(false);
const processing = ref(false);
const processResult = ref(null);
const mappingRows = ref([]);
const mappingSaved = ref(false);
const mappingSavedCount = ref(0);
const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.skip && r.entity && r.field).length);
const mappingError = ref('');
const savingMappings = ref(false);
const form = useForm({
client_uuid: null,
import_template_id: null,
source_type: null,
sheet_name: null,
has_header: true,
file: null,
});
// Bridge Multiselect (expects option objects) to our form (stores client_uuid as string)
const selectedClientOption = computed({
get() {
const cuuid = form.client_uuid;
if (!cuuid) return null;
return (props.clients || []).find(c => c.uuid === cuuid) || null;
},
set(val) {
form.client_uuid = val ? val.uuid : null;
}
});
// Bridge Template Multiselect to store only template id (number) in form
const selectedTemplateOption = computed({
get() {
const tid = form.import_template_id;
if (tid == null) return null;
return (props.templates || []).find(t => t.id === tid) || null;
},
set(val) {
form.import_template_id = val ? val.id : null;
}
});
// Helper: selected client's numeric id (fallback)
const selectedClientId = computed(() => {
const cuuid = form.client_uuid;
if (!cuuid) return null;
const c = (props.clients || []).find(x => x.uuid === cuuid);
return c ? c.id : null;
});
// Show only global templates when no client is selected.
// When a client is selected, show only that client's templates (match by client_uuid).
const filteredTemplates = computed(() => {
const cuuid = form.client_uuid;
const list = props.templates || [];
if (!cuuid) {
return list.filter(t => t.client_id == null);
}
// When client is selected, only show that client's templates (no globals)
return list.filter(t => t.client_uuid && t.client_uuid === cuuid);
});
function onFileChange(e) {
const files = e.target.files;
if (files && files.length) {
form.file = files[0];
}
}
async function submitUpload() {
await form.post(route('imports.store'), {
forceFormData: true,
onSuccess: (res) => {
const data = res?.props || {};
},
onFinish: async () => {
// After upload, fetch columns for preview
if (!form.recentlySuccessful) return;
// Inertia doesn't expose JSON response directly with useForm; fallback to API call using fetch
const fd = new FormData();
fd.append('file', form.file);
},
});
}
async function fetchColumns() {
if (!importId.value) return;
const url = route('imports.columns', { import: importId.value });
const { data } = await axios.get(url, { params: { has_header: hasHeader.value ? 1 : 0 } });
detected.value = {
columns: data.columns || [],
delimiter: data.detected_delimiter || ',',
has_header: !!data.has_header,
};
// initialize simple mapping rows with defaults if none exist
if (!mappingRows.value.length) {
mappingRows.value = (detected.value.columns || []).map((c, idx) => ({
source_column: c,
entity: '',
field: '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
}));
}
// If there are mappings already (template applied or saved), load them to auto-assign
await loadImportMappings();
}
async function uploadAndPreview() {
if (!form.file) {
// Basic guard: require a file before proceeding
return;
}
templateApplied.value = false;
processResult.value = null;
const fd = new window.FormData();
fd.append('file', form.file);
if (Number.isFinite(form.import_template_id)) {
fd.append('import_template_id', String(form.import_template_id));
}
if (form.client_uuid) {
fd.append('client_uuid', String(form.client_uuid));
}
fd.append('has_header', hasHeader.value ? '1' : '0');
try {
const { data } = await axios.post(route('imports.store'), fd, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
// Redirect immediately to the continue page for this import
if (data?.uuid) {
router.visit(route('imports.continue', { import: data.uuid }));
} else if (data?.id) {
// Fallback: if uuid not returned for some reason, fetch columns here (legacy)
importId.value = data.id;
await fetchColumns();
}
} catch (e) {
if (e.response) {
console.error('Upload error', e.response.status, e.response.data);
if (e.response.data?.errors) {
// Optionally you could surface errors in the UI; for now, log for visibility
}
} else {
console.error('Upload error', e);
}
}
}
// If continuing an existing import, set importId and hydrate columns and mappings
// No continuation logic on Create page anymore
async function applyTemplateToImport() {
if (!importId.value || !form.import_template_id) return;
try {
await axios.post(route('importTemplates.apply', { template: form.import_template_id, import: importId.value }), {}, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
templateApplied.value = true;
// Load mappings and auto-assign UI rows
await loadImportMappings();
} catch (e) {
templateApplied.value = false;
if (e.response) {
console.error('Apply template error', e.response.status, e.response.data);
} else {
console.error('Apply template error', e);
}
}
}
async function loadImportMappings() {
if (!importId.value) return;
try {
const { data } = await axios.get(route('imports.mappings.get', { import: importId.value }), {
headers: { Accept: 'application/json' },
withCredentials: true,
});
const rows = Array.isArray(data?.mappings) ? data.mappings : [];
if (!rows.length) return;
// Build a lookup by source_column
const bySource = new Map(rows.map(r => [r.source_column, r]));
// Update mappingRows (detected columns) to reflect applied mappings
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
const m = bySource.get(r.source_column);
if (!m) return r;
// Parse target_field like 'person.first_name' into UI entity/field
const [record, field] = String(m.target_field || '').split('.', 2);
const entity = recordToEntityKey(record);
return {
...r,
entity,
field: field || '',
transform: m.transform || '',
apply_mode: m.apply_mode || 'both',
skip: false,
position: idx,
};
});
} catch (e) {
console.error('Load import mappings error', e.response?.status || '', e.response?.data || e);
}
}
async function processImport() {
if (!importId.value) return;
processing.value = true;
processResult.value = null;
try {
const { data } = await axios.post(route('imports.process', { import: importId.value }), {}, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
processResult.value = data;
} catch (e) {
if (e.response) {
console.error('Process import error', e.response.status, e.response.data);
processResult.value = { error: e.response.data || 'Processing failed' };
} else {
console.error('Process import error', e);
processResult.value = { error: 'Processing failed' };
}
} finally {
processing.value = false;
}
}
const entityOptions = [
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Address' },
{ value: 'person_phones', label: 'Person Phone' },
{ value: 'emails', label: 'Email' },
{ value: 'accounts', label: 'Account' },
{ value: 'contracts', label: 'Contract' },
];
const fieldOptionsByEntity = {
person: [
{ value: 'first_name', label: 'First name' },
{ value: 'last_name', label: 'Last name' },
{ value: 'full_name', label: 'Full name' },
{ value: 'tax_number', label: 'Tax number' },
{ value: 'social_security_number', label: 'SSN' },
{ value: 'birthday', label: 'Birthday' },
{ value: 'gender', label: 'Gender' },
{ value: 'description', label: 'Description' },
],
person_addresses: [
{ value: 'address', label: 'Address' },
{ value: 'country', label: 'Country' },
{ value: 'type_id', label: 'Address Type Id' },
{ value: 'description', label: 'Description' },
],
person_phones: [
{ value: 'nu', label: 'Phone number' },
{ value: 'country_code', label: 'Country code' },
{ value: 'type_id', label: 'Phone Type Id' },
{ value: 'description', label: 'Description' },
],
emails: [
{ value: 'value', label: 'Email address' },
{ value: 'label', label: 'Label' },
{ value: 'is_primary', label: 'Is primary' },
],
accounts: [
{ value: 'reference', label: 'Reference' },
{ value: 'description', label: 'Description' },
{ value: 'contract_id', label: 'Contract Id' },
{ value: 'contract_reference', label: 'Contract Reference' },
{ value: 'type_id', label: 'Account Type Id' },
{ value: 'active', label: 'Active' },
{ value: 'balance_amount', label: 'Balance Amount' },
],
contracts: [
{ value: 'reference', label: 'Reference' },
{ value: 'start_date', label: 'Start Date' },
{ value: 'end_date', label: 'End Date' },
{ value: 'description', label: 'Description' },
{ value: 'client_case_id', label: 'Client Case Id' },
{ value: 'type_id', label: 'Contract Type Id' },
],
};
async function saveMappings() {
if (!importId.value) return;
mappingError.value = '';
const mappings = mappingRows.value
.filter(r => !r.skip && r.entity && r.field)
.map(r => ({
source_column: r.source_column,
target_field: `${entityKeyToRecord(r.entity)}.${r.field}`,
transform: r.transform || null,
apply_mode: r.apply_mode || 'both',
options: null,
}));
if (!mappings.length) {
mappingSaved.value = false;
mappingError.value = 'Select entity and field for at least one column (or uncheck Skip) before saving.';
return;
}
try {
savingMappings.value = true;
const url = (typeof route === 'function')
? route('imports.mappings.save', { import: importId.value })
: `/imports/${importId.value}/mappings`;
const { data } = await axios.post(url, { mappings }, {
headers: { 'Accept': 'application/json' },
withCredentials: true,
});
mappingSaved.value = true;
mappingSavedCount.value = Number(data?.saved || mappings.length);
mappingError.value = '';
} catch (e) {
mappingSaved.value = false;
if (e.response) {
console.error('Save mappings error', e.response.status, e.response.data);
alert('Failed to save mappings: ' + (e.response.data?.message || e.response.status));
} else {
console.error('Save mappings error', e);
alert('Failed to save mappings. See console for details.');
}
} finally {
savingMappings.value = false;
}
}
// Reset saved flag whenever user edits mappings
watch(mappingRows, () => {
mappingSaved.value = false;
mappingSavedCount.value = 0;
mappingError.value = '';
}, { deep: true });
function entityKeyToRecord(key) {
// Map UI entities to record_type nouns used by processor
if (key === 'person_addresses') return 'address';
if (key === 'person_phones') return 'phone';
if (key === 'emails') return 'email';
if (key === 'accounts') return 'account';
if (key === 'contracts') return 'contract';
return 'person';
}
function recordToEntityKey(record) {
if (record === 'address') return 'person_addresses';
if (record === 'phone') return 'person_phones';
if (record === 'email') return 'emails';
if (record === 'account') return 'accounts';
if (record === 'contract') return 'contracts';
return 'person';
}
</script>
<template>
<AppLayout title="New Import">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">New Import</h2>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Client</label>
<Multiselect
v-model="selectedClientOption"
:options="clients"
track-by="uuid"
label="name"
placeholder="Search clients..."
:searchable="true"
:allow-empty="true"
class="mt-1"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Template</label>
<Multiselect
v-model="selectedTemplateOption"
:options="filteredTemplates"
track-by="id"
label="name"
placeholder="Search templates..."
:searchable="true"
:allow-empty="true"
class="mt-1"
>
<template #option="{ option }">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span class="ml-2 text-xs text-gray-500">({{ option.source_type }})</span>
</div>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{ option.client_id ? 'Client' : 'Global' }}</span>
</div>
</template>
<template #singleLabel="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span class="ml-1 text-xs text-gray-500">({{ option.source_type }})</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{ option.client_id ? 'Client' : 'Global' }}</span>
</div>
</template>
</Multiselect>
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">Only global templates are shown until a client is selected.</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">File</label>
<input type="file" @change="onFileChange" class="mt-1 block w-full" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Has header row</label>
<input type="checkbox" v-model="hasHeader" class="mt-2" />
</div>
</div>
<div class="flex gap-3">
<button @click.prevent="uploadAndPreview" class="px-4 py-2 bg-blue-600 text-white rounded">Upload & Preview Columns</button>
<button
@click.prevent="applyTemplateToImport"
:disabled="!importId || !form.import_template_id || templateApplied"
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
>
{{ templateApplied ? 'Template Applied' : 'Apply Template' }}
</button>
<button
@click.prevent="saveMappings"
:disabled="!importId || processing || savingMappings"
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
title="Save ad-hoc mappings for this import"
>
<span v-if="savingMappings" class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"></span>
<span>Save Mappings</span>
<span v-if="selectedMappingsCount" class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded">{{ selectedMappingsCount }}</span>
</button>
<button
@click.prevent="processImport"
:disabled="!importId || processing || (!templateApplied && !mappingSaved)"
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
>
{{ processing ? 'Processing…' : 'Process Import' }}
</button>
</div>
<div class="mt-2 text-xs text-gray-600" v-if="!importId">
Upload a file first to enable saving mappings.
</div>
<div class="mt-2 text-xs text-gray-600" v-else-if="importId && !selectedMappingsCount">
Select an Entity and Field for at least one detected column (or uncheck Skip) and then click Save Mappings.
</div>
<div v-if="detected.columns.length" class="pt-4">
<h3 class="font-semibold mb-2">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})</h3>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white">
<thead>
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Source column</th>
<th class="p-2 border">Entity</th>
<th class="p-2 border">Field</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Apply mode</th>
<th class="p-2 border">Skip</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in mappingRows" :key="idx" class="border-t">
<td class="p-2 border text-sm">{{ row.source_column }}</td>
<td class="p-2 border">
<select v-model="row.entity" class="border rounded p-1 w-full">
<option value=""></option>
<option v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.field" class="border rounded p-1 w-full">
<option value=""></option>
<option v-for="f in fieldOptionsByEntity[row.entity] || []" :key="f.value" :value="f.value">{{ f.label }}</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.transform" class="border rounded p-1 w-full">
<option value="">None</option>
<option value="trim">Trim</option>
<option value="upper">Uppercase</option>
<option value="lower">Lowercase</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.apply_mode" class="border rounded p-1 w-full">
<option value="both">Both</option>
<option value="insert">Insert only</option>
<option value="update">Update only</option>
</select>
</td>
<td class="p-2 border text-center">
<input type="checkbox" v-model="row.skip" />
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">Mappings saved ({{ mappingSavedCount }}).</div>
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">{{ mappingError }}</div>
</div>
<div v-if="processResult" class="pt-4">
<h3 class="font-semibold mb-2">Import Result</h3>
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{ processResult }}</pre>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
+805
View File
@@ -0,0 +1,805 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, computed, onMounted, watch } from 'vue';
import Multiselect from 'vue-multiselect';
import axios from 'axios';
const props = defineProps({
import: Object,
templates: Array,
clients: Array,
});
const importId = ref(props.import?.id || null);
const hasHeader = ref(Boolean(props.import?.meta?.has_header ?? true));
const detected = ref({ columns: props.import?.meta?.columns || [], delimiter: props.import?.meta?.detected_delimiter || ',', has_header: hasHeader.value });
const templateApplied = ref(Boolean(props.import?.import_template_id));
const processing = ref(false);
const processResult = ref(null);
const mappingRows = ref([]);
const mappingSaved = ref(false);
const mappingSavedCount = ref(0);
const mappingError = ref('');
const savingMappings = ref(false);
// Persisted mappings from backend (raw view regardless of detected columns)
const persistedMappings = ref([]);
const detectedNote = ref('');
// Logs
const events = ref([]);
const eventsLimit = ref(200);
const loadingEvents = ref(false);
// Completed status helper
const isCompleted = computed(() => (props.import?.status || '') === 'completed');
// Whether backend has any saved mappings for this import
const hasPersistedMappings = computed(() => (persistedMappings.value?.length || 0) > 0);
const canProcess = computed(() => !!importId.value && !processing.value && hasPersistedMappings.value && !isCompleted.value);
// Display rows used by the table: prefer mappingRows if present; otherwise fall back to detected columns
const displayRows = computed(() => {
if (Array.isArray(mappingRows.value) && mappingRows.value.length > 0) {
return mappingRows.value;
}
const cols = detected.value?.columns || [];
return cols.map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
});
// Header normalization and guess mapping for auto-assigning sensible defaults
function stripDiacritics(s) {
if (!s) return '';
return String(s)
.replace(/[čć]/gi, 'c')
.replace(/[š]/gi, 's')
.replace(/[ž]/gi, 'z')
.replace(/[đ]/gi, 'd')
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
function normalizeHeader(h) {
if (!h) return '';
const base = stripDiacritics(String(h)).toLowerCase();
return base.replace(/[^a-z0-9]+/g, ' ').trim().replace(/\s+/g, '');
}
function guessMappingForHeader(h) {
const key = normalizeHeader(h);
// Confident matches only; avoid overly generic keys like 'name'
const dict = {
// Person
firstname: { entity: 'person', field: 'first_name' },
givenname: { entity: 'person', field: 'first_name' },
forename: { entity: 'person', field: 'first_name' },
lastname: { entity: 'person', field: 'last_name' },
surname: { entity: 'person', field: 'last_name' },
familyname: { entity: 'person', field: 'last_name' },
fullname: { entity: 'person', field: 'full_name' },
taxnumber: { entity: 'person', field: 'tax_number' },
taxid: { entity: 'person', field: 'tax_number' },
tin: { entity: 'person', field: 'tax_number' },
socialsecuritynumber: { entity: 'person', field: 'social_security_number' },
ssn: { entity: 'person', field: 'social_security_number' },
birthday: { entity: 'person', field: 'birthday' },
birthdate: { entity: 'person', field: 'birthday' },
dob: { entity: 'person', field: 'birthday' },
gender: { entity: 'person', field: 'gender' },
description: { entity: 'person', field: 'description' },
// Email
email: { entity: 'emails', field: 'value' },
emailaddress: { entity: 'emails', field: 'value' },
// Phone
phone: { entity: 'person_phones', field: 'nu' },
phonenumber: { entity: 'person_phones', field: 'nu' },
mobile: { entity: 'person_phones', field: 'nu' },
gsm: { entity: 'person_phones', field: 'nu' },
telephone: { entity: 'person_phones', field: 'nu' },
countrycode: { entity: 'person_phones', field: 'country_code' },
// Address
address: { entity: 'person_addresses', field: 'address' },
street: { entity: 'person_addresses', field: 'address' },
country: { entity: 'person_addresses', field: 'country' },
// Accounts
accountreference: { entity: 'accounts', field: 'reference' },
accountref: { entity: 'accounts', field: 'reference' },
balancedue: { entity: 'accounts', field: 'balance_amount' },
balance: { entity: 'accounts', field: 'balance_amount' },
amount: { entity: 'accounts', field: 'balance_amount' },
// Contracts
reference: { entity: 'contracts', field: 'reference' },
ref: { entity: 'contracts', field: 'reference' },
contractreference: { entity: 'contracts', field: 'reference' },
contractref: { entity: 'contracts', field: 'reference' },
contract: { entity: 'contracts', field: 'reference' },
contractno: { entity: 'contracts', field: 'reference' },
contractnum: { entity: 'contracts', field: 'reference' },
contractnumber: { entity: 'contracts', field: 'reference' },
agreement: { entity: 'contracts', field: 'reference' },
agreementno: { entity: 'contracts', field: 'reference' },
agreementnum: { entity: 'contracts', field: 'reference' },
agreementnumber: { entity: 'contracts', field: 'reference' },
startdate: { entity: 'contracts', field: 'start_date' },
enddate: { entity: 'contracts', field: 'end_date' },
// Slovenian/common localized headers (normalized)
ime: { entity: 'person', field: 'first_name' },
priimek: { entity: 'person', field: 'last_name' },
imeinpriimek: { entity: 'person', field: 'full_name' },
polnoime: { entity: 'person', field: 'full_name' },
davcna: { entity: 'person', field: 'tax_number' },
davcnastevilka: { entity: 'person', field: 'tax_number' },
emso: { entity: 'person', field: 'social_security_number' },
rojstnidatum: { entity: 'person', field: 'birthday' },
spol: { entity: 'person', field: 'gender' },
opis: { entity: 'person', field: 'description' },
eposta: { entity: 'emails', field: 'value' },
elektronskaposta: { entity: 'emails', field: 'value' },
telefon: { entity: 'person_phones', field: 'nu' },
mobilni: { entity: 'person_phones', field: 'nu' },
gsm: { entity: 'person_phones', field: 'nu' },
klicna: { entity: 'person_phones', field: 'country_code' },
drzava: { entity: 'person_addresses', field: 'country' },
naslov: { entity: 'person_addresses', field: 'address' },
ulica: { entity: 'person_addresses', field: 'address' },
sklic: { entity: 'accounts', field: 'reference' },
referenca: { entity: 'accounts', field: 'reference' },
saldo: { entity: 'accounts', field: 'balance_amount' },
znesek: { entity: 'accounts', field: 'balance_amount' },
pogodbasklic: { entity: 'contracts', field: 'reference' },
pogodbastevilka: { entity: 'contracts', field: 'reference' },
pogodba: { entity: 'contracts', field: 'reference' },
pogodbast: { entity: 'contracts', field: 'reference' },
zacetek: { entity: 'contracts', field: 'start_date' },
konec: { entity: 'contracts', field: 'end_date' },
};
return dict[key] || null;
}
// Normalize source column to match persisted mappings in a case/space/diacritic-insensitive way
function normalizeSource(s) {
return normalizeHeader(s);
}
// Entity and field options used by the mapping grid
const entityOptions = [
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Address' },
{ value: 'person_phones', label: 'Person Phone' },
{ value: 'emails', label: 'Email' },
{ value: 'accounts', label: 'Account' },
{ value: 'contracts', label: 'Contract' },
];
const fieldOptionsByEntity = {
person: [
{ value: 'first_name', label: 'First name' },
{ value: 'last_name', label: 'Last name' },
{ value: 'full_name', label: 'Full name' },
{ value: 'tax_number', label: 'Tax number' },
{ value: 'social_security_number', label: 'SSN' },
{ value: 'birthday', label: 'Birthday' },
{ value: 'gender', label: 'Gender' },
{ value: 'description', label: 'Description' },
],
person_addresses: [
{ value: 'address', label: 'Address' },
{ value: 'country', label: 'Country' },
{ value: 'type_id', label: 'Address Type Id' },
{ value: 'description', label: 'Description' },
],
person_phones: [
{ value: 'nu', label: 'Phone number' },
{ value: 'country_code', label: 'Country code' },
{ value: 'type_id', label: 'Phone Type Id' },
{ value: 'description', label: 'Description' },
],
emails: [
{ value: 'value', label: 'Email address' },
{ value: 'label', label: 'Label' },
{ value: 'is_primary', label: 'Is primary' },
],
accounts: [
{ value: 'reference', label: 'Reference' },
{ value: 'description', label: 'Description' },
{ value: 'contract_id', label: 'Contract Id' },
{ value: 'contract_reference', label: 'Contract Reference' },
{ value: 'type_id', label: 'Account Type Id' },
{ value: 'active', label: 'Active' },
{ value: 'balance_amount', label: 'Balance Amount' },
],
contracts: [
{ value: 'reference', label: 'Reference' },
{ value: 'start_date', label: 'Start Date' },
{ value: 'end_date', label: 'End Date' },
{ value: 'description', label: 'Description' },
{ value: 'client_case_id', label: 'Client Case Id' },
{ value: 'type_id', label: 'Contract Type Id' },
],
};
// Local state for selects
const form = ref({
client_uuid: null,
import_template_id: props.import?.import_template_id || null,
});
// Initialize client_uuid from numeric client_id using provided clients list
if (props.import?.client_id) {
const found = (props.clients || []).find(c => c.id === props.import.client_id);
form.value.client_uuid = found ? found.uuid : null;
}
const selectedClientOption = computed({
get() {
const cuuid = form.value.client_uuid;
if (!cuuid) return null;
return (props.clients || []).find(c => c.uuid === cuuid) || null;
},
set(val) {
form.value.client_uuid = val ? val.uuid : null;
}
});
const selectedTemplateOption = computed({
get() {
const tid = form.value.import_template_id;
if (tid == null) return null;
return (props.templates || []).find(t => t.id === tid) || null;
},
set(val) {
form.value.import_template_id = val ? val.id : null;
}
});
// Show only global templates when no client is selected.
// When a client is selected, show only that client's templates (strict match by client_uuid, no globals).
const filteredTemplates = computed(() => {
const cuuid = form.value.client_uuid;
const list = props.templates || [];
if (!cuuid) {
return list.filter(t => t.client_id == null);
}
return list.filter(t => t.client_uuid && t.client_uuid === cuuid);
});
const selectedMappingsCount = computed(() => mappingRows.value.filter(r => !r.skip && r.entity && r.field).length);
async function fetchColumns() {
if (!importId.value) return;
const url = route('imports.columns', { import: importId.value });
const { data } = await axios.get(url, { params: { has_header: hasHeader.value ? 1 : 0 } });
// Normalize columns to strings for consistent rendering
const colsRaw = Array.isArray(data.columns) ? data.columns : [];
const normCols = colsRaw.map((c) => {
if (typeof c === 'string' || typeof c === 'number') return String(c);
if (c && typeof c === 'object') {
return String(c.name ?? c.header ?? c.label ?? Object.values(c)[0] ?? '');
}
return '';
}).filter(Boolean);
detected.value = {
columns: normCols,
delimiter: data.detected_delimiter || ',',
has_header: !!data.has_header,
};
detectedNote.value = data.note || '';
// initialize mapping rows if empty
if (!mappingRows.value.length && detected.value.columns.length) {
mappingRows.value = detected.value.columns.map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
}
await loadImportMappings();
// Fallback: if no detected columns were found, but persisted mappings exist, use them to render the grid
if ((!detected.value.columns || detected.value.columns.length === 0) && mappingRows.value.length === 0 && persistedMappings.value.length > 0) {
mappingRows.value = persistedMappings.value.map((m, idx) => {
const tf = String(m.target_field || '');
const [record, field] = tf ? tf.split('.', 2) : ['', ''];
return {
source_column: m.source_column,
entity: recordToEntityKey(record),
field: field || '',
skip: false,
transform: m.transform || 'trim',
apply_mode: m.apply_mode || 'both',
position: idx,
};
});
}
}
async function applyTemplateToImport() {
if (!importId.value || !form.value.import_template_id) return;
try {
await axios.post(route('importTemplates.apply', { template: form.value.import_template_id, import: importId.value }), {}, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
templateApplied.value = true;
await loadImportMappings();
} catch (e) {
templateApplied.value = false;
console.error('Apply template error', e.response?.status || '', e.response?.data || e);
}
}
async function loadImportMappings() {
if (!importId.value) return;
try {
const { data } = await axios.get(route('imports.mappings.get', { import: importId.value }), {
headers: { Accept: 'application/json' },
withCredentials: true,
});
const rows = Array.isArray(data?.mappings) ? data.mappings : [];
// Store raw persisted mappings for display regardless of detected columns
persistedMappings.value = rows.slice();
if (!rows.length) return;
const bySource = new Map(rows.map(r => [normalizeSource(r.source_column), r]));
mappingRows.value = (mappingRows.value || []).map((r, idx) => {
const m = bySource.get(normalizeSource(r.source_column));
if (!m) return r;
const tf = String(m.target_field || '');
let entity = m.entity || '';
let field = r.field || '';
if (tf) {
const [record, fld] = tf.split('.', 2);
const inferred = recordToEntityKey(record);
if (!entity) entity = inferred;
if (fld) field = fld;
}
return {
...r,
entity,
field,
transform: m.transform || '',
apply_mode: m.apply_mode || 'both',
skip: false,
position: idx,
};
});
} catch (e) {
console.error('Load import mappings error', e.response?.status || '', e.response?.data || e);
}
}
async function saveMappings() {
if (!importId.value) return;
mappingError.value = '';
const mappings = mappingRows.value
.filter(r => !r.skip && r.entity && r.field)
.map(r => ({
source_column: r.source_column,
entity: r.entity || null,
target_field: `${entityKeyToRecord(r.entity)}.${r.field}`,
transform: r.transform || null,
apply_mode: r.apply_mode || 'both',
options: null,
}));
if (!mappings.length) {
mappingSaved.value = false;
mappingError.value = 'Select entity and field for at least one column (or uncheck Skip) before saving.';
return;
}
try {
savingMappings.value = true;
const url = route('imports.mappings.save', { import: importId.value });
const { data } = await axios.post(url, { mappings }, {
headers: { 'Accept': 'application/json' },
withCredentials: true,
});
mappingSaved.value = true;
mappingSavedCount.value = Number(data?.saved || mappings.length);
mappingError.value = '';
// Refresh persisted mappings so Process gating reflects the actual DB state
await loadImportMappings();
} catch (e) {
mappingSaved.value = false;
console.error('Save mappings error', e.response?.status || '', e.response?.data || e);
} finally {
savingMappings.value = false;
}
}
async function processImport() {
if (!importId.value) return;
processing.value = true;
processResult.value = null;
try {
const { data } = await axios.post(route('imports.process', { import: importId.value }), {}, {
headers: { Accept: 'application/json' },
withCredentials: true,
});
processResult.value = data;
} catch (e) {
console.error('Process import error', e.response?.status || '', e.response?.data || e);
processResult.value = { error: 'Processing failed' };
} finally {
processing.value = false;
}
}
// Helpers
function entityKeyToRecord(key) {
if (key === 'person_addresses') return 'address';
if (key === 'person_phones') return 'phone';
if (key === 'emails') return 'email';
if (key === 'accounts') return 'account';
if (key === 'contracts') return 'contract';
return 'person';
}
function recordToEntityKey(record) {
switch (record) {
case 'person':
return 'person';
case 'address':
case 'person_address':
case 'person_addresses':
return 'person_addresses';
case 'phone':
case 'person_phone':
case 'person_phones':
return 'person_phones';
case 'email':
case 'emails':
return 'emails';
case 'account':
case 'accounts':
return 'accounts';
case 'contract':
case 'contracts':
return 'contracts';
default:
return '';
}
}
// Initial load
onMounted(async () => {
// Build mapping grid from existing meta columns if present
if (detected.value.columns?.length) {
mappingRows.value = (detected.value.columns || []).map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
await loadImportMappings();
} else {
await fetchColumns();
}
// Load recent events (logs)
await fetchEvents();
});
// Reset saved flag whenever user edits mappings
watch(mappingRows, () => {
mappingSaved.value = false;
mappingSavedCount.value = 0;
mappingError.value = '';
}, { deep: true });
// If detected columns are loaded after mount, initialize mapping rows once
watch(() => detected.value.columns, (cols) => {
if (Array.isArray(cols) && cols.length > 0 && mappingRows.value.length === 0) {
mappingRows.value = cols.map((c, idx) => {
const guess = guessMappingForHeader(c);
return {
source_column: c,
entity: guess?.entity || '',
field: guess?.field || '',
skip: false,
transform: 'trim',
apply_mode: 'both',
position: idx,
};
});
}
});
async function fetchEvents() {
if (!importId.value) return;
loadingEvents.value = true;
try {
const { data } = await axios.get(route('imports.events', { import: importId.value }), {
params: { limit: eventsLimit.value },
headers: { Accept: 'application/json' },
withCredentials: true,
});
events.value = Array.isArray(data?.events) ? data.events : [];
} catch (e) {
console.error('Load import events error', e.response?.status || '', e.response?.data || e);
} finally {
loadingEvents.value = false;
}
}
</script>
<template>
<AppLayout :title="`Import ${props.import?.uuid || ''}`">
<template #header>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-1">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Continue Import</h2>
<div class="text-sm text-gray-600">
<span class="mr-4">Client: <strong>{{ selectedClientOption?.name || selectedClientOption?.uuid || '—' }}</strong></span>
<span>
Template:
<strong>{{ selectedTemplateOption?.name || '—' }}</strong>
<span v-if="templateApplied" class="ml-1 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle">locked</span>
</span>
</div>
</div>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div v-if="isCompleted" class="p-3 border rounded bg-gray-50 text-sm">
<div class="flex flex-wrap gap-x-6 gap-y-1">
<div><span class="text-gray-600">Status:</span> <span class="font-medium text-emerald-700">Completed</span></div>
<div><span class="text-gray-600">Finished:</span> <span class="font-medium">{{ props.import?.finished_at ? new Date(props.import.finished_at).toLocaleString() : '—' }}</span></div>
<div><span class="text-gray-600">Total:</span> <span class="font-medium">{{ props.import?.total_rows ?? '—' }}</span></div>
<div><span class="text-gray-600">Imported:</span> <span class="font-medium">{{ props.import?.imported_rows ?? '—' }}</span></div>
<div><span class="text-gray-600">Invalid:</span> <span class="font-medium">{{ props.import?.invalid_rows ?? '—' }}</span></div>
<div><span class="text-gray-600">Valid:</span> <span class="font-medium">{{ props.import?.valid_rows ?? '—' }}</span></div>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Client</label>
<Multiselect
v-model="selectedClientOption"
:options="clients"
track-by="uuid"
label="name"
placeholder="Search clients..."
:searchable="true"
:allow-empty="true"
class="mt-1"
disabled
/>
<p class="text-xs text-gray-500 mt-1">Client is set during upload.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Template</label>
<Multiselect
v-model="selectedTemplateOption"
:options="filteredTemplates"
track-by="id"
label="name"
placeholder="Search templates..."
:searchable="true"
:allow-empty="true"
class="mt-1"
:disabled="templateApplied"
>
<template #option="{ option }">
<div class="flex items-center justify-between w-full">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span class="ml-2 text-xs text-gray-500">({{ option.source_type }})</span>
</div>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{ option.client_id ? 'Client' : 'Global' }}</span>
</div>
</template>
<template #singleLabel="{ option }">
<div class="flex items-center gap-2">
<span>{{ option.name }}</span>
<span class="ml-1 text-xs text-gray-500">({{ option.source_type }})</span>
<span class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">{{ option.client_id ? 'Client' : 'Global' }}</span>
</div>
</template>
</Multiselect>
</div>
</div>
<div class="flex gap-3" v-if="!isCompleted">
<button
@click.prevent="applyTemplateToImport"
:disabled="!importId || !form.import_template_id || templateApplied"
class="px-4 py-2 bg-emerald-600 disabled:bg-gray-300 text-white rounded"
>
{{ templateApplied ? 'Template Applied' : 'Apply Template' }}
</button>
<button
@click.prevent="saveMappings"
:disabled="!importId || processing || savingMappings || isCompleted"
class="px-4 py-2 bg-orange-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
title="Save ad-hoc mappings for this import"
>
<span v-if="savingMappings" class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"></span>
<span>Save Mappings</span>
<span v-if="selectedMappingsCount" class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded">{{ selectedMappingsCount }}</span>
</button>
<button
@click.prevent="processImport"
:disabled="!canProcess"
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded"
>
{{ processing ? 'Processing…' : 'Process Import' }}
</button>
</div>
<div class="mt-2 text-xs text-gray-600" v-if="!importId">
Import not found.
</div>
<div class="mt-2 text-xs text-gray-600" v-else-if="importId && !hasPersistedMappings && !isCompleted">
Apply a template or select Entity and Field for one or more columns, then click Save Mappings to enable processing.
</div>
<div v-if="persistedMappings.length" class="pt-4">
<h3 class="font-semibold mb-2">Current Saved Mappings</h3>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white text-sm">
<thead>
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Source column</th>
<th class="p-2 border">Target field</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Mode</th>
</tr>
</thead>
<tbody>
<tr v-for="m in persistedMappings" :key="m.id" class="border-t">
<td class="p-2 border">{{ m.source_column }}</td>
<td class="p-2 border">{{ m.target_field }}</td>
<td class="p-2 border">{{ m.transform || '—' }}</td>
<td class="p-2 border">{{ m.apply_mode || 'both' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="!isCompleted && displayRows.length" class="pt-4">
<h3 class="font-semibold mb-2">
<template v-if="!isCompleted">Detected Columns ({{ detected.has_header ? 'header' : 'positional' }})
<span class="ml-2 text-xs text-gray-500">detected: {{ detected.columns.length }}, rows: {{ displayRows.length }}</span>
</template>
<template v-else>Detected Columns</template>
</h3>
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">{{ detectedNote }}</p>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white">
<thead>
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Source column</th>
<th class="p-2 border">Entity</th>
<th class="p-2 border">Field</th>
<th class="p-2 border">Transform</th>
<th class="p-2 border">Apply mode</th>
<th class="p-2 border">Skip</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in displayRows" :key="idx" class="border-t">
<td class="p-2 border text-sm">{{ row.source_column }}</td>
<td class="p-2 border">
<select v-model="row.entity" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value=""></option>
<option v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.field" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value=""></option>
<option v-for="f in fieldOptionsByEntity[row.entity] || []" :key="f.value" :value="f.value">{{ f.label }}</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.transform" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value="">None</option>
<option value="trim">Trim</option>
<option value="upper">Uppercase</option>
<option value="lower">Lowercase</option>
</select>
</td>
<td class="p-2 border">
<select v-model="row.apply_mode" class="border rounded p-1 w-full" :disabled="isCompleted">
<option value="both">Both</option>
<option value="insert">Insert only</option>
<option value="update">Update only</option>
</select>
</td>
<td class="p-2 border text-center">
<input type="checkbox" v-model="row.skip" :disabled="isCompleted" />
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2">Mappings saved ({{ mappingSavedCount }}).</div>
<div v-else-if="mappingError" class="text-sm text-red-600 mt-2">{{ mappingError }}</div>
</div>
<div v-else-if="!isCompleted" class="pt-4">
<h3 class="font-semibold mb-2">Detected Columns</h3>
<p class="text-sm text-gray-600">No columns detected. {{ detectedNote || 'Preview is available for CSV/TXT files. You can still apply a template or use the saved mappings below.' }}</p>
</div>
<div v-if="processResult" class="pt-4">
<h3 class="font-semibold mb-2">Import Result</h3>
<pre class="bg-gray-50 border rounded p-3 text-sm overflow-x-auto">{{ processResult }}</pre>
</div>
<div class="pt-4">
<div class="flex items-center justify-between mb-2">
<h3 class="font-semibold">Logs</h3>
<div class="flex items-center gap-2 text-sm">
<label class="text-gray-600">Show</label>
<select v-model.number="eventsLimit" class="border rounded p-1" @change="fetchEvents">
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="200">200</option>
<option :value="500">500</option>
</select>
<button @click.prevent="fetchEvents" class="px-2 py-1 border rounded text-sm" :disabled="loadingEvents">
{{ loadingEvents ? 'Refreshing…' : 'Refresh' }}
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full border bg-white text-sm">
<thead>
<tr class="bg-gray-50 text-left text-xs uppercase text-gray-600">
<th class="p-2 border">Time</th>
<th class="p-2 border">Level</th>
<th class="p-2 border">Event</th>
<th class="p-2 border">Message</th>
<th class="p-2 border">Row</th>
</tr>
</thead>
<tbody>
<tr v-for="ev in events" :key="ev.id" class="border-t">
<td class="p-2 border whitespace-nowrap">{{ new Date(ev.created_at).toLocaleString() }}</td>
<td class="p-2 border">
<span :class="[
'px-2 py-0.5 rounded text-xs',
ev.level === 'error' ? 'bg-red-100 text-red-800' :
ev.level === 'warning' ? 'bg-amber-100 text-amber-800' :
'bg-gray-100 text-gray-700'
]">{{ ev.level }}</span>
</td>
<td class="p-2 border">{{ ev.event }}</td>
<td class="p-2 border">
<div>{{ ev.message }}</div>
<div v-if="ev.context" class="text-xs text-gray-500">{{ ev.context }}</div>
</td>
<td class="p-2 border">{{ ev.import_row_id ?? '—' }}</td>
</tr>
<tr v-if="!events.length">
<td class="p-3 text-center text-gray-500" colspan="5">No events yet</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
<style scoped>
</style>
+75
View File
@@ -0,0 +1,75 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { Link } from '@inertiajs/vue3';
const props = defineProps({
imports: Object,
});
function statusBadge(status) {
const map = {
uploaded: 'bg-gray-200 text-gray-700',
parsed: 'bg-blue-100 text-blue-800',
validating: 'bg-amber-100 text-amber-800',
completed: 'bg-emerald-100 text-emerald-800',
failed: 'bg-red-100 text-red-800',
};
return map[status] || 'bg-gray-100 text-gray-800';
}
</script>
<template>
<AppLayout title="Uvozi">
<template #header>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozi</h2>
<Link :href="route('imports.create')" class="px-3 py-2 rounded bg-blue-600 text-white text-sm">Novi uvoz</Link>
</div>
</template>
<div class="py-6">
<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead>
<tr class="text-left text-xs uppercase text-gray-500 border-b">
<th class="p-2">Datum</th>
<th class="p-2">Datoteka</th>
<th class="p-2">Status</th>
<th class="p-2">Naročnik</th>
<th class="p-2">Predloga</th>
<th class="p-2">Akcije</th>
</tr>
</thead>
<tbody>
<tr v-for="imp in imports.data" :key="imp.uuid" class="border-b">
<td class="p-2 whitespace-nowrap">{{ new Date(imp.created_at).toLocaleString() }}</td>
<td class="p-2">{{ imp.original_name }}</td>
<td class="p-2"><span :class="['px-2 py-0.5 rounded text-xs', statusBadge(imp.status)]">{{ imp.status }}</span></td>
<td class="p-2">{{ imp.client?.uuid ?? '—' }}</td>
<td class="p-2">{{ imp.template?.name ?? '—' }}</td>
<td class="p-2 space-x-2">
<Link :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-gray-200 text-gray-800 text-xs">Poglej</Link>
<Link v-if="imp.status !== 'completed'" :href="route('imports.continue', { import: imp.uuid })" class="px-2 py-1 rounded bg-amber-600 text-white text-xs">Nadaljuj</Link>
<span v-else class="text-xs text-gray-400">Zaključen</span>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between mt-4 text-sm text-gray-600">
<div>
Prikaz {{ imports.meta.from }}{{ imports.meta.to }} od {{ imports.meta.total }}
</div>
<div class="space-x-2">
<Link v-if="imports.links.prev" :href="imports.links.prev" class="px-2 py-1 border rounded">Nazaj</Link>
<Link v-if="imports.links.next" :href="imports.links.next" class="px-2 py-1 border rounded">Naprej</Link>
</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,123 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
const props = defineProps({
clients: Array,
});
const form = useForm({
name: '',
description: '',
source_type: 'csv',
default_record_type: '',
is_active: true,
client_uuid: null,
entities: [],
});
function submit() {
form.post(route('importTemplates.store'), {
onSuccess: () => {
// You can redirect or show a success message here
},
});
}
</script>
<template>
<AppLayout title="Create Import Template">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Create Import Template</h2>
</template>
<div class="py-6">
<div class="max-w-3xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Client (optional)</label>
<Multiselect
v-model="form.client_uuid"
:options="props.clients || []"
:reduce="c => c.uuid"
track-by="uuid"
label="name"
placeholder="Global (no client)"
:searchable="true"
:allow-empty="true"
class="mt-1"
/>
<p class="text-xs text-gray-500 mt-1">Leave empty to make this template global (visible to all clients).</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Entities (tables)</label>
<Multiselect
v-model="form.entities"
:options="[
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Addresses' },
{ value: 'person_phones', label: 'Person Phones' },
{ value: 'emails', label: 'Emails' },
{ value: 'accounts', label: 'Accounts' },
{ value: 'contracts', label: 'Contracts' },
]"
:multiple="true"
track-by="value"
label="label"
:reduce="o => o.value"
placeholder="Select one or more entities"
:searchable="false"
class="mt-1"
/>
<p class="text-xs text-gray-500 mt-1">Choose which tables this template targets. You can still define per-column mappings later.</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Description</label>
<textarea v-model="form.description" class="mt-1 block w-full border rounded p-2" rows="3" />
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Source Type</label>
<select v-model="form.source_type" class="mt-1 block w-full border rounded p-2">
<option value="csv">CSV</option>
<option value="xml">XML</option>
<option value="xls">XLS</option>
<option value="xlsx">XLSX</option>
<option value="json">JSON</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Default Record Type (optional)</label>
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="e.g., account, person" />
</div>
</div>
<div class="flex items-center gap-2">
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
<label for="is_active" class="text-sm font-medium text-gray-700">Active</label>
</div>
<div class="pt-4">
<button @click.prevent="submit" class="px-4 py-2 bg-emerald-600 text-white rounded" :disabled="form.processing">
{{ form.processing ? 'Saving…' : 'Create Template' }}
</button>
</div>
<div v-if="form.errors && Object.keys(form.errors).length" class="text-sm text-red-600">
<div v-for="(msg, key) in form.errors" :key="key">{{ msg }}</div>
</div>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -0,0 +1,495 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { ref, computed } from 'vue';
import { useForm, Link } from '@inertiajs/vue3';
import Multiselect from 'vue-multiselect';
const props = defineProps({
template: Object,
clients: Array,
});
const form = useForm({
name: props.template.name,
description: props.template.description,
source_type: props.template.source_type,
default_record_type: props.template.default_record_type || '',
is_active: props.template.is_active,
client_uuid: props.template.client_uuid || null,
});
const entities = computed(() => (props.template.meta?.entities || []));
const hasMappings = computed(() => (props.template.mappings?.length || 0) > 0);
const canChangeClient = computed(() => !hasMappings.value); // guard reassignment when mappings exist (optional rule)
// Local state for adding a new mapping row per entity accordion
const newRows = ref({});
const bulkRows = ref({}); // per-entity textarea and options
const bulkGlobal = ref({ entity: '', sources: '', default_field: '', transform: '', apply_mode: 'both' });
const unassigned = computed(() => (props.template.mappings || []).filter(m => !m.target_field));
const unassignedSourceColumns = computed(() => {
const set = new Set();
for (const m of unassigned.value) {
if (m.source_column) set.add(m.source_column);
}
return Array.from(set).sort((a,b)=>a.localeCompare(b));
});
const unassignedState = ref({});
function saveUnassigned(m) {
const st = unassignedState.value[m.id] || {};
if (st.entity && st.field) {
m.target_field = `${st.entity}.${st.field}`;
} else {
m.target_field = null;
}
updateMapping(m);
}
const entityOptions = [
{ key: 'person', label: 'Person' },
{ key: 'person_addresses', label: 'Person Addresses' },
{ key: 'person_phones', label: 'Person Phones' },
{ key: 'emails', label: 'Emails' },
{ key: 'accounts', label: 'Accounts' },
{ key: 'contracts', label: 'Contracts' },
];
const fieldOptions = {
person: [
'first_name', 'last_name', 'full_name', 'gender', 'birthday', 'tax_number', 'social_security_number', 'description'
],
person_addresses: [ 'address', 'country', 'type_id', 'description' ],
person_phones: [ 'nu', 'country_code', 'type_id', 'description' ],
emails: [ 'email', 'is_primary' ],
accounts: [ 'reference', 'balance_amount', 'contract_id', 'contract_reference' ],
contracts: [ 'reference', 'start_date', 'end_date', 'description', 'type_id', 'client_case_id' ],
};
function toggle(entity) {
const el = document.getElementById(`acc-${entity}`);
if (el) el.open = !el.open;
}
function addRow(entity) {
const row = newRows.value[entity];
if (!row || !row.source || !row.field) return;
const target_field = `${entity}.${row.field}`;
const payload = {
source_column: row.source,
target_field,
transform: row.transform || null,
apply_mode: row.apply_mode || 'both',
position: (props.template.mappings?.length || 0) + 1,
};
useForm(payload).post(route('importTemplates.mappings.add', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { newRows.value[entity] = {}; },
});
}
function updateMapping(m) {
const payload = {
source_column: m.source_column,
target_field: m.target_field,
transform: m.transform,
apply_mode: m.apply_mode,
position: m.position,
};
useForm(payload).put(route('importTemplates.mappings.update', { template: props.template.uuid, mapping: m.id }), {
preserveScroll: true,
});
}
function deleteMapping(m) {
useForm({}).delete(route('importTemplates.mappings.delete', { template: props.template.uuid, mapping: m.id }), {
preserveScroll: true,
});
}
function reorder(entity, direction, m) {
// Build new order across all mappings, swapping positions for this entity scope
const all = [...props.template.mappings];
const entityMaps = all.filter(x => x.target_field?.startsWith(entity + '.'));
const idx = entityMaps.findIndex(x => x.id === m.id);
if (idx < 0) return;
const swapIdx = direction === 'up' ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= entityMaps.length) return;
const a = entityMaps[idx];
const b = entityMaps[swapIdx];
// Build final ordered ids list using current order, swapping a/b positions
const byId = Object.fromEntries(all.map(x => [x.id, x]));
const ordered = all.map(x => x.id);
const ai = ordered.indexOf(a.id);
const bi = ordered.indexOf(b.id);
if (ai < 0 || bi < 0) return;
[ordered[ai], ordered[bi]] = [ordered[bi], ordered[ai]];
useForm({ order: ordered }).post(route('importTemplates.mappings.reorder', { template: props.template.uuid }), {
preserveScroll: true,
});
}
// Save basic
const save = () => {
const payload = { ...form.data() };
if (!canChangeClient.value) {
// drop client change when blocked
delete payload.client_uuid;
}
useForm(payload).put(route('importTemplates.update', { template: props.template.uuid }), { preserveScroll: true });
};
// Non-blocking confirm modal state for delete
const deleteConfirmOpen = ref(false);
const deleteForm = useForm({});
function openDeleteConfirm() { deleteConfirmOpen.value = true; }
function cancelDelete() { deleteConfirmOpen.value = false; }
function performDelete() {
deleteForm.delete(route('importTemplates.destroy', { template: props.template.uuid }), {
onFinish: () => { deleteConfirmOpen.value = false; },
});
}
</script>
<template>
<AppLayout :title="`Edit Template: ${props.template.name}`">
<template #header>
<div class="flex items-center justify-between">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uredi uvozno predlogo</h2>
<div class="flex items-center gap-2">
<Link :href="route('importTemplates.index')" class="px-3 py-1.5 border rounded text-sm">Nazaj</Link>
<button class="px-3 py-1.5 border rounded text-sm text-red-700 border-red-300 hover:bg-red-50" @click.prevent="openDeleteConfirm">Izbriši predlogo</button>
</div>
</div>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6 space-y-6">
<!-- Basic info -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">Ime predloge</label>
<input v-model="form.name" type="text" class="mt-1 block w-full border rounded p-2" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Vir</label>
<select v-model="form.source_type" class="mt-1 block w-full border rounded p-2">
<option value="csv">CSV</option>
<option value="xml">XML</option>
<option value="xls">XLS</option>
<option value="xlsx">XLSX</option>
<option value="json">JSON</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Privzeti tip zapisa</label>
<input v-model="form.default_record_type" type="text" class="mt-1 block w-full border rounded p-2" placeholder="npr.: account, person" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Naročnik (opcijsko)</label>
<Multiselect
v-model="form.client_uuid"
:options="props.clients || []"
:reduce="c => c.uuid"
track-by="uuid"
label="name"
placeholder="Global (brez naročnika)"
:searchable="true"
:allow-empty="true"
class="mt-1"
:disabled="!canChangeClient"
/>
<p v-if="!canChangeClient" class="text-xs text-amber-600 mt-1">Ni mogoče spremeniti naročnika, ker ta predloga že vsebuje preslikave.</p>
</div>
<div class="flex items-center gap-2">
<input id="is_active" v-model="form.is_active" type="checkbox" class="rounded" />
<label for="is_active" class="text-sm font-medium text-gray-700">Aktivna</label>
<button @click.prevent="save" class="ml-auto px-3 py-2 bg-indigo-600 text-white rounded">Shrani</button>
</div>
</div>
<!-- Sample headers viewer/editor -->
<div class="p-3 bg-gray-50 rounded border">
<div class="flex items-center justify-between mb-2">
<div class="text-sm font-medium text-gray-700">Vzorčni glavi stolpcev</div>
<button class="text-xs px-2 py-1 border rounded" @click.prevent="form.sample_headers = (form.sample_headers || []).concat([''])">Dodaj stolpec</button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div v-for="(h, i) in (form.sample_headers = form.sample_headers ?? props.template.sample_headers ?? [])" :key="i" class="flex items-center gap-2">
<input v-model="form.sample_headers[i]" type="text" class="flex-1 border rounded p-2" placeholder="npr.: reference" />
<button class="px-2 py-1 border rounded" @click.prevent="form.sample_headers.splice(i,1)"></button>
</div>
</div>
</div>
<!-- Global Bulk Add Mappings -->
<div class="p-3 bg-indigo-50 rounded border border-indigo-200">
<div class="mb-2 text-sm font-medium text-indigo-900">Bulk dodajanje preslikav</div>
<div class="grid grid-cols-1 sm:grid-cols-6 gap-3 items-start">
<div class="sm:col-span-3">
<label class="block text-xs text-indigo-900">Source columns (CSV ali po vrsticah)</label>
<textarea v-model="bulkGlobal.sources" rows="3" class="mt-1 w-full border rounded p-2" placeholder="npr.: reference,first name,last name,amount"></textarea>
</div>
<div>
<label class="block text-xs text-indigo-900">Entity (opcijsko)</label>
<select v-model="bulkGlobal.entity" class="mt-1 w-full border rounded p-2">
<option value="">(brez pusti target prazno)</option>
<option v-for="opt in entityOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Field (opcijsko, za vse)</label>
<select v-model="bulkGlobal.default_field" class="mt-1 w-full border rounded p-2" :disabled="!bulkGlobal.entity">
<option value="">(auto from source)</option>
<option v-for="f in (fieldOptions[bulkGlobal.entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Transform (za vse)</label>
<select v-model="bulkGlobal.transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-indigo-900">Apply (za vse)</label>
<select v-model="bulkGlobal.apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
</div>
<div class="mt-3">
<button
@click.prevent="(() => {
if (!bulkGlobal.sources || !bulkGlobal.sources.trim()) return;
useForm({
sources: bulkGlobal.sources,
entity: bulkGlobal.entity || null,
default_field: bulkGlobal.default_field || null,
transform: bulkGlobal.transform || null,
apply_mode: bulkGlobal.apply_mode || 'both',
}).post(route('importTemplates.mappings.bulk', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { bulkGlobal.entity=''; bulkGlobal.sources=''; bulkGlobal.default_field=''; bulkGlobal.transform=''; bulkGlobal.apply_mode='both'; },
});
})()"
class="px-3 py-2 bg-indigo-600 text-white rounded"
>Dodaj več</button>
</div>
</div>
<!-- Unassigned mappings (no target_field) -->
<div v-if="unassigned.length" class="p-3 bg-amber-50 rounded border border-amber-200">
<div class="mb-2 text-sm font-medium text-amber-900">Nedodeljene preslikave ({{ unassigned.length }})</div>
<div class="space-y-2">
<div v-for="m in unassigned" :key="m.id" class="p-2 bg-white/60 border rounded">
<div class="grid grid-cols-1 sm:grid-cols-6 gap-2 items-center">
<div class="text-sm">
<div class="text-gray-500 text-xs">Source</div>
<div class="font-medium">{{ m.source_column }}</div>
</div>
<div>
<label class="block text-xs text-gray-600">Entity</label>
<select v-model="(unassignedState[m.id] ||= {}).entity" class="mt-1 w-full border rounded p-2">
<option value="">(izberi)</option>
<option v-for="opt in entityOptions" :key="opt.key" :value="opt.key">{{ opt.label }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Field</label>
<select v-model="(unassignedState[m.id] ||= {}).field" class="mt-1 w-full border rounded p-2" :disabled="!(unassignedState[m.id]||{}).entity">
<option value="">(izberi)</option>
<option v-for="f in (fieldOptions[(unassignedState[m.id]||{}).entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Transform</label>
<select v-model="m.transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply</label>
<select v-model="m.apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm" @click.prevent="saveUnassigned(m)">Shrani</button>
<button class="px-3 py-1.5 bg-red-600 text-white rounded text-sm" @click.prevent="deleteMapping(m)">Izbriši</button>
</div>
</div>
</div>
</div>
</div>
<!-- Entities accordion -->
<div class="divide-y">
<details v-for="entity in entities" :key="entity" :id="`acc-${entity}`" class="py-3">
<summary class="cursor-pointer select-none flex items-center justify-between">
<span class="font-medium">{{ entityOptions.find(e=>e.key===entity)?.label || entity }}</span>
<span class="text-xs text-gray-500">Klikni za razširitev</span>
</summary>
<div class="mt-4 space-y-4">
<!-- Existing mappings for this entity -->
<div v-if="props.template.mappings && props.template.mappings.length" class="space-y-2">
<div v-for="m in props.template.mappings.filter(m=>m.target_field?.startsWith(entity + '.'))" :key="m.id" class="flex items-center justify-between p-2 border rounded gap-3">
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 flex-1 items-center">
<input v-model="m.source_column" class="border rounded p-2 text-sm" />
<input v-model="m.target_field" class="border rounded p-2 text-sm" />
<select v-model="m.transform" class="border rounded p-2 text-sm">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
<select v-model="m.apply_mode" class="border rounded p-2 text-sm">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
<div class="flex items-center gap-2">
<button class="px-2 py-1 text-xs border rounded" @click.prevent="reorder(entity, 'up', m)"></button>
<button class="px-2 py-1 text-xs border rounded" @click.prevent="reorder(entity, 'down', m)"></button>
</div>
</div>
<div class="flex items-center gap-2">
<button class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm" @click.prevent="updateMapping(m)">Shrani</button>
<button class="px-3 py-1.5 bg-red-600 text-white rounded text-sm" @click.prevent="deleteMapping(m)">Izbriši</button>
</div>
</div>
</div>
<div v-else class="text-sm text-gray-500">Ni definiranih preslikav za to entiteto.</div>
<!-- Add new mapping row -->
<div class="p-3 bg-gray-50 rounded border">
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-end">
<div>
<label class="block text-xs text-gray-600">Source column (ne-dodeljene)</label>
<select v-model="(newRows[entity] ||= {}).source" class="mt-1 w-full border rounded p-2">
<option value="" disabled>(izberi)</option>
<option v-for="s in unassignedSourceColumns" :key="s" :value="s">{{ s }}</option>
</select>
<p v-if="!unassignedSourceColumns.length" class="text-xs text-gray-500 mt-1">Ni nedodeljenih virov. Uporabi Bulk ali najprej dodaj vire.</p>
</div>
<div>
<label class="block text-xs text-gray-600">Field</label>
<select v-model="(newRows[entity] ||= {}).field" class="mt-1 w-full border rounded p-2">
<option v-for="f in (fieldOptions[entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Transform</label>
<select v-model="(newRows[entity] ||= {}).transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply</label>
<select v-model="(newRows[entity] ||= {}).apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
<div class="sm:col-span-1">
<button @click.prevent="addRow(entity)" class="w-full sm:w-auto px-3 py-2 bg-emerald-600 text-white rounded">Dodaj preslikavo</button>
</div>
</div>
</div>
<!-- Bulk add mapping rows -->
<div class="p-3 bg-gray-50 rounded border">
<div class="mb-2 text-xs text-gray-600">Dodaj več stolpcev naenkrat (ločeno z vejicami ali novimi vrsticami). Če polje ne izbereš, bo target nastavljen na entity + ime stolpca.</div>
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-start">
<div class="sm:col-span-2">
<label class="block text-xs text-gray-600">Source columns (CSV ali po vrsticah)</label>
<textarea v-model="(bulkRows[entity] ||= {}).sources" rows="3" class="mt-1 w-full border rounded p-2" placeholder="npr.: reference,first name,last name"></textarea>
</div>
<div>
<label class="block text-xs text-gray-600">Field (opcijsko, za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).default_field" class="mt-1 w-full border rounded p-2">
<option value="">(auto from source)</option>
<option v-for="f in (fieldOptions[entity] || [])" :key="f" :value="f">{{ f }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Transform (za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).transform" class="mt-1 w-full border rounded p-2">
<option value="">None</option>
<option value="trim">trim</option>
<option value="upper">upper</option>
<option value="lower">lower</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-600">Apply (za vse)</label>
<select v-model="(bulkRows[entity] ||= {}).apply_mode" class="mt-1 w-full border rounded p-2">
<option value="both">both</option>
<option value="insert">insert</option>
<option value="update">update</option>
</select>
</div>
</div>
<div class="mt-2">
<button
@click.prevent="(() => {
const b = bulkRows[entity] ||= {};
if (!b.sources || !b.sources.trim()) return;
useForm({
sources: b.sources,
entity,
default_field: b.default_field || null,
transform: b.transform || null,
apply_mode: b.apply_mode || 'both',
}).post(route('importTemplates.mappings.bulk', { template: props.template.uuid }), {
preserveScroll: true,
onSuccess: () => { bulkRows[entity] = {}; },
});
})()"
class="px-3 py-2 bg-indigo-600 text-white rounded"
>Dodaj več</button>
</div>
</div>
</div>
</details>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="deleteConfirmOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancelDelete"></div>
<div class="relative bg-white rounded shadow-lg w-96 max-w-[90%] p-5">
<div class="text-lg font-semibold mb-2">Izbrišem predlogo?</div>
<p class="text-sm text-gray-600 mb-4">Tega dejanja ni mogoče razveljaviti. Vse preslikave te predloge bodo izbrisane.</p>
<div class="flex items-center justify-end gap-2">
<button class="px-3 py-1.5 border rounded" @click.prevent="cancelDelete" :disabled="deleteForm.processing">Prekliči</button>
<button class="px-3 py-1.5 rounded text-white bg-red-600 disabled:opacity-60" @click.prevent="performDelete" :disabled="deleteForm.processing">
<span v-if="deleteForm.processing">Brisanje</span>
<span v-else>Izbriši</span>
</button>
</div>
</div>
</div>
</AppLayout>
</template>
<style>
.fade-enter-active, .fade-leave-active { transition: opacity .2s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
<!-- moved modal into main template to avoid multiple <template> blocks -->
@@ -0,0 +1,88 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
// Non-blocking confirm modal state
const confirmOpen = ref(false);
const confirmUuid = ref(null);
const deleteForm = useForm({});
function requestDelete(uuid) {
confirmUuid.value = uuid;
confirmOpen.value = true;
}
function performDelete() {
if (!confirmUuid.value) return;
deleteForm.delete(route('importTemplates.destroy', { template: confirmUuid.value }), {
preserveScroll: true,
onFinish: () => {
confirmOpen.value = false;
confirmUuid.value = null;
},
});
}
function cancelDelete() {
confirmOpen.value = false;
confirmUuid.value = null;
}
const props = defineProps({
templates: Array,
});
</script>
<template>
<AppLayout title="Uvozne predloge">
<template #header>
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Uvozne predloge</h2>
</template>
<div class="py-6">
<div class="max-w-5xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white shadow sm:rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<div class="text-sm text-gray-600">Skupaj: {{ props.templates?.length || 0 }}</div>
<Link :href="route('importTemplates.create')" class="px-3 py-2 bg-emerald-600 text-white rounded">Nova predloga</Link>
</div>
<div class="divide-y">
<div v-for="t in props.templates" :key="t.uuid" class="py-3 flex items-center justify-between">
<div>
<div class="font-medium">{{ t.name }}</div>
<div class="text-xs text-gray-500">{{ t.description }}</div>
<div class="text-xs text-gray-400 mt-1">{{ t.client?.name || 'Global' }} {{ t.source_type.toUpperCase() }}</div>
</div>
<div class="flex items-center gap-2">
<span :class="['text-xs px-2 py-0.5 rounded', t.is_active ? 'bg-emerald-50 text-emerald-700' : 'bg-gray-100 text-gray-500']">{{ t.is_active ? 'Active' : 'Inactive' }}</span>
<Link :href="route('importTemplates.edit', { template: t.uuid })" class="px-3 py-1.5 border rounded text-sm">Uredi</Link>
<button
class="px-3 py-1.5 border rounded text-sm text-red-700 border-red-300 hover:bg-red-50"
@click.prevent="requestDelete(t.uuid)"
>Izbriši</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Confirm Delete Modal -->
<div v-if="confirmOpen" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/30" @click="cancelDelete"></div>
<div class="relative bg-white rounded shadow-lg w-96 max-w-[90%] p-5">
<div class="text-lg font-semibold mb-2">Izbrišem predlogo?</div>
<p class="text-sm text-gray-600 mb-4">Tega dejanja ni mogoče razveljaviti. Vse preslikave te predloge bodo izbrisane.</p>
<div class="flex items-center justify-end gap-2">
<button class="px-3 py-1.5 border rounded" @click.prevent="cancelDelete" :disabled="deleteForm.processing">Prekliči</button>
<button class="px-3 py-1.5 rounded text-white bg-red-600 disabled:opacity-60" @click.prevent="performDelete" :disabled="deleteForm.processing">
<span v-if="deleteForm.processing">Brisanje</span>
<span v-else>Izbriši</span>
</button>
</div>
</div>
</div>
</AppLayout>
</template>
@@ -1,7 +1,7 @@
<script setup>
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow, FwbDropdown } from 'flowbite-vue';
import { DottedMenu, EditIcon, TrashBinIcon } from '@/Utilities/Icons';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { onMounted, ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import InputLabel from '@/Components/InputLabel.vue';
@@ -134,7 +134,7 @@ const store = () => {
</fwb-table-row>
</fwb-table-body>
</fwb-table>
<Drawer
<DialogModal
:show="drawerEdit"
@close="closeEditDrawer"
>
@@ -211,9 +211,9 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
<Drawer
<DialogModal
:show="drawerCreate"
@close="closeCreateDrawer"
>
@@ -285,5 +285,5 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
@@ -1,7 +1,7 @@
<script setup>
import { FwbTable, FwbTableBody, FwbTableHead, FwbTableHeadCell, FwbTableCell, FwbTableRow } from 'flowbite-vue';
import { EditIcon } from '@/Utilities/Icons';
import Drawer from '@/Components/Drawer.vue';
import DialogModal from '@/Components/DialogModal.vue';
import { onMounted, ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import InputLabel from '@/Components/InputLabel.vue';
@@ -116,7 +116,7 @@ const store = () => {
</fwb-table-body>
</fwb-table>
<Drawer
<DialogModal
:show="drawerEdit"
@close="closeEditDrawer"
>
@@ -174,9 +174,9 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
<Drawer
<DialogModal
:show="drawerCreate"
@close="closeCreateDrawer"
>
@@ -234,5 +234,5 @@ const store = () => {
</div>
</form>
</template>
</Drawer>
</DialogModal>
</template>
+25
View File
@@ -0,0 +1,25 @@
export type DocLike = { uuid: string; original_name?: string; name?: string; extension?: string; mime_type?: string }
const PREVIEW_EXTS = ['pdf', 'txt', 'jpeg', 'jpg', 'png', 'doc', 'docx']
const DOWNLOAD_ONLY_EXTS = ['xls', 'xlsx', 'csv']
export function getExtension(doc: DocLike): string {
const ext = doc.extension || (doc.original_name || doc.name || '').split('.').pop() || ''
return ext.toLowerCase()
}
export function isPreviewableExt(ext?: string): boolean {
if (!ext) return false
return PREVIEW_EXTS.includes(ext.toLowerCase())
}
export function isDownloadOnlyExt(ext?: string): boolean {
if (!ext) return false
return DOWNLOAD_ONLY_EXTS.includes(ext.toLowerCase())
}
export function classifyDocument(doc: DocLike): 'preview' | 'download' {
const ext = getExtension(doc)
if (isPreviewableExt(ext)) return 'preview'
return 'download'
}
+2
View File
@@ -8,6 +8,7 @@ import { ZiggyVue } from '../../vendor/tightenco/ziggy';
import VueApexCharts from 'vue3-apexcharts';
import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css';
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome';
const appName = import.meta.env.VITE_APP_NAME || 'Laravel';
@@ -20,6 +21,7 @@ createInertiaApp({
.use(ZiggyVue)
.use(VueApexCharts)
.component('vue-date-picker', VueDatePicker)
.component('FontAwesomeIcon', FontAwesomeIcon)
.mount(el);
},
progress: {
+6
View File
@@ -2,3 +2,9 @@ import axios from 'axios';
window.axios = axios;
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
// Ensure CSRF token is sent with axios requests (useful when not relying on XSRF cookie)
const token = document.head && document.head.querySelector('meta[name="csrf-token"]');
if (token) {
window.axios.defaults.headers.common['X-CSRF-TOKEN'] = token.getAttribute('content');
}