Changes to import / template pages frontend updated design

This commit is contained in:
Simon Pocrnjič 2025-12-22 20:52:45 +01:00
parent ee641586c3
commit f8623a6071
30 changed files with 2349 additions and 1839 deletions

View File

@ -21,14 +21,35 @@ class ImportController extends Controller
// List imports (paginated)
public function index(Request $request)
{
$paginator = Import::query()
$query = Import::query()
->with([
'client:id,uuid,person_id',
'client.person:id,uuid,full_name',
'template:id,name',
])
->orderByDesc('created_at')
->paginate(15);
->orderByDesc('created_at');
// Apply search filter
if ($search = $request->input('search')) {
$query->where(function ($q) use ($search) {
$q->where('original_name', 'LIKE', "%{$search}%")
->orWhere('status', 'LIKE', "%{$search}%")
->orWhereHas('client.person', function ($q) use ($search) {
$q->where('full_name', 'LIKE', "%{$search}%");
})
->orWhereHas('template', function ($q) use ($search) {
$q->where('name', 'LIKE', "%{$search}%");
});
});
}
// Get per_page from request, default to 25
$perPage = (int) $request->input('per_page', 25);
if ($perPage < 1 || $perPage > 100) {
$perPage = 25;
}
$paginator = $query->paginate($perPage);
$imports = [
'data' => $paginator->items(),

View File

@ -547,6 +547,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
'options' => 'nullable|array',
'position' => 'nullable|integer',
])->validate();
$mapping->update([
'source_column' => $data['source_column'],
'entity' => $data['entity'] ?? null,
@ -557,8 +558,7 @@ public function updateMapping(Request $request, ImportTemplate $template, Import
'position' => $data['position'] ?? $mapping->position,
]);
return redirect()->route('importTemplates.edit', ['template' => $template->uuid])
->with('success', 'Mapping updated');
return back()->with('success', 'Mapping updated');
}
// Delete a mapping

713
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -41,6 +41,7 @@
"@vueuse/core": "^14.1.0",
"apexcharts": "^4.7.0",
"class-variance-authority": "^0.7.1",
"clean": "^4.0.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",

View File

@ -0,0 +1,25 @@
<script setup>
import { AccordionRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
collapsible: { type: Boolean, required: false },
disabled: { type: Boolean, required: false },
dir: { type: String, required: false },
orientation: { type: String, required: false },
unmountOnHide: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
type: { type: String, required: false },
modelValue: { type: null, required: false },
defaultValue: { type: null, required: false },
});
const emits = defineEmits(["update:modelValue"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AccordionRoot v-bind="forwarded">
<slot />
</AccordionRoot>
</template>

View File

@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AccordionContent } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AccordionContent
v-bind="delegatedProps"
class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
>
<div :class="cn('pb-4 pt-0', props.class)">
<slot />
</div>
</AccordionContent>
</template>

View File

@ -0,0 +1,24 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AccordionItem, useForwardProps } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
disabled: { type: Boolean, required: false },
value: { type: String, required: true },
unmountOnHide: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
const forwardedProps = useForwardProps(delegatedProps);
</script>
<template>
<AccordionItem v-bind="forwardedProps" :class="cn('border-b', props.class)">
<slot />
</AccordionItem>
</template>

View File

@ -0,0 +1,35 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { ChevronDown } from "lucide-vue-next";
import { AccordionHeader, AccordionTrigger } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AccordionHeader class="flex">
<AccordionTrigger
v-bind="delegatedProps"
:class="
cn(
'flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
props.class,
)
"
>
<slot />
<slot name="icon">
<ChevronDown
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
/>
</slot>
</AccordionTrigger>
</AccordionHeader>
</template>

View File

@ -0,0 +1,4 @@
export { default as Accordion } from "./Accordion.vue";
export { default as AccordionContent } from "./AccordionContent.vue";
export { default as AccordionItem } from "./AccordionItem.vue";
export { default as AccordionTrigger } from "./AccordionTrigger.vue";

View File

@ -0,0 +1,17 @@
<script setup>
import { AlertDialogRoot, useForwardPropsEmits } from "reka-ui";
const props = defineProps({
open: { type: Boolean, required: false },
defaultOpen: { type: Boolean, required: false },
});
const emits = defineEmits(["update:open"]);
const forwarded = useForwardPropsEmits(props, emits);
</script>
<template>
<AlertDialogRoot v-bind="forwarded">
<slot />
</AlertDialogRoot>
</template>

View File

@ -0,0 +1,23 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AlertDialogAction } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/Components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AlertDialogAction
v-bind="delegatedProps"
:class="cn(buttonVariants(), props.class)"
>
<slot />
</AlertDialogAction>
</template>

View File

@ -0,0 +1,25 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AlertDialogCancel } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from '@/Components/ui/button';
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AlertDialogCancel
v-bind="delegatedProps"
:class="
cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', props.class)
"
>
<slot />
</AlertDialogCancel>
</template>

View File

@ -0,0 +1,49 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import {
AlertDialogContent,
AlertDialogOverlay,
AlertDialogPortal,
useForwardPropsEmits,
} from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
forceMount: { type: Boolean, required: false },
disableOutsidePointerEvents: { type: Boolean, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const emits = defineEmits([
"escapeKeyDown",
"pointerDownOutside",
"focusOutside",
"interactOutside",
"openAutoFocus",
"closeAutoFocus",
]);
const delegatedProps = reactiveOmit(props, "class");
const forwarded = useForwardPropsEmits(delegatedProps, emits);
</script>
<template>
<AlertDialogPortal>
<AlertDialogOverlay
class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
/>
<AlertDialogContent
v-bind="forwarded"
:class="
cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
props.class,
)
"
>
<slot />
</AlertDialogContent>
</AlertDialogPortal>
</template>

View File

@ -0,0 +1,22 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AlertDialogDescription } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AlertDialogDescription
v-bind="delegatedProps"
:class="cn('text-sm text-muted-foreground', props.class)"
>
<slot />
</AlertDialogDescription>
</template>

View File

@ -0,0 +1,20 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
:class="
cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-x-2',
props.class,
)
"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,15 @@
<script setup>
import { cn } from "@/lib/utils";
const props = defineProps({
class: { type: null, required: false },
});
</script>
<template>
<div
:class="cn('flex flex-col gap-y-2 text-center sm:text-left', props.class)"
>
<slot />
</div>
</template>

View File

@ -0,0 +1,22 @@
<script setup>
import { reactiveOmit } from "@vueuse/core";
import { AlertDialogTitle } from "reka-ui";
import { cn } from "@/lib/utils";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
class: { type: null, required: false },
});
const delegatedProps = reactiveOmit(props, "class");
</script>
<template>
<AlertDialogTitle
v-bind="delegatedProps"
:class="cn('text-lg font-semibold', props.class)"
>
<slot />
</AlertDialogTitle>
</template>

View File

@ -0,0 +1,14 @@
<script setup>
import { AlertDialogTrigger } from "reka-ui";
const props = defineProps({
asChild: { type: Boolean, required: false },
as: { type: null, required: false },
});
</script>
<template>
<AlertDialogTrigger v-bind="props">
<slot />
</AlertDialogTrigger>
</template>

View File

@ -0,0 +1,9 @@
export { default as AlertDialog } from "./AlertDialog.vue";
export { default as AlertDialogAction } from "./AlertDialogAction.vue";
export { default as AlertDialogCancel } from "./AlertDialogCancel.vue";
export { default as AlertDialogContent } from "./AlertDialogContent.vue";
export { default as AlertDialogDescription } from "./AlertDialogDescription.vue";
export { default as AlertDialogFooter } from "./AlertDialogFooter.vue";
export { default as AlertDialogHeader } from "./AlertDialogHeader.vue";
export { default as AlertDialogTitle } from "./AlertDialogTitle.vue";
export { default as AlertDialogTrigger } from "./AlertDialogTrigger.vue";

View File

@ -1,20 +1,15 @@
<script setup lang="ts">
import type { PrimitiveProps } from "reka-ui"
import type { HTMLAttributes } from "vue"
import type { ButtonVariants } from "."
import { Primitive } from "reka-ui"
import { cn } from "@/lib/utils"
import { buttonVariants } from "."
<script setup>
import { Primitive } from "reka-ui";
import { cn } from "@/lib/utils";
import { buttonVariants } from ".";
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"]
size?: ButtonVariants["size"]
class?: HTMLAttributes["class"]
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
})
const props = defineProps({
variant: { type: null, required: false },
size: { type: null, required: false },
class: { type: null, required: false },
asChild: { type: Boolean, required: false },
as: { type: null, required: false, default: "button" },
});
</script>
<template>

View File

@ -146,7 +146,7 @@ function formatDate(value) {
</div>
<div class="pt-1">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<Card class="p-0">
<Card class="p-0!">
<div class="p-3">
<PersonInfoGrid
:types="types"
@ -163,7 +163,7 @@ function formatDate(value) {
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
<div class="mx-auto max-w-4x1">
<div class="px-3 py-4 flex flex-row items-center gap-3">
<div class="p-2 flex flex-row items-center gap-2">
<Link
:class="
cn(

View File

@ -1,10 +1,9 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { ref, computed } from "vue";
import ConfirmationModal from "@/Components/ConfirmationModal.vue";
import DataTableServer from "@/Components/DataTable/DataTableServer.vue";
import Dropdown from "@/Components/Dropdown.vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import {
faEllipsisVertical,
@ -13,6 +12,13 @@ import {
faTrash,
faCircleCheck,
} from "@fortawesome/free-solid-svg-icons";
import TableActions from "@/Components/DataTable/TableActions.vue";
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { ImportIcon } from "lucide-vue-next";
import { CardTitle } from "@/Components/ui/card";
const props = defineProps({
imports: Object,
@ -23,6 +29,8 @@ const confirming = ref(false);
const errorMsg = ref(null);
const search = ref(new URLSearchParams(window.location.search).get("search") || "");
const rows = computed(() => props.imports?.data || []);
function canDelete(status) {
return !["completed", "processing"].includes(status);
}
@ -59,13 +67,33 @@ function statusBadge(status) {
return map[status] || "bg-gray-100 text-gray-800";
}
function applySearch() {
const params = {};
const currentParams = new URLSearchParams(window.location.search);
for (const [key, value] of currentParams.entries()) {
if (key !== "search" && key !== "page") {
params[key] = value;
}
}
const term = (search.value || "").trim();
if (term) {
params.search = term;
}
router.get(route("imports.index"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
only: ["imports"],
});
}
const columns = [
{ key: "created_at", label: "Datum" },
{ key: "original_name", label: "Datoteka" },
{ key: "status", label: "Status" },
{ key: "client", label: "Naročnik" },
{ key: "template", label: "Predloga" },
{ key: "actions", label: "Akcije", class: "w-px" },
{ key: "created_at", label: "Datum", sortable: false },
{ key: "original_name", label: "Datoteka", sortable: false },
{ key: "status", label: "Status", sortable: false },
{ key: "client", label: "Naročnik", sortable: false },
{ key: "template", label: "Predloga", sortable: false },
{ key: "actions", label: "", sortable: false, hideable: false, align: "center" },
];
function formatDateTimeNoSeconds(value) {
@ -84,41 +112,61 @@ function formatDateTimeNoSeconds(value) {
<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>
</div>
</template>
<template #header> </template>
<div class="py-6">
<div class="max-w-6xl mx-auto sm:px-6 lg:px-8">
<div class="flex flex-col gap-3 bg-white shadow sm:rounded-lg p-4">
<div class="flex justify-end">
<Link
:href="route('imports.create')"
class="px-3 py-2 rounded bg-blue-600 text-white text-sm"
>Novi uvoz</Link
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<ImportIcon size="18" />
<CardTitle class="uppercase">Uvozi</CardTitle>
</div>
<DataTableServer
</template>
<DataTable
:columns="columns"
:rows="imports?.data || []"
:meta="
imports?.meta
? {
current_page: imports.meta.current_page,
per_page: imports.meta.per_page,
total: imports.meta.total,
last_page: imports.meta.last_page,
}
: {}
"
v-model:search="search"
:data="rows"
:meta="{
current_page: imports?.meta?.current_page,
per_page: imports?.meta?.per_page,
total: imports?.meta?.total,
last_page: imports?.meta?.last_page,
from: imports?.meta?.from,
to: imports?.meta?.to,
links: imports?.links,
}"
route-name="imports.index"
:only-props="['imports']"
:page-size="25"
:page-size-options="[10, 15, 25, 50, 100]"
:show-pagination="true"
:show-toolbar="true"
:hoverable="true"
row-key="uuid"
empty-text="Ni uvozov."
>
<template #toolbar-actions>
<Button size="sm" variant="default" as-child>
<Link :href="route('imports.create')">Novi uvoz</Link>
</Button>
</template>
<template #toolbar-filters>
<div class="flex items-center gap-2">
<Input
v-model="search"
placeholder="Išči uvoz..."
class="w-65"
@keydown.enter="applySearch"
/>
<Button size="sm" variant="outline" @click="applySearch">Išči</Button>
</div>
</template>
<!-- Datum column formatted -->
<template #cell-created_at="{ row }">
{{ formatDateTimeNoSeconds(row.created_at) }}
@ -143,60 +191,40 @@ function formatDateTimeNoSeconds(value) {
<!-- Actions -->
<template #cell-actions="{ row }">
<Dropdown width="48" :close-on-content-click="true">
<template #trigger>
<button
type="button"
class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-gray-100"
aria-label="Akcije"
>
<FontAwesomeIcon
:icon="faEllipsisVertical"
class="w-4 h-4 text-gray-600"
<TableActions align="right">
<template #default>
<ActionMenuItem
:icon="faEye"
label="Poglej"
@click="
$inertia.visit(route('imports.continue', { import: row.uuid }))
"
/>
</button>
</template>
<template #content>
<div class="py-1">
<Link
:href="route('imports.continue', { import: row.uuid })"
class="flex items-center w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<FontAwesomeIcon :icon="faEye" class="w-4 h-4 me-2 text-gray-500" />
<span>Poglej</span>
</Link>
<Link
<ActionMenuItem
v-if="row.status !== 'completed'"
:href="route('imports.continue', { import: row.uuid })"
class="flex items-center w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<FontAwesomeIcon
:icon="faPlay"
class="w-4 h-4 me-2 text-gray-500"
label="Nadaljuj"
@click="
$inertia.visit(route('imports.continue', { import: row.uuid }))
"
/>
<span>Nadaljuj</span>
</Link>
<button
<ActionMenuItem
v-if="canDelete(row.status)"
type="button"
class="flex items-center w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
:icon="faTrash"
label="Izbriši"
danger
@click="confirmDelete(row)"
>
<FontAwesomeIcon :icon="faTrash" class="w-4 h-4 me-2" />
<span>Izbriši</span>
</button>
<div
v-else
class="flex items-center px-4 py-2 text-sm text-gray-400 cursor-default"
>
<FontAwesomeIcon :icon="faCircleCheck" class="w-4 h-4 me-2" />
<span>Zaključen</span>
</div>
</div>
/>
<ActionMenuItem
v-if="!canDelete(row.status)"
:icon="faCircleCheck"
label="Zaključen"
disabled
/>
</template>
</Dropdown>
</TableActions>
</template>
</DataTableServer>
</DataTable>
<ConfirmationModal
:show="confirming"
@close="
@ -230,7 +258,7 @@ function formatDateTimeNoSeconds(value) {
</button>
</template>
</ConfirmationModal>
</div>
</AppCard>
</div>
</div>
</AppLayout>

View File

@ -1,9 +1,29 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { ref } from "vue";
import { ref, computed, watch } from "vue";
import { useForm } from "@inertiajs/vue3";
import Multiselect from "vue-multiselect";
import { computed, watch } from "vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import { Button } from "@/Components/ui/button";
import { Checkbox } from "@/Components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Badge } from "@/Components/ui/badge";
import { Separator } from "@/Components/ui/separator";
const props = defineProps({
clients: Array,
@ -88,7 +108,14 @@ watch(
form.meta.payments_import = false;
form.meta.contract_key_mode = null;
}
const allowed = ["person", "person_addresses", "person_phones", "contracts", "activities", "client_cases"];
const allowed = [
"person",
"person_addresses",
"person_phones",
"contracts",
"activities",
"client_cases",
];
if (enabled) {
const current = Array.isArray(form.entities) ? [...form.entities] : [];
let filtered = current.filter((e) => allowed.includes(e));
@ -103,7 +130,12 @@ watch(
watch(
() => form.entities,
(vals) => {
if (form.meta.history_import && Array.isArray(vals) && vals.includes("contracts") && ! vals.includes("accounts")) {
if (
form.meta.history_import &&
Array.isArray(vals) &&
vals.includes("contracts") &&
!vals.includes("accounts")
) {
form.entities = [...vals, "accounts"];
}
}
@ -111,254 +143,278 @@ watch(
</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>
<AppLayout title="Ustvari predlogo uvoza">
<template #header> </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
<Card>
<CardHeader>
<CardTitle>Konfiguracija predloge</CardTitle>
<CardDescription
>Določite nastavitve predloge uvoza in ciljne entitete</CardDescription
>
<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).
</CardHeader>
<CardContent class="space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="client">Stranka (izbirno)</Label>
<Select v-model="form.client_uuid">
<SelectTrigger id="client">
<SelectValue placeholder="Globalno (brez stranke)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Globalno (brez stranke)</SelectItem>
<SelectItem
v-for="c in props.clients || []"
:key="c.uuid"
:value="c.uuid"
>
{{ c.name }}
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
Pustite prazno za globalno predlogo (vidno vsem strankam).
</p>
</div>
<div>
<div class="flex items-center justify-between">
<label class="block text-sm font-medium text-gray-700"
>Entities (tables)</label
>
<div class="flex items-center gap-4 text-sm">
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
v-model="form.meta.history_import"
class="rounded"
/>
<span>History import</span>
</label>
<label class="inline-flex items-center gap-2">
<input
type="checkbox"
v-model="form.meta.payments_import"
class="rounded"
/>
<span>Payments import</span>
</label>
</div>
</div>
<div class="space-y-2">
<Label>Entitete (tabele)</Label>
<template v-if="!form.meta.payments_import">
<Multiselect
<AppMultiSelect
v-model="form.entities"
:options="[
{ value: 'person', label: 'Person' },
{ value: 'person_addresses', label: 'Person Addresses' },
{ value: 'person_phones', label: 'Person Phones' },
{ value: 'client_cases', label: 'Client Cases' },
{ value: 'emails', label: 'Emails' },
{ value: 'accounts', label: 'Accounts' },
{ value: 'contracts', label: 'Contracts' },
{ value: 'case_objects', label: 'Case Objects' },
{ value: 'payments', label: 'Payments' },
{ value: 'activities', label: 'Activities' },
:items="[
{ value: 'person', label: 'Oseba' },
{ value: 'person_addresses', label: 'Naslov' },
{ value: 'person_phones', label: 'Telefon' },
{ value: 'client_cases', label: 'Primer' },
{ value: 'emails', label: 'E-pošta' },
{ value: 'accounts', label: 'Računi' },
{ value: 'contracts', label: 'Pogodbe' },
{ value: 'case_objects', label: 'Predmet' },
{ value: 'payments', label: 'Plačilo' },
{ value: 'activities', label: 'Aktivnost' },
]"
:multiple="true"
track-by="value"
label="label"
:reduce="(o) => o.value"
placeholder="Select one or more entities"
:searchable="false"
class="mt-1"
placeholder="Izberite eno ali več entitet"
search-placeholder="Iskanje entitet..."
content-class="p-0 w-full"
/>
</template>
<template v-else>
<div class="mt-1">
<div class="flex flex-wrap gap-2">
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Contracts</span>
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Accounts</span>
<span class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-800 px-3 py-1 text-xs font-medium">Payments</span>
</div>
<Badge variant="secondary">Pogodbe</Badge>
<Badge variant="secondary">Računi</Badge>
<Badge variant="secondary">Plačila</Badge>
</div>
</template>
<p class="text-xs text-gray-500 mt-1">
Choose which tables this template targets. You can still define per-column
mappings later.
<p class="text-xs text-muted-foreground mt-1">
Izberite katere tabele ta predloga cilja. Preslikave stolpcev lahko
dodate kasneje.
</p>
<div v-if="form.meta.history_import" class="mt-2 text-xs text-gray-600">
History mode allows only person/address/phone/contracts/activities/client cases. Accounts are auto-added when contracts are present and balances stay unchanged.
<!-- Import Mode Toggles -->
<div class="flex items-center gap-6 pt-2">
<label class="inline-flex items-center gap-2">
<Checkbox
:checked="form.meta.history_import"
@update:checked="form.meta.history_import = $event"
/>
<span class="text-sm">Uvoz zgodovine</span>
</label>
<label class="inline-flex items-center gap-2">
<Checkbox
:checked="form.meta.payments_import"
@update:checked="form.meta.payments_import = $event"
/>
<span class="text-sm">Uvoz plačil</span>
</label>
</div>
<div v-if="form.meta.payments_import" class="mt-2 text-xs text-gray-600">
Payments mode locks entities to:
<span class="font-medium">Contracts Accounts Payments</span> and
optimizes matching for payments import.
<div
v-if="form.meta.history_import"
class="mt-2 text-xs text-muted-foreground"
>
Način zgodovine dovoljuje samo
oseba/naslovi/telefoni/pogodbe/aktivnosti/primeri strank. Računi so
samodejno dodani, ko so prisotne pogodbe in stanja ostanejo
nespremenjena.
</div>
<div v-if="form.meta.payments_import" class="mt-3">
<label class="block text-sm font-medium text-gray-700"
>Contract match key</label
<div
v-if="form.meta.payments_import"
class="mt-2 text-xs text-muted-foreground"
>
<select
v-model="form.meta.contract_key_mode"
class="mt-1 block w-full border rounded p-2"
>
<option value="reference">
Reference (use only contract.reference to locate records)
</option>
</select>
<p class="text-xs text-gray-500 mt-1">
When importing payments, Contract records are located using the selected
key. Use your CSV mapping to map the appropriate column to the contract
reference.
Način plačil zaklene entitete na:
<span class="font-medium">Pogodbe Računi Plačila</span> in
optimizira ujemanje za uvoz plačil.
</div>
<div v-if="form.meta.payments_import" class="mt-3 space-y-2">
<Label>Ključ ujemanja pogodb</Label>
<Select v-model="form.meta.contract_key_mode">
<SelectTrigger>
<SelectValue placeholder="Izberite ključ pogodbe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="reference">
Referenca (uporabi samo contract.reference za iskanje zapisov)
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
Pri uvozu plačil se zapisi pogodb najdejo z uporabo izbranega ključa.
Uporabite CSV preslikavo za povezavo ustreznega stolpca z referenco
pogodbe.
</p>
</div>
</div>
</div>
<Separator />
<!-- Defaults: Segment / Decision / Action -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700"
>Default Segment</label
<div class="space-y-2">
<Label>Privzeti segment</Label>
<Select v-model="form.meta.segment_id">
<SelectTrigger>
<SelectValue placeholder="(brez)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">(brez)</SelectItem>
<SelectItem
v-for="s in props.segments || []"
:key="s.id"
:value="s.id"
>
<select
v-model="form.meta.segment_id"
class="mt-1 block w-full border rounded p-2"
>
<option :value="null">(none)</option>
<option v-for="s in props.segments || []" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>Default Action (for Activity)</label
<div class="space-y-2">
<Label>Privzeto dejanje (za aktivnost)</Label>
<Select v-model="form.meta.action_id">
<SelectTrigger>
<SelectValue placeholder="(brez)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">(brez)</SelectItem>
<SelectItem
v-for="a in props.actions || []"
:key="a.id"
:value="a.id"
>
<select
v-model="form.meta.action_id"
class="mt-1 block w-full border rounded p-2"
>
<option :value="null">(none)</option>
<option v-for="a in props.actions || []" :key="a.id" :value="a.id">
{{ a.name }}
</option>
</select>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>Default Decision</label
<div class="space-y-2">
<Label>Privzeta odločitev</Label>
<Select v-model="form.meta.decision_id" :disabled="!form.meta.action_id">
<SelectTrigger>
<SelectValue placeholder="(brez)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">(brez)</SelectItem>
<SelectItem
v-for="d in decisionsForSelectedAction"
:key="d.id"
:value="d.id"
>
<select
v-model="form.meta.decision_id"
class="mt-1 block w-full border rounded p-2"
:disabled="!form.meta.action_id"
>
<option :value="null">(none)</option>
<option v-for="d in decisionsForSelectedAction" :key="d.id" :value="d.id">
{{ d.name }}
</option>
</select>
<p v-if="!form.meta.action_id" class="text-xs text-gray-500 mt-1">
Select an Action to see its Decisions.
</SelectItem>
</SelectContent>
</Select>
<p v-if="!form.meta.action_id" class="text-xs text-muted-foreground">
Izberite dejanje za ogled njegovih odločitev.
</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Name</label>
<input
<Separator />
<div class="space-y-2">
<Label for="name">Ime</Label>
<Input
id="name"
v-model="form.name"
type="text"
class="mt-1 block w-full border rounded p-2"
placeholder="Vnesite ime predloge"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Description</label>
<textarea
<div class="space-y-2">
<Label for="description">Opis</Label>
<Textarea
id="description"
v-model="form.description"
class="mt-1 block w-full border rounded p-2"
placeholder="Vnesite opis predloge"
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 class="space-y-2">
<Label for="source-type">Tip vira</Label>
<Select v-model="form.source_type">
<SelectTrigger id="source-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="csv">CSV</SelectItem>
<SelectItem value="xml">XML</SelectItem>
<SelectItem value="xls">XLS</SelectItem>
<SelectItem value="xlsx">XLSX</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700"
>Default Record Type (optional)</label
>
<input
<div class="space-y-2">
<Label for="record-type">Privzeti tip zapisa (izbirno)</Label>
<Input
id="record-type"
v-model="form.default_record_type"
type="text"
class="mt-1 block w-full border rounded p-2"
placeholder="e.g., account, person"
placeholder="npr. račun, oseba"
/>
</div>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<input
<Checkbox
id="is_active"
v-model="form.is_active"
type="checkbox"
class="rounded"
:checked="form.is_active"
@update:checked="form.is_active = $event"
/>
<label for="is_active" class="text-sm font-medium text-gray-700"
>Active</label
>
<div class="flex items-center gap-2 ml-6">
<input id="reactivate" v-model="form.reactivate" type="checkbox" class="rounded" />
<label for="reactivate" class="text-sm font-medium text-gray-700">Reactivation import</label>
<Label for="is_active" class="cursor-pointer">Aktivno</Label>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="reactivate"
:checked="form.reactivate"
@update:checked="form.reactivate = $event"
/>
<Label for="reactivate" class="cursor-pointer">Uvoz reaktivacije</Label>
</div>
</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>
<Separator />
<div class="flex items-center justify-between pt-2">
<div
v-if="form.errors && Object.keys(form.errors).length"
class="text-sm text-red-600"
class="text-sm text-destructive"
>
<div v-for="(msg, key) in form.errors" :key="key">{{ msg }}</div>
</div>
<Button @click.prevent="submit" :disabled="form.processing" class="ml-auto">
{{ form.processing ? "Shranjevanje…" : "Ustvari predlogo" }}
</Button>
</div>
</CardContent>
</Card>
</div>
</div>
</AppLayout>

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,32 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import { Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, useForm } from "@inertiajs/vue3";
import { ref } from "vue";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/Components/ui/card";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/Components/ui/alert-dialog";
import { Separator } from "@/Components/ui/separator";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import { ListIndentIncreaseIcon } from "lucide-vue-next";
import TableActions from "@/Components/DataTable/TableActions.vue";
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
import { faPencil, faTrash } from "@fortawesome/free-solid-svg-icons";
// Non-blocking confirm modal state
const confirmOpen = ref(false);
@ -15,7 +40,7 @@ function requestDelete(uuid) {
function performDelete() {
if (!confirmUuid.value) return;
deleteForm.delete(route('importTemplates.destroy', { template: confirmUuid.value }), {
deleteForm.delete(route("importTemplates.destroy", { template: confirmUuid.value }), {
preserveScroll: true,
onFinish: () => {
confirmOpen.value = false;
@ -42,47 +67,124 @@ const props = defineProps({
<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>
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<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>
<ListIndentIncreaseIcon size="18" />
<CardTitle class="uppercase">Predloge uvoza</CardTitle>
</div>
</template>
<div class="flex items-center justify-between border-t border-b py-2 px-4">
<div>
<CardDescription>
Skupaj {{ props.templates?.length || 0 }} predlog{{
props.templates?.length === 1 ? "a" : ""
}}
</CardDescription>
</div>
<Button as-child>
<Link :href="route('importTemplates.create')"> Nova predloga </Link>
</Button>
</div>
<div
v-if="!props.templates || props.templates.length === 0"
class="p-8 text-center text-muted-foreground"
>
Ni predlog uvoza.
</div>
<div v-else class="grid gap-4 p-4">
<Card
v-for="t in props.templates"
:key="t.uuid"
class="hover:shadow-md transition-shadow px-0! p-4"
>
<CardHeader>
<div class="flex items-start justify-between">
<div class="flex-1">
<CardTitle class="text-base">{{ t.name }}</CardTitle>
<CardDescription class="mt-1">
{{ t.description }}
</CardDescription>
</div>
<TableActions align="right">
<template #default>
<ActionMenuItem
:icon="faPencil"
label="Uredi"
@click="
$inertia.visit(
route('importTemplates.edit', { template: t.uuid })
)
"
/>
<ActionMenuItem
:icon="faTrash"
label="Izbriši"
danger
@click="requestDelete(t.uuid)"
/>
</template>
</TableActions>
</div>
</CardHeader>
<CardContent class="pt-0">
<div class="flex items-center gap-2">
<Badge variant="outline" class="text-xs">
{{ t.client?.name || "Globalno" }}
</Badge>
<Badge variant="secondary" class="text-xs">
{{ t.source_type.toUpperCase() }}
</Badge>
<Badge :variant="t.is_active ? 'default' : 'secondary'" class="text-xs">
{{ t.is_active ? "Aktivno" : "Neaktivno" }}
</Badge>
</div>
</CardContent>
</Card>
</div>
</AppCard>
</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">
<!-- Confirm Delete Dialog -->
<AlertDialog
:open="confirmOpen"
@update:open="
(val) => {
if (!val) cancelDelete();
}
"
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Izbrišem predlogo?</AlertDialogTitle>
<AlertDialogDescription>
Tega dejanja ni mogoče razveljaviti. Vse preslikave te predloge bodo
izbrisane.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel @click="cancelDelete" :disabled="deleteForm.processing">
Prekliči
</AlertDialogCancel>
<AlertDialogAction
@click="performDelete"
:disabled="deleteForm.processing"
class="bg-destructive hover:bg-destructive/90"
>
<span v-if="deleteForm.processing">Brisanje</span>
<span v-else>Izbriši</span>
</button>
</div>
</div>
</div>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AppLayout>
</template>

View File

@ -0,0 +1,168 @@
<script setup>
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Checkbox } from "@/Components/ui/checkbox";
import { Button } from "@/Components/ui/button";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
const props = defineProps({
form: { type: Object, required: true },
clients: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
actions: { type: Array, default: () => [] },
decisions: { type: Array, default: () => [] },
canChangeClient: { type: Boolean, default: true },
});
const emit = defineEmits(["save"]);
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Osnovne informacije</CardTitle>
</CardHeader>
<CardContent class="space-y-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<Label for="name">Ime predloge</Label>
<Input id="name" v-model="form.name" type="text" />
</div>
<div class="space-y-2">
<Label for="source_type">Vir</Label>
<Select v-model="form.source_type">
<SelectTrigger id="source_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="csv">CSV</SelectItem>
<SelectItem value="xml">XML</SelectItem>
<SelectItem value="xls">XLS</SelectItem>
<SelectItem value="xlsx">XLSX</SelectItem>
<SelectItem value="json">JSON</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="default_record_type">Privzeti tip zapisa</Label>
<Input
id="default_record_type"
v-model="form.default_record_type"
type="text"
placeholder="npr.: account, person"
/>
</div>
<div class="space-y-2">
<Label for="client">Naročnik (izbirno)</Label>
<Select v-model="form.client_uuid" :disabled="!canChangeClient">
<SelectTrigger id="client">
<SelectValue placeholder="Globalno (brez naročnika)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">Globalno (brez naročnika)</SelectItem>
<SelectItem v-for="c in clients || []" :key="c.uuid" :value="c.uuid">
{{ c.name }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="!canChangeClient" class="text-xs text-amber-600">
Ni mogoče spremeniti naročnika, ker ta predloga že vsebuje preslikave.
</p>
</div>
<div class="space-y-2">
<Label for="delimiter">Privzeti ločilni znak (CSV)</Label>
<Select v-model="form.meta.delimiter">
<SelectTrigger id="delimiter">
<SelectValue placeholder="(Auto-detect)" />
</SelectTrigger>
<SelectContent>
<SelectItem value=",">Vejica ,</SelectItem>
<SelectItem value=";">Podpičje ;</SelectItem>
<SelectItem value="\t">Tab \t</SelectItem>
<SelectItem value="|">Pipe |</SelectItem>
<SelectItem value=" ">Presledek </SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
Pusti prazno za samodejno zaznavo. Uporabi, ko zaznavanje ne deluje pravilno.
</p>
</div>
<div class="space-y-2">
<Label for="segment">Privzeti segment</Label>
<Select v-model="form.meta.segment_id">
<SelectTrigger id="segment">
<SelectValue placeholder="(brez)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">(brez)</SelectItem>
<SelectItem v-for="s in segments || []" :key="s.id" :value="s.id">
{{ s.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="action">Privzeto dejanje (post-contract activity)</Label>
<Select v-model="form.meta.action_id">
<SelectTrigger id="action">
<SelectValue placeholder="(brez)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">(brez)</SelectItem>
<SelectItem v-for="a in actions || []" :key="a.id" :value="a.id">
{{ a.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="decision">Privzeta odločitev (post-contract)</Label>
<Select v-model="form.meta.decision_id" :disabled="!form.meta.action_id">
<SelectTrigger id="decision">
<SelectValue placeholder="(brez)" />
</SelectTrigger>
<SelectContent>
<SelectItem :value="null">(brez)</SelectItem>
<SelectItem v-for="d in decisions || []" :key="d.id" :value="d.id">
{{ d.name }}
</SelectItem>
</SelectContent>
</Select>
<p v-if="!form.meta.action_id" class="text-xs text-muted-foreground">
Najprej izberi dejanje, nato odločitev.
</p>
</div>
</div>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Checkbox
id="is_active"
:checked="form.is_active"
@update:checked="form.is_active = $event"
/>
<Label for="is_active" class="cursor-pointer">Aktivna</Label>
</div>
<div class="flex items-center gap-2">
<Checkbox
id="reactivate"
:checked="form.reactivate"
@update:checked="form.reactivate = $event"
/>
<Label for="reactivate" class="cursor-pointer">Reaktivacija</Label>
</div>
<Button @click="emit('save')" class="ml-auto">Shrani</Button>
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,301 @@
<script setup>
import { ref } from "vue";
import { router } from "@inertiajs/vue3";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/Components/ui/accordion";
import { Card, CardContent } from "@/Components/ui/card";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import { Textarea } from "@/Components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
import { ArrowUp, ArrowDown } from "lucide-vue-next";
const props = defineProps({
entities: { type: Array, default: () => [] },
entityOptions: { type: Array, default: () => [] },
fieldOptions: { type: Object, default: () => ({}) },
mappings: { type: Array, default: () => [] },
templateUuid: { type: String, required: true },
allSourceColumns: { type: Array, default: () => [] },
entityAliases: { type: Object, default: () => ({}) },
actions: { type: Array, default: () => [] },
decisions: { type: Array, default: () => [] },
});
const emit = defineEmits(["refresh"]);
const newRows = ref({});
const bulkRows = ref({});
function addRow(entity) {
const row = newRows.value[entity];
if (!row || !row.source || !row.field) return;
const target_field = `${entity}.${row.field}`;
const opts = {};
if (row.group) opts.group = row.group;
if (row.field === "meta" && row.metaKey) {
opts.key = String(row.metaKey).trim();
if (row.metaType) opts.type = String(row.metaType).trim();
}
const payload = {
source_column: row.source,
target_field,
transform: row.transform || "",
apply_mode: row.apply_mode || "both",
options: Object.keys(opts).length ? opts : null,
position: (props.mappings?.length || 0) + 1,
};
router.post(
route("importTemplates.mappings.add", { template: props.templateUuid }),
payload,
{
preserveScroll: true,
onSuccess: () => {
newRows.value[entity] = {};
emit("refresh");
},
}
);
}
function updateMapping(m) {
const payload = {
source_column: m.source_column,
target_field: m.target_field,
transform: m.transform || "",
apply_mode: m.apply_mode || "both",
options: m.options || null,
position: m.position,
};
router.put(
route("importTemplates.mappings.update", {
template: props.templateUuid,
mapping: m.id,
}),
payload,
{
preserveScroll: true,
onSuccess: () => emit("refresh"),
}
);
}
function deleteMapping(m) {
router.delete(
route("importTemplates.mappings.delete", {
template: props.templateUuid,
mapping: m.id,
}),
{
preserveScroll: true,
onSuccess: () => emit("refresh"),
}
);
}
function reorder(entity, direction, m) {
const all = [...props.mappings];
const aliases = (props.entityAliases[entity] || [entity]).map((a) => a + ".");
const entityMaps = all.filter((x) => {
const tf = x.target_field || "";
return aliases.some((prefix) => tf.startsWith(prefix));
});
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];
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]];
router.post(
route("importTemplates.mappings.reorder", { template: props.templateUuid }),
{ order: ordered },
{
preserveScroll: true,
onSuccess: () => emit("refresh"),
}
);
}
function getEntityMappings(entity) {
const aliases = (props.entityAliases[entity] || [entity]).map((a) => a + ".");
return (props.mappings || []).filter((m) => {
const tf = m.target_field || "";
return aliases.some((prefix) => tf.startsWith(prefix));
});
}
</script>
<template>
<Card>
<CardContent class="p-0">
<Accordion type="multiple" collapsible class="w-full">
<AccordionItem v-for="entity in entities" :key="entity" :value="entity">
<AccordionTrigger class="px-4 hover:no-underline">
<span class="font-medium">
{{ entityOptions.find((e) => e.key === entity)?.label || entity }}
</span>
</AccordionTrigger>
<AccordionContent class="px-4 pb-4 space-y-4">
<!-- Existing mappings -->
<div v-if="getEntityMappings(entity).length > 0" class="space-y-2">
<div
v-for="m in getEntityMappings(entity)"
:key="m.id"
class="p-3 border rounded-lg bg-muted/30"
>
<div class="grid grid-cols-1 sm:grid-cols-5 gap-2 items-center">
<div class="space-y-1">
<Label class="text-xs">Izvor</Label>
<Input v-model="m.source_column" class="text-sm" />
</div>
<div class="space-y-1">
<Label class="text-xs">Cilj</Label>
<Input v-model="m.target_field" class="text-sm" />
</div>
<div class="space-y-1">
<Label class="text-xs">Transform</Label>
<Select v-model="m.transform">
<SelectTrigger class="text-sm">
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem value="trim">trim</SelectItem>
<SelectItem value="upper">upper</SelectItem>
<SelectItem value="lower">lower</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1">
<Label class="text-xs">Način</Label>
<Select v-model="m.apply_mode">
<SelectTrigger class="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">both</SelectItem>
<SelectItem value="insert">insert</SelectItem>
<SelectItem value="update">update</SelectItem>
<SelectItem value="keyref">keyref</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-end gap-2">
<div class="flex flex-col gap-1">
<Button
size="icon"
variant="outline"
class="h-6 w-6"
@click="reorder(entity, 'up', m)"
>
<ArrowUp class="h-3 w-3" />
</Button>
<Button
size="icon"
variant="outline"
class="h-6 w-6"
@click="reorder(entity, 'down', m)"
>
<ArrowDown class="h-3 w-3" />
</Button>
</div>
<div class="flex gap-2">
<Button size="sm" @click="updateMapping(m)">Shrani</Button>
<Button size="sm" variant="destructive" @click="deleteMapping(m)">
Izbriši
</Button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="text-sm text-muted-foreground py-4 text-center">
Ni definiranih preslikav za to entiteto.
</div>
<!-- Add new mapping -->
<div class="p-3 bg-muted/50 rounded-lg border">
<div class="space-y-3">
<div class="text-sm font-medium">Dodaj novo preslikavo</div>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-3">
<div class="space-y-2">
<Label class="text-xs">Izvorno polje</Label>
<Input
v-model="(newRows[entity] ||= {}).source"
placeholder="npr.: reference"
list="`src-opts-${entity}`"
/>
<datalist :id="`src-opts-${entity}`">
<option v-for="s in allSourceColumns" :key="s" :value="s" />
</datalist>
</div>
<div class="space-y-2">
<Label class="text-xs">Polje</Label>
<Select v-model="(newRows[entity] ||= {}).field">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="f in fieldOptions[entity] || []"
:key="f"
:value="f"
>
{{ f }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label class="text-xs">Transform</Label>
<Select v-model="(newRows[entity] ||= {}).transform">
<SelectTrigger>
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem value="trim">trim</SelectItem>
<SelectItem value="upper">upper</SelectItem>
<SelectItem value="lower">lower</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label class="text-xs">Način</Label>
<Select v-model="(newRows[entity] ||= {}).apply_mode">
<SelectTrigger>
<SelectValue placeholder="both" />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">both</SelectItem>
<SelectItem value="insert">insert</SelectItem>
<SelectItem value="update">update</SelectItem>
<SelectItem value="keyref">keyref</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button @click="addRow(entity)" size="sm">Dodaj preslikavo</Button>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,73 @@
<script setup>
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/Components/ui/card";
import { Label } from "@/Components/ui/label";
import { Checkbox } from "@/Components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
form: { type: Object, required: true },
entities: { type: Array, default: () => [] },
});
</script>
<template>
<Card>
<CardHeader>
<CardTitle>Nastavitve načina uvoza</CardTitle>
<CardDescription>
Konfiguriraj način uvoza za zgodovino ali plačila
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-center gap-6">
<label class="inline-flex items-center gap-2 cursor-pointer">
<Checkbox
:checked="form.meta.history_import"
@update:checked="form.meta.history_import = $event"
/>
<span class="text-sm font-medium">Uvoz zgodovine</span>
</label>
<label class="inline-flex items-center gap-2 cursor-pointer">
<Checkbox
:checked="form.meta.payments_import"
@update:checked="form.meta.payments_import = $event"
/>
<span class="text-sm font-medium">Uvoz plačil</span>
</label>
</div>
<p class="text-xs text-muted-foreground">
Zgodovina dovoljuje oseba/naslov/telefon/pogodbe/aktivnosti/primeri strank; računi so
samodejno dodani s pogodbami. Plačila zaklene entitete na Pogodbe Računi Plačila.
</p>
<div v-if="form.meta.payments_import" class="space-y-2 pt-2">
<Label for="contract_key">Ključ ujemanja pogodb</Label>
<Select v-model="form.meta.contract_key_mode">
<SelectTrigger id="contract_key">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="reference">
Referenca (uporabi samo contract.reference za iskanje zapisov)
</SelectItem>
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
Preslika stolpec CSV na contract.reference za reševanje pogodb za tega naročnika.
</p>
</div>
<!-- Entities locked info for payments mode -->
<div v-if="form.meta.payments_import" class="p-3 bg-emerald-50 rounded-lg border border-emerald-200">
<div class="text-sm text-emerald-900 mb-2 font-medium">Entitete so zaklenjene:</div>
<div class="flex flex-wrap gap-2">
<Badge variant="secondary" class="bg-emerald-100 text-emerald-800">Pogodbe</Badge>
<Badge variant="secondary" class="bg-emerald-100 text-emerald-800">Računi</Badge>
<Badge variant="secondary" class="bg-emerald-100 text-emerald-800">Plačila</Badge>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@ -0,0 +1,253 @@
<script setup>
import { ref } from "vue";
import { useForm } from "@inertiajs/vue3";
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from "@/Components/ui/card";
import { Label } from "@/Components/ui/label";
import { Input } from "@/Components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/Components/ui/select";
import { Button } from "@/Components/ui/button";
import { Badge } from "@/Components/ui/badge";
const props = defineProps({
unassigned: { type: Array, default: () => [] },
templateUuid: { type: String, required: true },
entityOptions: { type: Array, default: () => [] },
fieldOptions: { type: Object, default: () => ({}) },
suggestions: { type: Object, default: () => ({}) },
});
const emit = defineEmits(["refresh"]);
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;
}
if (st.group) {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.group = st.group;
}
if (st.field === "meta") {
if (st.metaKey && String(st.metaKey).trim() !== "") {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.key = String(st.metaKey).trim();
}
if (st.metaType && String(st.metaType).trim() !== "") {
m.options = m.options && typeof m.options === "object" ? m.options : {};
m.options.type = String(st.metaType).trim();
}
}
const payload = {
source_column: m.source_column,
target_field: m.target_field,
transform: m.transform,
apply_mode: m.apply_mode,
options: m.options || null,
position: m.position,
};
useForm(payload).put(
route("importTemplates.mappings.update", {
template: props.templateUuid,
mapping: m.id,
}),
{
preserveScroll: true,
onSuccess: () => emit("refresh"),
}
);
}
function deleteMapping(m) {
useForm({}).delete(
route("importTemplates.mappings.delete", {
template: props.templateUuid,
mapping: m.id,
}),
{
preserveScroll: true,
onSuccess: () => emit("refresh"),
}
);
}
function applySuggestion(m, suggestion) {
if (!suggestion || !suggestion.entity || !suggestion.field) return;
const state = unassignedState.value[m.id] || {};
state.entity = suggestion.entity;
state.field = suggestion.field;
unassignedState.value[m.id] = state;
saveUnassigned(m);
}
</script>
<template>
<Card v-if="unassigned && unassigned.length > 0" class="border-amber-200">
<CardHeader class="bg-amber-50">
<CardTitle class="text-amber-900">
Nedodeljene preslikave ({{ unassigned.length }})
</CardTitle>
<CardDescription class="text-amber-700">
Te preslikave nimajo dodeljene ciljne entitete in polja
</CardDescription>
</CardHeader>
<CardContent class="p-4 space-y-3">
<div v-for="m in unassigned" :key="m.id" class="p-3 bg-white border rounded-lg">
<div class="space-y-3">
<!-- Source column with suggestion -->
<div class="flex items-start justify-between">
<div>
<Label class="text-xs text-muted-foreground">Izvorno polje</Label>
<div class="font-medium">{{ m.source_column }}</div>
<div v-if="suggestions && suggestions[m.source_column]" class="mt-1">
<Button
size="sm"
variant="link"
class="h-auto p-0 text-xs text-indigo-700"
@click="applySuggestion(m, suggestions[m.source_column])"
>
Predlog: {{ suggestions[m.source_column].entity }}.{{
suggestions[m.source_column].field
}}
</Button>
</div>
</div>
</div>
<!-- Entity and Field selection -->
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div class="space-y-2">
<Label for="entity">Entiteta</Label>
<Select v-model="(unassignedState[m.id] ||= {}).entity">
<SelectTrigger>
<SelectValue placeholder="(izberi)" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="opt in entityOptions"
:key="opt.key"
:value="opt.key"
>
{{ opt.label }}
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label for="field">Polje</Label>
<Select
v-model="(unassignedState[m.id] ||= {}).field"
:disabled="!(unassignedState[m.id] || {}).entity"
>
<SelectTrigger>
<SelectValue placeholder="(izberi)" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="f in fieldOptions[(unassignedState[m.id] || {}).entity] || []"
:key="f"
:value="f"
>
{{ f }}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- Transform, Apply Mode, Group -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="space-y-2">
<Label>Transform</Label>
<Select v-model="m.transform">
<SelectTrigger>
<SelectValue placeholder="Brez" />
</SelectTrigger>
<SelectContent>
<SelectItem value="trim">trim</SelectItem>
<SelectItem value="upper">upper</SelectItem>
<SelectItem value="lower">lower</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Način</Label>
<Select v-model="m.apply_mode">
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="both">both</SelectItem>
<SelectItem value="insert">insert</SelectItem>
<SelectItem value="update">update</SelectItem>
<SelectItem value="keyref">keyref</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-2">
<Label>Skupina</Label>
<Input
v-model="(unassignedState[m.id] ||= {}).group"
placeholder="1, 2, home, work"
/>
</div>
</div>
<!-- Meta fields if applicable -->
<div
v-if="(unassignedState[m.id] || {}).field === 'meta'"
class="grid grid-cols-1 sm:grid-cols-2 gap-3"
>
<div class="space-y-2">
<Label>Meta ključ</Label>
<Input
v-model="(unassignedState[m.id] ||= {}).metaKey"
placeholder="npr.: note, category"
/>
</div>
<div class="space-y-2">
<Label>Meta tip</Label>
<Select v-model="(unassignedState[m.id] ||= {}).metaType">
<SelectTrigger>
<SelectValue placeholder="(auto/string)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">string</SelectItem>
<SelectItem value="number">number</SelectItem>
<SelectItem value="date">date</SelectItem>
<SelectItem value="boolean">boolean</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 pt-2">
<Button size="sm" @click="saveUnassigned(m)">Shrani</Button>
<Button size="sm" variant="destructive" @click="deleteMapping(m)"
>Izbriši</Button
>
</div>
</div>
</div>
</CardContent>
</Card>
</template>

View File

@ -6,6 +6,7 @@
// this import. This is nice for IDE syntax and refactoring.
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\ImportTemplate;
use App\Models\Segment;
use Diglactic\Breadcrumbs\Breadcrumbs;
// This import is also not required, and you could replace `BreadcrumbTrail $trail`
@ -107,3 +108,33 @@
$trail->parent('dashboard');
$trail->push('Terenska dela', route('fieldjobs.index'));
});
// Dashboard > Imports
Breadcrumbs::for('imports.index', function (BreadcrumbTrail $trail) {
$trail->parent('dashboard');
$trail->push('Uvozi', route('imports.index'));
});
// Dashboard > Imports > Templates
Breadcrumbs::for('importTemplates.index', function (BreadcrumbTrail $trail) {
$trail->parent('imports.index');
$trail->push('Uvozne predloge', route('importTemplates.index'));
});
// Dashboard > Imports > Templates > Create
Breadcrumbs::for('importTemplates.create', function (BreadcrumbTrail $trail) {
$trail->parent('importTemplates.index');
$trail->push('Ustvari', route('importTemplates.create'));
});
// Dashboard > Imports > Templates > Edit > [Template]
Breadcrumbs::for('importTemplates.edit', function (BreadcrumbTrail $trail, ImportTemplate $template) {
$trail->parent('importTemplates.index');
$trail->push($template->name, route('importTemplates.edit', $template));
});