changes
This commit is contained in:
@@ -1,20 +1,24 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { computed } from "vue";
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from "lucide-vue-next";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/Components/ui/select';
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
table: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
showPerPageSelector: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
||||
@@ -27,13 +31,13 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
||||
{{ table.getFilteredRowModel().rows.length }} row(s) selected.
|
||||
</div>
|
||||
<div class="flex items-center space-x-6 lg:space-x-8">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div v-if="showPerPageSelector" class="flex items-center space-x-2">
|
||||
<p class="text-sm font-medium">Rows per page</p>
|
||||
<Select
|
||||
:model-value="`${table.getState().pagination.pageSize}`"
|
||||
@update:model-value="(value) => table.setPageSize(Number(value))"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-[70px]">
|
||||
<SelectTrigger class="h-8 w-17.5">
|
||||
<SelectValue :placeholder="`${table.getState().pagination.pageSize}`" />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
@@ -47,7 +51,7 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
<div class="flex w-25 items-center justify-center text-sm font-medium">
|
||||
Page {{ table.getState().pagination.pageIndex + 1 }} of
|
||||
{{ table.getPageCount() }}
|
||||
</div>
|
||||
@@ -92,4 +96,3 @@ const pageSizeOptions = computed(() => [10, 20, 30, 40, 50]);
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
@@ -523,15 +530,16 @@ const submitAttachSegment = () => {
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<label class="block text-sm font-medium text-gray-700">Segment</label>
|
||||
<select
|
||||
v-model="attachForm.segment_id"
|
||||
class="w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500"
|
||||
>
|
||||
<option :value="null" disabled>-- izberi segment --</option>
|
||||
<option v-for="s in availableSegments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</option>
|
||||
</select>
|
||||
<Select v-model="attachForm.segment_id">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="-- izberi segment --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="s in availableSegments" :key="s.id" :value="s.id">
|
||||
{{ s.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="attachForm.errors.segment_id" class="text-sm text-red-600">
|
||||
{{ attachForm.errors.segment_id }}
|
||||
</div>
|
||||
|
||||
@@ -163,7 +163,7 @@ function applySearch() {
|
||||
<Input
|
||||
v-model="search"
|
||||
placeholder="Išči po primeru, davčni, osebi..."
|
||||
class="w-[260px]"
|
||||
class="w-65"
|
||||
@keydown.enter="applySearch"
|
||||
/>
|
||||
<Button size="sm" variant="outline" @click="applySearch">Išči</Button>
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Link, useForm } from "@inertiajs/vue3";
|
||||
import { Link, useForm, router } from "@inertiajs/vue3";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
import { AlertCircle } from "lucide-vue-next";
|
||||
import Checkbox from "@/Components/ui/checkbox/Checkbox.vue";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import { watchDebounced } from "@vueuse/core";
|
||||
|
||||
const props = defineProps({
|
||||
setting: Object,
|
||||
contracts: Array,
|
||||
unassignedContracts: Object,
|
||||
assignedContracts: Object,
|
||||
users: Array,
|
||||
assignments: Object,
|
||||
filters: Object,
|
||||
});
|
||||
|
||||
const form = useForm({
|
||||
@@ -24,10 +41,113 @@ const bulkForm = useForm({
|
||||
assigned_user_id: null,
|
||||
});
|
||||
|
||||
// Global search (applies to both tables)
|
||||
const search = ref("");
|
||||
// Separate reactive state for selected UUIDs (for UI reactivity)
|
||||
const selectedContractUuids = ref([]);
|
||||
|
||||
// Select all state for unassigned table (current page only)
|
||||
const isAllUnassignedSelected = computed({
|
||||
get: () => {
|
||||
const pageUuids = props.unassignedContracts?.data?.map((c) => c.uuid) || [];
|
||||
return (
|
||||
pageUuids.length > 0 &&
|
||||
pageUuids.every((uuid) => selectedContractUuids.value.includes(uuid))
|
||||
);
|
||||
},
|
||||
set: (value) => {
|
||||
const pageUuids = props.unassignedContracts?.data?.map((c) => c.uuid) || [];
|
||||
if (value) {
|
||||
// Add all page items to selection
|
||||
selectedContractUuids.value = [
|
||||
...new Set([...selectedContractUuids.value, ...pageUuids]),
|
||||
];
|
||||
} else {
|
||||
// Remove all page items from selection
|
||||
selectedContractUuids.value = selectedContractUuids.value.filter(
|
||||
(uuid) => !pageUuids.includes(uuid)
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Helper to toggle contract selection
|
||||
function toggleContractSelection(uuid, checked) {
|
||||
if (checked) {
|
||||
if (!selectedContractUuids.value.includes(uuid)) {
|
||||
selectedContractUuids.value = [...selectedContractUuids.value, uuid];
|
||||
}
|
||||
} else {
|
||||
selectedContractUuids.value = selectedContractUuids.value.filter((id) => id !== uuid);
|
||||
}
|
||||
console.log(selectedContractUuids.value);
|
||||
}
|
||||
|
||||
// Format helpers (Slovenian formatting)
|
||||
|
||||
// Initialize search and filter from URL params
|
||||
const search = ref(props.filters?.search || "");
|
||||
const assignedFilterUserId = ref(props.filters?.assigned_user_id || "all");
|
||||
|
||||
// Navigation helpers
|
||||
function navigateWithParams(params) {
|
||||
router.visit(route("fieldjobs.index"), {
|
||||
data: params,
|
||||
preserveState: true,
|
||||
preserveScroll: true,
|
||||
only: ["unassignedContracts", "assignedContracts", "filters"],
|
||||
});
|
||||
}
|
||||
|
||||
const applySearch = async function () {
|
||||
const params = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
|
||||
const term = (search.value || "").trim();
|
||||
if (term) {
|
||||
params.search = term;
|
||||
} else {
|
||||
delete params.search;
|
||||
}
|
||||
delete params.page;
|
||||
router.get(route("fieldjobs.index"), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
only: ["unassignedContracts"],
|
||||
});
|
||||
};
|
||||
|
||||
watchDebounced(
|
||||
() => search.value,
|
||||
(val) => {
|
||||
applySearch();
|
||||
},
|
||||
{
|
||||
debounce: 200,
|
||||
maxWait: 1000,
|
||||
}
|
||||
);
|
||||
|
||||
// Watch search and filter changes
|
||||
/*watch(search, (value) => {
|
||||
navigateWithParams({
|
||||
search: value || undefined,
|
||||
assigned_user_id:
|
||||
assignedFilterUserId.value !== "all" ? assignedFilterUserId.value : undefined,
|
||||
page_contracts: 1, // Reset to first page on search
|
||||
page_assignments: 1,
|
||||
});
|
||||
});*/
|
||||
|
||||
watch(assignedFilterUserId, (value) => {
|
||||
navigateWithParams({
|
||||
search: search.value || undefined,
|
||||
assigned_user_id: value !== "all" ? value : undefined,
|
||||
page_contracts: props.unassignedContracts?.current_page,
|
||||
page_assignments: 1, // Reset to first page on filter change
|
||||
});
|
||||
});
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
@@ -77,8 +197,10 @@ function assign(contract) {
|
||||
function assignSelected() {
|
||||
// Use the same selected user as in the single-assign dropdown
|
||||
bulkForm.assigned_user_id = form.assigned_user_id;
|
||||
bulkForm.contract_uuids = selectedContractUuids.value;
|
||||
bulkForm.post(route("fieldjobs.assign-bulk"), {
|
||||
onSuccess: () => {
|
||||
selectedContractUuids.value = [];
|
||||
bulkForm.contract_uuids = [];
|
||||
},
|
||||
});
|
||||
@@ -89,185 +211,50 @@ function cancelAssignment(contract) {
|
||||
form.transform(() => payload).post(route("fieldjobs.cancel"));
|
||||
}
|
||||
|
||||
function isAssigned(contract) {
|
||||
return !!(props.assignments && props.assignments[contract.uuid]);
|
||||
}
|
||||
|
||||
function assignedTo(contract) {
|
||||
return props.assignments?.[contract.uuid]?.assigned_to?.name || null;
|
||||
}
|
||||
|
||||
function assignedBy(contract) {
|
||||
return props.assignments?.[contract.uuid]?.assigned_by?.name || null;
|
||||
}
|
||||
|
||||
// removed window.open behavior; default SPA navigation via Inertia Link
|
||||
|
||||
// Derived lists
|
||||
const unassignedContracts = computed(() => {
|
||||
return (props.contracts || []).filter((c) => !isAssigned(c));
|
||||
});
|
||||
|
||||
const assignedContracts = computed(() => {
|
||||
return (props.contracts || []).filter((c) => isAssigned(c));
|
||||
});
|
||||
|
||||
// Apply search to lists
|
||||
function matchesSearch(c) {
|
||||
if (!search.value) {
|
||||
return true;
|
||||
}
|
||||
const q = String(search.value).toLowerCase();
|
||||
const ref = String(c.reference || "").toLowerCase();
|
||||
const casePerson = String(c.client_case?.person?.full_name || "").toLowerCase();
|
||||
// Optionally include client person in search as well for convenience
|
||||
const clientPerson = String(c.client?.person?.full_name || "").toLowerCase();
|
||||
// Include address fields
|
||||
const primaryAddr = String(primaryCaseAddress(c) || "").toLowerCase();
|
||||
const allAddrs = String(
|
||||
(c.client_case?.person?.addresses || [])
|
||||
.map((a) => `${a?.address || ""} ${a?.country || ""}`.trim())
|
||||
.join(" ")
|
||||
).toLowerCase();
|
||||
return (
|
||||
ref.includes(q) ||
|
||||
casePerson.includes(q) ||
|
||||
clientPerson.includes(q) ||
|
||||
primaryAddr.includes(q) ||
|
||||
allAddrs.includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
const unassignedFiltered = computed(() =>
|
||||
unassignedContracts.value.filter(matchesSearch)
|
||||
);
|
||||
|
||||
// Filter for assigned table
|
||||
const assignedFilterUserId = ref("");
|
||||
const assignedContractsFiltered = computed(() => {
|
||||
let list = assignedContracts.value;
|
||||
if (assignedFilterUserId.value) {
|
||||
list = list.filter((c) => {
|
||||
const uid = props.assignments?.[c.uuid]?.assigned_to?.id;
|
||||
return String(uid) === String(assignedFilterUserId.value);
|
||||
});
|
||||
}
|
||||
return list.filter(matchesSearch);
|
||||
});
|
||||
|
||||
// DataTableClient state per table
|
||||
const unassignedSort = ref({ key: null, direction: null });
|
||||
const unassignedPage = ref(1);
|
||||
const unassignedPageSize = ref(10);
|
||||
const assignedSort = ref({ key: null, direction: null });
|
||||
const assignedPage = ref(1);
|
||||
const assignedPageSize = ref(10);
|
||||
|
||||
watch([search, assignedFilterUserId], () => {
|
||||
unassignedPage.value = 1;
|
||||
assignedPage.value = 1;
|
||||
});
|
||||
|
||||
// Column definitions for DataTableClient
|
||||
// Column definitions for DataTableNew2
|
||||
const unassignedColumns = [
|
||||
{ key: "_select", label: "", class: "w-8" },
|
||||
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
|
||||
{
|
||||
key: "case_person",
|
||||
label: "Primer",
|
||||
sortable: true,
|
||||
formatter: (c) => c.client_case?.person?.full_name || "-",
|
||||
key: "_select",
|
||||
label: "",
|
||||
sortable: false,
|
||||
class: "w-8",
|
||||
},
|
||||
{
|
||||
key: "address",
|
||||
label: "Naslov",
|
||||
sortable: true,
|
||||
formatter: (c) => primaryCaseAddress(c),
|
||||
},
|
||||
{
|
||||
key: "client_person",
|
||||
label: "Stranka",
|
||||
sortable: true,
|
||||
formatter: (c) => c.client?.person?.full_name || "-",
|
||||
},
|
||||
{
|
||||
key: "start_date",
|
||||
label: "Začetek",
|
||||
sortable: true,
|
||||
formatter: (c) => formatDate(c.start_date),
|
||||
},
|
||||
{
|
||||
key: "balance_amount",
|
||||
label: "Stanje",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
formatter: (c) => formatCurrencyEUR(c.account?.balance_amount),
|
||||
},
|
||||
{ key: "_actions", label: "Dejanje", class: "w-32" },
|
||||
{ key: "reference", label: "Pogodba", sortable: false },
|
||||
{ key: "case_person", label: "Primer", sortable: false },
|
||||
{ key: "address", label: "Naslov", sortable: false },
|
||||
{ key: "client_person", label: "Stranka", sortable: false },
|
||||
{ key: "start_date", label: "Začetek", sortable: false },
|
||||
{ key: "balance_amount", label: "Stanje", sortable: false, align: "right" },
|
||||
{ key: "_actions", label: "Dejanje", sortable: false },
|
||||
];
|
||||
|
||||
const assignedColumns = [
|
||||
{ key: "reference", label: "Pogodba", sortable: true, class: "w-32" },
|
||||
{
|
||||
key: "case_person",
|
||||
label: "Primer",
|
||||
sortable: true,
|
||||
formatter: (c) => c.client_case?.person?.full_name || "-",
|
||||
},
|
||||
{
|
||||
key: "address",
|
||||
label: "Naslov",
|
||||
sortable: true,
|
||||
formatter: (c) => primaryCaseAddress(c),
|
||||
},
|
||||
{
|
||||
key: "client_person",
|
||||
label: "Stranka",
|
||||
sortable: true,
|
||||
formatter: (c) => c.client?.person?.full_name || "-",
|
||||
},
|
||||
{
|
||||
key: "assigned_at",
|
||||
label: "Dodeljeno dne",
|
||||
sortable: true,
|
||||
formatter: (c) => formatDate(props.assignments?.[c.uuid]?.assigned_at),
|
||||
},
|
||||
{
|
||||
key: "assigned_to",
|
||||
label: "Dodeljeno komu",
|
||||
sortable: true,
|
||||
formatter: (c) => assignedTo(c) || "-",
|
||||
},
|
||||
{
|
||||
key: "balance_amount",
|
||||
label: "Stanje",
|
||||
align: "right",
|
||||
sortable: true,
|
||||
formatter: (c) => formatCurrencyEUR(c.account?.balance_amount),
|
||||
},
|
||||
{ key: "_actions", label: "Dejanje", class: "w-32" },
|
||||
{ key: "reference", label: "Pogodba", sortable: false },
|
||||
{ key: "case_person", label: "Primer", sortable: false },
|
||||
{ key: "address", label: "Naslov", sortable: false },
|
||||
{ key: "client_person", label: "Stranka", sortable: false },
|
||||
{ key: "assigned_at", label: "Dodeljeno dne", sortable: false },
|
||||
{ key: "assigned_to", label: "Dodeljeno komu", sortable: false },
|
||||
{ key: "balance_amount", label: "Stanje", sortable: false, align: "right" },
|
||||
{ key: "_actions", label: "Dejanje", sortable: false },
|
||||
];
|
||||
|
||||
// Provide derived row arrays for DataTable (already filtered)
|
||||
// Add a flat numeric property `balance_amount` so the generic table sorter can sort by value
|
||||
// (original data nests it under account.balance_amount which the sorter cannot reach).
|
||||
// Prepare rows with flattened fields for display
|
||||
const unassignedRows = computed(() =>
|
||||
unassignedFiltered.value.map((c) => ({
|
||||
(props.unassignedContracts?.data || []).map((c) => ({
|
||||
...c,
|
||||
// Ensure numeric so sorter treats it as number (server often returns string)
|
||||
balance_amount:
|
||||
c?.account?.balance_amount === null || c?.account?.balance_amount === undefined
|
||||
? null
|
||||
: Number(c.account.balance_amount),
|
||||
// Flatten derived text fields so DataTable sorting/searching works
|
||||
case_person: c.client_case?.person?.full_name || null,
|
||||
client_person: c.client?.person?.full_name || null,
|
||||
address: primaryCaseAddress(c) || null,
|
||||
assigned_to: null, // not assigned yet
|
||||
}))
|
||||
);
|
||||
|
||||
const assignedRows = computed(() =>
|
||||
assignedContractsFiltered.value.map((c) => ({
|
||||
(props.assignedContracts?.data || []).map((c) => ({
|
||||
...c,
|
||||
balance_amount:
|
||||
c?.account?.balance_amount === null || c?.account?.balance_amount === undefined
|
||||
@@ -276,7 +263,8 @@ const assignedRows = computed(() =>
|
||||
case_person: c.client_case?.person?.full_name || null,
|
||||
client_person: c.client?.person?.full_name || null,
|
||||
address: primaryCaseAddress(c) || null,
|
||||
assigned_to: assignedTo(c) || null,
|
||||
assigned_to: c.last_field_jobs.assigned_user.name || null,
|
||||
assigned_at_formatted: formatDate(c.last_field_jobs.assigned_at),
|
||||
}))
|
||||
);
|
||||
</script>
|
||||
@@ -288,155 +276,199 @@ const assignedRows = computed(() =>
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
v-if="!setting"
|
||||
class="bg-yellow-50 border border-yellow-200 text-yellow-800 rounded p-4 mb-6"
|
||||
class="mb-6 flex items-start gap-3 rounded-lg border border-yellow-200 bg-yellow-50 p-4"
|
||||
>
|
||||
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve →
|
||||
Nastavitve terenskih opravil.
|
||||
<AlertCircle class="h-5 w-5 text-yellow-600 shrink-0 mt-0.5" />
|
||||
<p class="text-sm text-yellow-800">
|
||||
Nastavitev za terenska opravila ni najdena. Najprej jo ustvarite v Nastavitve
|
||||
→ Nastavitve terenskih opravil.
|
||||
</p>
|
||||
</div>
|
||||
<!-- Global search -->
|
||||
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg p-4 mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Iskanje (št. pogodbe, nazivu ali naslovu)</label
|
||||
>
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Išči po številki pogodbe, nazivu ali naslovu"
|
||||
class="border rounded px-3 py-2 w-full max-w-xl"
|
||||
/>
|
||||
</div>
|
||||
<!-- Unassigned (Assignable) Contracts via DataTableClient -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Pogodbe (nedodeljene)</h2>
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1"
|
||||
>Dodeli uporabniku</label
|
||||
>
|
||||
<select
|
||||
v-model="form.assigned_user_id"
|
||||
class="border rounded px-3 py-2 w-full max-w-xs"
|
||||
>
|
||||
<option :value="null" disabled>Izberite uporabnika</option>
|
||||
<option v-for="u in users || []" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm mt-1">
|
||||
{{ form.errors.assigned_user_id }}
|
||||
|
||||
<!-- Unassigned (Assignable) Contracts -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg mb-8">
|
||||
<div class="p-4 border-b">
|
||||
<h2 class="text-xl font-semibold">Pogodbe (nedodeljene)</h2>
|
||||
</div>
|
||||
<div class="p-4 border-b space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="assign-user">Dodeli uporabniku</Label>
|
||||
<Select v-model="form.assigned_user_id">
|
||||
<SelectTrigger id="assign-user" class="max-w-xs">
|
||||
<SelectValue placeholder="Izberite uporabnika" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="u in users || []" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="form.errors.assigned_user_id" class="text-red-600 text-sm">
|
||||
{{ form.errors.assigned_user_id }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-2 text-sm rounded bg-indigo-600 text-white disabled:opacity-50"
|
||||
:disabled="!bulkForm.contract_uuids.length || !form.assigned_user_id"
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
:disabled="!selectedContractUuids.length || !form.assigned_user_id"
|
||||
@click="assignSelected"
|
||||
>
|
||||
Dodeli izbrane ({{ bulkForm.contract_uuids.length }})
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 text-sm rounded border border-gray-300 disabled:opacity-50"
|
||||
:disabled="!bulkForm.contract_uuids.length"
|
||||
@click="bulkForm.contract_uuids = []"
|
||||
Dodeli izbrane
|
||||
<Badge
|
||||
v-if="selectedContractUuids.length"
|
||||
variant="secondary"
|
||||
class="ml-2"
|
||||
>
|
||||
{{ selectedContractUuids.length }}
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="!selectedContractUuids.length"
|
||||
@click="selectedContractUuids = []"
|
||||
>
|
||||
Počisti izbor
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableClient
|
||||
<DataTable
|
||||
:columns="unassignedColumns"
|
||||
:rows="unassignedRows"
|
||||
:search-keys="['reference', 'case_person', 'client_person', 'address']"
|
||||
v-model:sort="unassignedSort"
|
||||
v-model:search="search"
|
||||
v-model:page="unassignedPage"
|
||||
v-model:pageSize="unassignedPageSize"
|
||||
:data="unassignedRows"
|
||||
:meta="{
|
||||
current_page: unassignedContracts.current_page,
|
||||
per_page: unassignedContracts.per_page,
|
||||
total: unassignedContracts.total,
|
||||
last_page: unassignedContracts.last_page,
|
||||
from: unassignedContracts.from,
|
||||
to: unassignedContracts.to,
|
||||
links: unassignedContracts.links,
|
||||
}"
|
||||
row-key="uuid"
|
||||
:page-size="props.unassignedContracts?.per_page || 10"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:show-toolbar="true"
|
||||
route-name="fieldjobs.index"
|
||||
page-param-name="page_contracts"
|
||||
per-page-param-name="per_page_contracts"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
v-model="search"
|
||||
placeholder="Išči po pogodbi, primeru, stranki, naslovu..."
|
||||
class="w-[320px]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #cell-_select="{ row }">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:value="row.uuid"
|
||||
v-model="bulkForm.contract_uuids"
|
||||
<Checkbox
|
||||
@update:model-value="
|
||||
(checked) => toggleContractSelection(row.uuid, checked)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-case_person="{ row }">
|
||||
<Link
|
||||
v-if="row.client_case?.uuid"
|
||||
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.client_case?.person?.full_name || "Primer stranke" }}
|
||||
</Link>
|
||||
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
|
||||
</template>
|
||||
<template #cell-_actions="{ row }">
|
||||
<button
|
||||
class="px-3 py-1 text-xs rounded bg-indigo-600 text-white"
|
||||
@click="assign(row)"
|
||||
>
|
||||
Dodeli
|
||||
</button>
|
||||
<template #cell-start_date="{ row }">
|
||||
{{ formatDate(row.start_date) }}
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="text-sm text-gray-500 py-4 text-center">
|
||||
Ni najdenih pogodb.
|
||||
<template #cell-balance_amount="{ row }">
|
||||
<div class="text-right">
|
||||
{{ formatCurrencyEUR(row.account?.balance_amount) }}
|
||||
</div>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
<template #cell-_actions="{ row }">
|
||||
<Button size="sm" @click="assign(row)">Dodeli</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
<!-- Assigned Contracts via DataTableClient -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<!-- Assigned Contracts -->
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="p-4 border-b">
|
||||
<h2 class="text-xl font-semibold">Dodeljene pogodbe</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-sm text-gray-700">Filter po uporabniku</label>
|
||||
<select v-model="assignedFilterUserId" class="border rounded px-3 py-2">
|
||||
<option value="">Vsi</option>
|
||||
<option v-for="u in users || []" :key="u.id" :value="u.id">
|
||||
{{ u.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTableClient
|
||||
<DataTable
|
||||
:columns="assignedColumns"
|
||||
:rows="assignedRows"
|
||||
:search-keys="[
|
||||
'reference',
|
||||
'case_person',
|
||||
'client_person',
|
||||
'address',
|
||||
'assigned_to',
|
||||
]"
|
||||
v-model:sort="assignedSort"
|
||||
v-model:search="search"
|
||||
v-model:page="assignedPage"
|
||||
v-model:pageSize="assignedPageSize"
|
||||
:data="assignedRows"
|
||||
:meta="{
|
||||
current_page: assignedContracts.current_page,
|
||||
per_page: assignedContracts.per_page,
|
||||
total: assignedContracts.total,
|
||||
last_page: assignedContracts.last_page,
|
||||
from: assignedContracts.from,
|
||||
to: assignedContracts.to,
|
||||
links: assignedContracts.links,
|
||||
}"
|
||||
row-key="uuid"
|
||||
:page-size="props.assignedContracts?.per_page || 10"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:show-toolbar="true"
|
||||
route-name="fieldjobs.index"
|
||||
page-param-name="page_assignments"
|
||||
per-page-param-name="per_page_assignments"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
v-model="search_contract"
|
||||
placeholder="Išči po pogodbi, primeru, stranki..."
|
||||
class="w-[320px]"
|
||||
/>
|
||||
<div class="flex items-center gap-2 ml-4">
|
||||
<Label for="filter-user" class="text-sm whitespace-nowrap"
|
||||
>Filter po uporabniku</Label
|
||||
>
|
||||
<Select v-model="assignedFilterUserId">
|
||||
<SelectTrigger id="filter-user" class="w-48">
|
||||
<SelectValue placeholder="Vsi" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Vsi</SelectItem>
|
||||
<SelectItem
|
||||
v-for="u in users || []"
|
||||
:key="u.id"
|
||||
:value="String(u.id)"
|
||||
>
|
||||
{{ u.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-case_person="{ row }">
|
||||
<Link
|
||||
v-if="row.client_case?.uuid"
|
||||
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.client_case?.person?.full_name || "Primer stranke" }}
|
||||
</Link>
|
||||
<span v-else>{{ row.client_case?.person?.full_name || "-" }}</span>
|
||||
</template>
|
||||
<template #cell-_actions="{ row }">
|
||||
<button
|
||||
class="px-3 py-1 text-xs rounded bg-red-600 text-white"
|
||||
@click="cancelAssignment(row)"
|
||||
>
|
||||
Prekliči
|
||||
</button>
|
||||
<template #cell-assigned_at="{ row }">
|
||||
{{ row.assigned_at_formatted }}
|
||||
</template>
|
||||
<template #empty>
|
||||
<div class="text-sm text-gray-500 py-4 text-center">
|
||||
Ni dodeljenih pogodb za izbran filter.
|
||||
<template #cell-balance_amount="{ row }">
|
||||
<div class="text-right">
|
||||
{{ formatCurrencyEUR(row.account?.balance_amount) }}
|
||||
</div>
|
||||
</template>
|
||||
</DataTableClient>
|
||||
<template #cell-_actions="{ row }">
|
||||
<Button variant="destructive" size="sm" @click="cancelAssignment(row)">
|
||||
Prekliči
|
||||
</Button>
|
||||
</template>
|
||||
</DataTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { useForm, router } from "@inertiajs/vue3";
|
||||
import { ref, computed } from "vue";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import axios from "axios";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
|
||||
// Props: provided by controller (clients + templates collections)
|
||||
const props = defineProps({
|
||||
templates: Array,
|
||||
clients: Array,
|
||||
});
|
||||
|
||||
// Basic create form (rest of workflow handled on the Continue page)
|
||||
const form = useForm({
|
||||
client_uuid: null,
|
||||
import_template_id: null,
|
||||
@@ -19,36 +27,12 @@ const form = useForm({
|
||||
file: null,
|
||||
});
|
||||
|
||||
// Multiselect bridge: client
|
||||
const selectedClientOption = computed({
|
||||
get() {
|
||||
if (!form.client_uuid) return null;
|
||||
return (props.clients || []).find((c) => c.uuid === form.client_uuid) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.client_uuid = val ? val.uuid : null;
|
||||
},
|
||||
});
|
||||
|
||||
// Multiselect bridge: template
|
||||
const selectedTemplateOption = computed({
|
||||
get() {
|
||||
if (form.import_template_id == null) return null;
|
||||
return (props.templates || []).find((t) => t.id === form.import_template_id) || null;
|
||||
},
|
||||
set(val) {
|
||||
form.import_template_id = val ? val.id : null;
|
||||
},
|
||||
});
|
||||
|
||||
// Filter templates: show globals when no client; when client selected show only that client's templates (no mixing to avoid confusion)
|
||||
// Filter templates: show globals when no client; when client selected show only that client's templates
|
||||
const filteredTemplates = computed(() => {
|
||||
const cuuid = form.client_uuid;
|
||||
const list = props.templates || [];
|
||||
if (!cuuid) {
|
||||
return list.filter((t) => t.client_id == null);
|
||||
if (!form.client_uuid) {
|
||||
return props.templates.filter((t) => !t.client_id);
|
||||
}
|
||||
return list.filter((t) => t.client_uuid === cuuid || t.client_id == null);
|
||||
return props.templates.filter((t) => t.client_uuid === form.client_uuid || !t.client_id);
|
||||
});
|
||||
|
||||
const uploading = ref(false);
|
||||
@@ -57,7 +41,7 @@ const uploadError = ref(null);
|
||||
|
||||
function onFileChange(e) {
|
||||
const files = e.target.files;
|
||||
if (files && files.length) {
|
||||
if (files?.length) {
|
||||
form.file = files[0];
|
||||
uploadError.value = null;
|
||||
}
|
||||
@@ -65,50 +49,51 @@ function onFileChange(e) {
|
||||
|
||||
function onFileDrop(e) {
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length) {
|
||||
if (files?.length) {
|
||||
form.file = files[0];
|
||||
uploadError.value = null;
|
||||
}
|
||||
dragActive.value = false;
|
||||
}
|
||||
|
||||
function clearFile() {
|
||||
form.file = null;
|
||||
uploadError.value = null;
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
uploadError.value = null;
|
||||
if (!form.file) {
|
||||
uploadError.value = "Najprej izberite datoteko."; // "Select a file first."
|
||||
uploadError.value = "Najprej izberite datoteko.";
|
||||
return;
|
||||
}
|
||||
|
||||
uploading.value = true;
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append("file", form.file);
|
||||
if (form.import_template_id != null) {
|
||||
if (form.import_template_id) {
|
||||
fd.append("import_template_id", String(form.import_template_id));
|
||||
}
|
||||
if (form.client_uuid) {
|
||||
fd.append("client_uuid", form.client_uuid);
|
||||
}
|
||||
fd.append("has_header", form.has_header ? "1" : "0");
|
||||
|
||||
const { data } = await axios.post(route("imports.store"), fd, {
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
if (data?.uuid) {
|
||||
router.visit(route("imports.continue", { import: data.uuid }));
|
||||
return;
|
||||
}
|
||||
if (data?.id) {
|
||||
// Fallback if only numeric id returned
|
||||
} else if (data?.id) {
|
||||
router.visit(route("imports.continue", { import: data.id }));
|
||||
return;
|
||||
}
|
||||
uploadError.value = "Nepričakovan odgovor strežnika."; // Unexpected server response.
|
||||
} catch (e) {
|
||||
if (e.response?.data?.message) {
|
||||
uploadError.value = e.response.data.message;
|
||||
} else {
|
||||
uploadError.value = "Nalaganje ni uspelo."; // Upload failed.
|
||||
uploadError.value = "Nepričakovan odgovor strežnika.";
|
||||
}
|
||||
} catch (e) {
|
||||
uploadError.value = e.response?.data?.message || "Nalaganje ni uspelo.";
|
||||
console.error("Import upload failed", e.response?.status, e.response?.data || e);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
@@ -119,13 +104,14 @@ async function startImport() {
|
||||
<template>
|
||||
<AppLayout title="Nov uvoz">
|
||||
<template #header>
|
||||
<h2 class="font-semibold text-xl text-gray-800 leading-tight">Nov uvoz</h2>
|
||||
<h2 class="text-xl font-semibold leading-tight text-gray-800">Nov uvoz</h2>
|
||||
</template>
|
||||
|
||||
<div class="py-6">
|
||||
<div class="max-w-4xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="bg-white shadow sm:rounded-lg p-6 space-y-8">
|
||||
<!-- Intro / guidance -->
|
||||
<div class="text-sm text-gray-600 leading-relaxed">
|
||||
<div class="mx-auto max-w-4xl sm:px-6 lg:px-8">
|
||||
<div class="space-y-8 rounded-lg bg-white p-6 shadow sm:rounded-lg">
|
||||
<!-- Intro -->
|
||||
<div class="text-sm leading-relaxed text-gray-600">
|
||||
<p class="mb-2">
|
||||
1) Izberite stranko (opcijsko) in predlogo (če obstaja), 2) izberite
|
||||
datoteko (CSV, TXT, XLSX*) in 3) kliknite Začni uvoz. Nadaljnje preslikave
|
||||
@@ -137,130 +123,138 @@ async function startImport() {
|
||||
</div>
|
||||
|
||||
<!-- Client & Template selection -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Stranka</label>
|
||||
<Multiselect
|
||||
v-model="selectedClientOption"
|
||||
:options="clients"
|
||||
track-by="uuid"
|
||||
label="name"
|
||||
placeholder="Poišči stranko..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Predloga</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateOption"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Poišči predlogo..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<span class="truncate">{{ option.name }}</span>
|
||||
<span
|
||||
class="ml-2 text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600"
|
||||
>{{ option.client_id ? "Client" : "Global" }}</span
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<!-- Client Select -->
|
||||
<div class="space-y-2">
|
||||
<Label>Stranka</Label>
|
||||
<Select v-model="form.client_uuid">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberite stranko..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="client in clients"
|
||||
:key="client.uuid"
|
||||
:value="client.uuid"
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
<p class="text-xs text-gray-500 mt-1" v-if="!form.client_uuid">
|
||||
{{ client.name }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p class="text-xs text-gray-500">
|
||||
Če stranka ni izbrana, bo uvoz globalen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Select -->
|
||||
<div class="space-y-2">
|
||||
<Label>Predloga</Label>
|
||||
<Select v-model="form.import_template_id">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberite predlogo..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.id"
|
||||
:value="template.id"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<span class="truncate">{{ template.name }}</span>
|
||||
<span
|
||||
class="ml-2 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600"
|
||||
>
|
||||
{{ template.client_id ? "Client" : "Global" }}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="!form.client_uuid" class="mt-1 text-xs text-gray-500">
|
||||
Prikazane so samo globalne predloge dokler ne izberete stranke.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File + Header -->
|
||||
<div class="grid grid-cols-1 gap-6 items-start">
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Datoteka</label>
|
||||
<div
|
||||
class="border-2 border-dashed rounded-md p-6 text-center cursor-pointer transition-colors"
|
||||
:class="{
|
||||
'border-indigo-400 bg-indigo-50': dragActive,
|
||||
'border-gray-300 hover:border-gray-400': !dragActive,
|
||||
}"
|
||||
@dragover.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="onFileDrop"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
id="import-file-input"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<label for="import-file-input" class="block cursor-pointer select-none">
|
||||
<div v-if="!form.file" class="text-sm text-gray-600">
|
||||
Povlecite datoteko sem ali
|
||||
<span class="text-indigo-600 underline">kliknite za izbiro</span>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-800 flex flex-col gap-1">
|
||||
<span class="font-medium">{{ form.file.name }}</span>
|
||||
<span class="text-xs text-gray-500"
|
||||
>{{ (form.file.size / 1024).toFixed(1) }} kB</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] inline-block bg-gray-100 px-1.5 py-0.5 rounded"
|
||||
>Zamenjaj</span
|
||||
>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center gap-2 text-sm font-medium text-gray-700">
|
||||
<input type="checkbox" v-model="form.has_header" class="rounded" />
|
||||
<span>Prva vrstica je glava</span>
|
||||
<!-- File Upload -->
|
||||
<div class="space-y-4">
|
||||
<Label>Datoteka</Label>
|
||||
<div
|
||||
class="cursor-pointer rounded-md border-2 border-dashed p-6 text-center transition-colors"
|
||||
:class="{
|
||||
'border-indigo-400 bg-indigo-50': dragActive,
|
||||
'border-gray-300 hover:border-gray-400': !dragActive,
|
||||
}"
|
||||
@dragover.prevent="dragActive = true"
|
||||
@dragleave.prevent="dragActive = false"
|
||||
@drop.prevent="onFileDrop"
|
||||
>
|
||||
<input
|
||||
id="import-file-input"
|
||||
type="file"
|
||||
class="hidden"
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<label for="import-file-input" class="block cursor-pointer select-none">
|
||||
<div v-if="!form.file" class="text-sm text-gray-600">
|
||||
Povlecite datoteko sem ali
|
||||
<span class="text-indigo-600 underline">kliknite za izbiro</span>
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1 text-sm text-gray-800">
|
||||
<span class="font-medium">{{ form.file.name }}</span>
|
||||
<span class="text-xs text-gray-500">
|
||||
{{ (form.file.size / 1024).toFixed(1) }} kB
|
||||
</span>
|
||||
<span class="inline-block rounded bg-gray-100 px-1.5 py-0.5 text-[10px]">
|
||||
Zamenjaj
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<div class="text-xs text-gray-500 leading-relaxed">
|
||||
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Has Header Checkbox -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<Checkbox id="has-header" v-model:checked="form.has_header" />
|
||||
<Label
|
||||
for="has-header"
|
||||
class="cursor-pointer text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Prva vrstica je glava
|
||||
</Label>
|
||||
</div>
|
||||
<p class="text-xs leading-relaxed text-gray-500">
|
||||
Če ni označeno, bodo stolpci poimenovani po zaporedju (A, B, C ...).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Errors -->
|
||||
<div v-if="uploadError" class="text-sm text-red-600">
|
||||
<!-- Error Message -->
|
||||
<div v-if="uploadError" class="rounded-md bg-red-50 p-3 text-sm text-red-600">
|
||||
{{ uploadError }}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-wrap justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="
|
||||
() => {
|
||||
form.file = null;
|
||||
uploadError = null;
|
||||
}
|
||||
"
|
||||
<div class="flex flex-wrap justify-end gap-3 border-t pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
:disabled="uploading || !form.file"
|
||||
class="px-4 py-2 text-sm rounded border bg-white disabled:opacity-50"
|
||||
@click="clearFile"
|
||||
>
|
||||
Počisti
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="startImport"
|
||||
:disabled="uploading"
|
||||
class="inline-flex items-center gap-2 px-5 py-2.5 rounded bg-indigo-600 disabled:bg-indigo-300 text-white text-sm font-medium shadow-sm"
|
||||
>
|
||||
</Button>
|
||||
<Button :disabled="uploading" @click="startImport">
|
||||
<span
|
||||
v-if="uploading"
|
||||
class="h-4 w-4 border-2 border-white/60 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<span>{{ uploading ? "Nalagam..." : "Začni uvoz" }}</span>
|
||||
</button>
|
||||
class="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white/60 border-t-transparent"
|
||||
/>
|
||||
{{ uploading ? "Nalagam..." : "Začni uvoz" }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-gray-400 pt-4 border-t">
|
||||
<div class="border-t pt-4 text-xs text-gray-400">
|
||||
Po nalaganju boste preusmerjeni na nadaljevanje uvoza, kjer lahko izvedete
|
||||
preslikave, simulacijo in končno obdelavo.
|
||||
</div>
|
||||
|
||||
@@ -9,13 +9,19 @@ import LogsTable from "./Partials/LogsTable.vue";
|
||||
import ProcessResult from "./Partials/ProcessResult.vue";
|
||||
import { ref, computed, onMounted, watch } from "vue";
|
||||
import { router } from "@inertiajs/vue3";
|
||||
import Multiselect from "vue-multiselect";
|
||||
import axios from "axios";
|
||||
import Modal from "@/Components/Modal.vue"; // still potentially used elsewhere
|
||||
import Modal from "@/Components/Modal.vue";
|
||||
import CsvPreviewModal from "./Partials/CsvPreviewModal.vue";
|
||||
import SimulationModal from "./Partials/SimulationModal.vue";
|
||||
import MissingContractsModal from "./Partials/MissingContractsModal.vue";
|
||||
import FoundContractsModal from "./Partials/FoundContractsModal.vue";
|
||||
import UnresolvedRowsModal from "./Partials/UnresolvedRowsModal.vue";
|
||||
import { useCurrencyFormat } from "./useCurrencyFormat.js";
|
||||
import DialogModal from "@/Components/DialogModal.vue";
|
||||
import { Switch } from "@/Components/ui/switch";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
|
||||
// Reintroduce props definition lost during earlier edits
|
||||
const props = defineProps({
|
||||
@@ -180,11 +186,6 @@ async function openUnresolved() {
|
||||
unresolvedLoading.value = false;
|
||||
}
|
||||
}
|
||||
function downloadUnresolvedCsv() {
|
||||
if (!importId.value) return;
|
||||
// Direct download
|
||||
window.location.href = route("imports.missing-keyref-csv", { import: importId.value });
|
||||
}
|
||||
|
||||
// History import: list of contracts that already existed in DB and were matched
|
||||
const isHistoryImport = computed(() => {
|
||||
@@ -592,32 +593,39 @@ const statusInfo = computed(() => {
|
||||
completed: {
|
||||
label: "Zaključeno",
|
||||
classes: "bg-emerald-100 text-emerald-700 border border-emerald-300",
|
||||
variant: "default",
|
||||
},
|
||||
processing: {
|
||||
label: "Obdelava",
|
||||
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
|
||||
variant: "default",
|
||||
},
|
||||
validating: {
|
||||
label: "Preverjanje",
|
||||
classes: "bg-indigo-100 text-indigo-700 border border-indigo-300",
|
||||
variant: "default",
|
||||
},
|
||||
failed: {
|
||||
label: "Neuspešno",
|
||||
classes: "bg-red-100 text-red-700 border border-red-300",
|
||||
variant: "destructive",
|
||||
},
|
||||
parsed: {
|
||||
label: "Razčlenjeno",
|
||||
classes: "bg-slate-100 text-slate-700 border border-slate-300",
|
||||
variant: "secondary",
|
||||
},
|
||||
uploaded: {
|
||||
label: "Naloženo",
|
||||
classes: "bg-slate-100 text-slate-700 border border-slate-300",
|
||||
variant: "secondary",
|
||||
},
|
||||
};
|
||||
return (
|
||||
map[raw] || {
|
||||
label: raw || "Status",
|
||||
classes: "bg-gray-100 text-gray-700 border border-gray-300",
|
||||
variant: "outline",
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -1117,11 +1125,19 @@ async function fetchSimulation() {
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
// V2 format
|
||||
paymentSimRows.value = Array.isArray(data?.rows) ? data.rows : [];
|
||||
paymentSimEntities.value = Array.isArray(data?.entities) ? data.entities : [];
|
||||
// Summaries keys vary (payment, contract, account, etc.). Keep existing behaviour for payment summary exposure.
|
||||
paymentSimSummary.value = data?.summaries?.payment || null;
|
||||
paymentSimSummarySl.value = data?.povzetki?.payment || null;
|
||||
paymentSimSummary.value = data?.summaries || null;
|
||||
|
||||
// Extract unique entity types from rows for SimulationModal
|
||||
const entitySet = new Set();
|
||||
for (const row of data?.rows || []) {
|
||||
if (row.entities && typeof row.entities === 'object') {
|
||||
Object.keys(row.entities).forEach(key => entitySet.add(key));
|
||||
}
|
||||
}
|
||||
paymentSimEntities.value = Array.from(entitySet);
|
||||
} catch (e) {
|
||||
console.error("Simulation failed", e.response?.status || "", e.response?.data || e);
|
||||
} finally {
|
||||
@@ -1142,20 +1158,20 @@ async function fetchSimulation() {
|
||||
selectedClientOption?.name || selectedClientOption?.uuid || "—"
|
||||
}}</strong>
|
||||
</span>
|
||||
<span
|
||||
v-if="templateApplied"
|
||||
class="text-[10px] px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 align-middle"
|
||||
>uporabljena</span
|
||||
<Badge v-if="templateApplied" variant="secondary" class="text-[10px]"
|
||||
>uporabljena</Badge
|
||||
>
|
||||
<span
|
||||
<Badge
|
||||
v-if="props.import?.status"
|
||||
:class="['px-2 py-0.5 rounded-full text-xs font-medium', statusInfo.classes]"
|
||||
>{{ statusInfo.label }}</span
|
||||
:variant="statusInfo.variant || 'default'"
|
||||
class="text-xs"
|
||||
>{{ statusInfo.label }}</Badge
|
||||
>
|
||||
<span
|
||||
<Badge
|
||||
v-if="showMissingEnabled"
|
||||
class="text-[10px] px-1 py-0.5 rounded bg-amber-100 text-amber-700 align-middle"
|
||||
>seznam manjkajočih</span
|
||||
variant="outline"
|
||||
class="text-[10px] bg-amber-50 text-amber-700 border-amber-200"
|
||||
>seznam manjkajočih</Badge
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1167,13 +1183,15 @@ async function fetchSimulation() {
|
||||
v-if="isHistoryImport || historyFoundContracts.length"
|
||||
class="flex flex-wrap items-center gap-2 text-sm"
|
||||
>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-emerald-700 text-white text-xs rounded"
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="bg-emerald-700 hover:bg-emerald-800 text-xs"
|
||||
@click.prevent="showFoundContracts = true"
|
||||
title="Prikaži pogodbe, ki so bile najdene in že obstajajo v bazi"
|
||||
>
|
||||
Najdene pogodbe
|
||||
</button>
|
||||
</Button>
|
||||
<span v-if="historyFoundContracts.length" class="text-xs text-gray-600">
|
||||
{{ historyFoundContracts.length }} že obstoječih
|
||||
</span>
|
||||
@@ -1210,28 +1228,34 @@ async function fetchSimulation() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 bg-gray-700 text-white text-xs rounded"
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
class="text-xs"
|
||||
@click.prevent="openPreview"
|
||||
>
|
||||
Ogled CSV
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canShowMissingButton"
|
||||
class="px-3 py-1.5 bg-indigo-600 text-white text-xs rounded"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-xs"
|
||||
@click.prevent="openMissingContracts"
|
||||
title="Prikaži aktivne pogodbe, ki niso bile prisotne v uvozu (samo keyref)"
|
||||
>
|
||||
Ogled manjkajoče
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
v-if="isCompleted && contractRefIsKeyref"
|
||||
class="px-3 py-1.5 bg-amber-600 text-white text-xs rounded"
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="bg-amber-600 hover:bg-amber-700 text-xs"
|
||||
@click.prevent="openUnresolved"
|
||||
title="Prikaži vrstice, kjer pogodba (keyref) ni bila najdena"
|
||||
>
|
||||
Neobstoječi
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
@@ -1265,22 +1289,34 @@ async function fetchSimulation() {
|
||||
@apply-template="applyTemplateToImport"
|
||||
/>
|
||||
<!-- Import options -->
|
||||
<div v-if="!isCompleted" class="mt-2 p-3 rounded border bg-gray-50">
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="inline-flex items-center text-sm text-gray-700">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="rounded mr-2"
|
||||
v-model="showMissingEnabled"
|
||||
@change="saveImportOptions"
|
||||
/>
|
||||
<span>Seznam manjkajočih (po končanem uvozu)</span>
|
||||
</label>
|
||||
<div
|
||||
v-if="!isCompleted"
|
||||
class="mt-2 p-4 rounded-lg border bg-linear-to-br from-gray-50 to-gray-100"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<Checkbox
|
||||
:id="'show-missing-checkbox'"
|
||||
:checked="showMissingEnabled"
|
||||
@update:checked="
|
||||
(val) => {
|
||||
showMissingEnabled = val;
|
||||
saveImportOptions();
|
||||
}
|
||||
"
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<Label
|
||||
:for="'show-missing-checkbox'"
|
||||
class="text-sm font-medium text-gray-700 cursor-pointer"
|
||||
>
|
||||
Seznam manjkajočih (po končanem uvozu)
|
||||
</Label>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Ko je omogočeno in je "contract.reference" nastavljen na keyref, bo po
|
||||
končanem uvozu na voljo gumb za ogled pogodb, ki jih ni v datoteki.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
Ko je omogočeno in je "contract.reference" nastavljen na keyref, bo po
|
||||
končanem uvozu na voljo gumb za ogled pogodb, ki jih ni v datoteki.
|
||||
</p>
|
||||
</div>
|
||||
<ChecklistSteps :steps="stepStates" :missing-critical="missingCritical" />
|
||||
</div>
|
||||
@@ -1356,160 +1392,37 @@ async function fetchSimulation() {
|
||||
:truncated="previewTruncated"
|
||||
:has-header="detected.has_header"
|
||||
@close="showPreview = false"
|
||||
@change-limit="(val) => (previewLimit = val)"
|
||||
@change-limit="
|
||||
async (val) => {
|
||||
previewLimit = val;
|
||||
await fetchPreview();
|
||||
}
|
||||
"
|
||||
@refresh="fetchPreview"
|
||||
/>
|
||||
<!-- Missing contracts modal -->
|
||||
<Modal
|
||||
|
||||
<MissingContractsModal
|
||||
:show="showMissingContracts"
|
||||
max-width="2xl"
|
||||
:loading="missingContractsLoading"
|
||||
:contracts="missingContracts"
|
||||
:format-money="formatMoney"
|
||||
@close="showMissingContracts = false"
|
||||
>
|
||||
<div class="p-4 max-h-[70vh] overflow-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-lg">Manjkajoče pogodbe (aktivne, ne-arhivirane)</h3>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
@click.prevent="showMissingContracts = false"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="missingContractsLoading" class="py-8 text-center text-sm text-gray-500">
|
||||
Nalagam …
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="!missingContracts.length" class="py-6 text-sm text-gray-600">
|
||||
Ni zadetkov.
|
||||
</div>
|
||||
<ul v-else class="divide-y divide-gray-200">
|
||||
<li
|
||||
v-for="row in missingContracts"
|
||||
:key="row.uuid"
|
||||
class="py-2 text-sm flex items-center justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-gray-800">{{ row.reference }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">
|
||||
<span class="font-medium text-gray-600">Primer: </span>
|
||||
<span>{{ row.full_name || "—" }}</span>
|
||||
<span v-if="row.balance_amount != null" class="ml-2"
|
||||
>• {{ formatMoney(row.balance_amount) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<a
|
||||
:href="route('clientCase.show', { client_case: row.case_uuid })"
|
||||
class="text-blue-600 hover:underline text-xs"
|
||||
>Odpri primer</a
|
||||
>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
/>
|
||||
|
||||
<!-- History import: existing contracts found -->
|
||||
<DialogModal :show="showFoundContracts" max-width="3xl" @close="showFoundContracts = false">
|
||||
<template #title>Obstoječe pogodbe najdene v zgodovinskem uvozu</template>
|
||||
<template #content>
|
||||
<div v-if="!historyFoundContracts.length" class="text-sm text-gray-600">Ni zadetkov.</div>
|
||||
<ul v-else class="divide-y divide-gray-200 max-h-[70vh] overflow-auto">
|
||||
<li
|
||||
v-for="item in historyFoundContracts"
|
||||
:key="item.contract_uuid || item.reference"
|
||||
class="py-3 flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="font-mono text-sm text-gray-900">{{ item.reference }}</div>
|
||||
<div class="text-xs text-gray-600 truncate">
|
||||
<span>{{ item.full_name || "—" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<a
|
||||
v-if="item.case_uuid"
|
||||
:href="route('clientCase.show', { client_case: item.case_uuid })"
|
||||
class="text-blue-600 hover:underline text-xs"
|
||||
>
|
||||
Odpri primer
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template #footer>
|
||||
<button
|
||||
class="px-3 py-1.5 bg-gray-700 text-white text-xs rounded"
|
||||
@click.prevent="showFoundContracts = false"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</template>
|
||||
</DialogModal>
|
||||
<FoundContractsModal
|
||||
:show="showFoundContracts"
|
||||
:contracts="historyFoundContracts"
|
||||
@close="showFoundContracts = false"
|
||||
/>
|
||||
|
||||
<!-- Unresolved keyref rows modal -->
|
||||
<Modal :show="showUnresolved" max-width="5xl" @close="showUnresolved = false">
|
||||
<div class="p-4 max-h-[75vh] overflow-auto">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="font-semibold text-lg">
|
||||
Vrstice z neobstoječim contract.reference (KEYREF)
|
||||
</h3>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 bg-green-600 text-white text-xs rounded"
|
||||
@click.prevent="downloadUnresolvedCsv"
|
||||
>
|
||||
Prenesi CSV
|
||||
</button>
|
||||
<button
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
@click.prevent="showUnresolved = false"
|
||||
>
|
||||
Zapri
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="unresolvedLoading" class="py-8 text-center text-sm text-gray-500">
|
||||
Nalagam …
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="!unresolvedRows.length" class="py-6 text-sm text-gray-600">
|
||||
Ni zadetkov.
|
||||
</div>
|
||||
<div v-else class="overflow-auto border border-gray-200 rounded">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left w-24"># vrstica</th>
|
||||
<th
|
||||
v-for="(c, i) in unresolvedColumns"
|
||||
:key="i"
|
||||
class="px-3 py-2 text-left"
|
||||
>
|
||||
{{ c }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="r in unresolvedRows" :key="r.id" class="border-t">
|
||||
<td class="px-3 py-2 text-gray-500">{{ r.row_number }}</td>
|
||||
<td
|
||||
v-for="(c, i) in unresolvedColumns"
|
||||
:key="i"
|
||||
class="px-3 py-2 whitespace-pre-wrap break-words"
|
||||
>
|
||||
{{ r.values?.[i] ?? "" }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<UnresolvedRowsModal
|
||||
:show="showUnresolved"
|
||||
:loading="unresolvedLoading"
|
||||
:columns="unresolvedColumns"
|
||||
:rows="unresolvedRows"
|
||||
:import-id="importId"
|
||||
@close="showUnresolved = false"
|
||||
/>
|
||||
<SimulationModal
|
||||
:show="showPaymentSim"
|
||||
:rows="paymentSimRows"
|
||||
@@ -1522,8 +1435,9 @@ async function fetchSimulation() {
|
||||
:money-formatter="formatMoney"
|
||||
@close="showPaymentSim = false"
|
||||
@change-limit="
|
||||
(val) => {
|
||||
async (val) => {
|
||||
paymentSimLimit = val;
|
||||
await fetchSimulation();
|
||||
}
|
||||
"
|
||||
@toggle-verbose="
|
||||
|
||||
@@ -5,6 +5,9 @@ import {
|
||||
BeakerIcon,
|
||||
ArrowDownOnSquareIcon,
|
||||
} from "@heroicons/vue/24/outline";
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
|
||||
const props = defineProps({
|
||||
importId: [Number, String],
|
||||
isCompleted: Boolean,
|
||||
@@ -17,47 +20,50 @@ const emits = defineEmits(["preview", "save-mappings", "process-import", "simula
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-wrap gap-2 items-center" v-if="!isCompleted">
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
@click.prevent="$emit('preview')"
|
||||
:disabled="!importId"
|
||||
class="px-4 py-2 bg-gray-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<EyeIcon class="h-4 w-4" />
|
||||
<EyeIcon class="h-4 w-4 mr-2" />
|
||||
Predogled vrstic
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-orange-600 hover:bg-orange-700"
|
||||
@click.prevent="$emit('save-mappings')"
|
||||
: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="Shrani preslikave za ta uvoz"
|
||||
>
|
||||
<span
|
||||
v-if="savingMappings"
|
||||
class="inline-block h-4 w-4 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
class="inline-block h-4 w-4 mr-2 border-2 border-white/70 border-t-transparent rounded-full animate-spin"
|
||||
></span>
|
||||
<ArrowPathIcon v-else class="h-4 w-4" />
|
||||
<ArrowPathIcon v-else class="h-4 w-4 mr-2" />
|
||||
<span>Shrani preslikave</span>
|
||||
<span
|
||||
<Badge
|
||||
v-if="selectedMappingsCount"
|
||||
class="ml-1 text-xs bg-white/20 px-1.5 py-0.5 rounded"
|
||||
>{{ selectedMappingsCount }}</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
variant="secondary"
|
||||
class="ml-2 text-xs"
|
||||
>{{ selectedMappingsCount }}</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-purple-600 hover:bg-purple-700"
|
||||
@click.prevent="$emit('process-import')"
|
||||
:disabled="!canProcess"
|
||||
class="px-4 py-2 bg-purple-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<BeakerIcon class="h-4 w-4" />
|
||||
<BeakerIcon class="h-4 w-4 mr-2" />
|
||||
{{ processing ? "Obdelava…" : "Obdelaj uvoz" }}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
class="bg-blue-600 hover:bg-blue-700"
|
||||
@click.prevent="$emit('simulate')"
|
||||
:disabled="!importId || processing"
|
||||
class="px-4 py-2 bg-blue-600 disabled:bg-gray-300 text-white rounded flex items-center gap-2"
|
||||
>
|
||||
<ArrowDownOnSquareIcon class="h-4 w-4" />
|
||||
<ArrowDownOnSquareIcon class="h-4 w-4 mr-2" />
|
||||
Simulacija vnosa
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
<script setup>
|
||||
import { CheckCircleIcon } from '@heroicons/vue/24/solid'
|
||||
import { Badge } from '@/Components/ui/badge'
|
||||
const props = defineProps({ steps: Array, missingCritical: Array })
|
||||
</script>
|
||||
<template>
|
||||
<div class="bg-gray-50 border rounded p-3 text-xs flex flex-col gap-1 h-fit">
|
||||
<div class="font-semibold text-gray-700 mb-1">Kontrolni seznam</div>
|
||||
<div v-for="s in steps" :key="s.label" class="flex items-center gap-2" :class="s.done ? 'text-emerald-700' : 'text-gray-500'">
|
||||
<div class="bg-muted/50 border rounded-lg p-4 text-xs flex flex-col gap-2 h-fit">
|
||||
<div class="font-semibold text-foreground mb-1">Kontrolni seznam</div>
|
||||
<div v-for="s in steps" :key="s.label" class="flex items-center gap-2" :class="s.done ? 'text-emerald-700' : 'text-muted-foreground'">
|
||||
<CheckCircleIcon v-if="s.done" class="h-4 w-4 text-emerald-600" />
|
||||
<span v-else class="h-4 w-4 rounded-full border border-gray-300 inline-block"></span>
|
||||
<span v-else class="h-4 w-4 rounded-full border-2 border-muted-foreground/30 inline-block"></span>
|
||||
<span>{{ s.label }}</span>
|
||||
</div>
|
||||
<div v-if="missingCritical?.length" class="mt-2 text-red-600 font-medium">Manjkajo kritične: {{ missingCritical.join(', ') }}</div>
|
||||
<div v-else class="mt-2 text-emerald-600">Kritične preslikave prisotne</div>
|
||||
<div v-if="missingCritical?.length" class="mt-2">
|
||||
<Badge variant="destructive" class="text-[10px]">Manjkajo kritične: {{ missingCritical.join(', ') }}</Badge>
|
||||
</div>
|
||||
<div v-else class="mt-2">
|
||||
<Badge variant="default" class="text-[10px] bg-emerald-600">Kritične preslikave prisotne</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script setup>
|
||||
import Modal from '@/Components/Modal.vue'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
limit: Number,
|
||||
@@ -13,49 +18,69 @@ const emits = defineEmits(['close','change-limit','refresh'])
|
||||
function onLimit(e){ emits('change-limit', Number(e.target.value)); emits('refresh') }
|
||||
</script>
|
||||
<template>
|
||||
<Modal :show="show" max-width="wide" @close="$emit('close')">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="font-semibold text-lg">CSV Preview ({{ rows.length }} / {{ limit }})</h3>
|
||||
<button class="text-sm px-2 py-1 rounded border" @click="$emit('close')">Close</button>
|
||||
</div>
|
||||
<div class="mb-2 flex items-center gap-3 text-sm">
|
||||
<div>
|
||||
<label class="mr-1 text-gray-600">Limit:</label>
|
||||
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="200">200</option>
|
||||
<option :value="300">300</option>
|
||||
<option :value="500">500</option>
|
||||
</select>
|
||||
<Dialog :open="show" @update:open="(val) => !val && $emit('close')">
|
||||
<DialogContent class="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>CSV Preview ({{ rows.length }} / {{ limit }})</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex items-center gap-3 pb-3 border-b">
|
||||
<div class="flex items-center gap-2">
|
||||
<Label for="limit-select" class="text-sm text-gray-600">Limit:</Label>
|
||||
<Select :model-value="String(limit)" @update:model-value="(val) => { emits('change-limit', Number(val)); emits('refresh'); }">
|
||||
<SelectTrigger id="limit-select" class="w-24 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="300">300</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<button @click="$emit('refresh')" class="px-2 py-1 border rounded" :disabled="loading">{{ loading ? 'Loading…' : 'Refresh' }}</button>
|
||||
<span v-if="truncated" class="text-xs text-amber-600">Truncated at limit</span>
|
||||
<Button @click="$emit('refresh')" variant="outline" size="sm" :disabled="loading">
|
||||
{{ loading ? 'Loading…' : 'Refresh' }}
|
||||
</Button>
|
||||
<Badge v-if="truncated" variant="outline" class="bg-amber-50 text-amber-700 border-amber-200">
|
||||
Truncated at limit
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="overflow-auto max-h-[60vh] border rounded">
|
||||
<table class="min-w-full text-xs">
|
||||
<thead class="bg-gray-50 sticky top-0">
|
||||
<tr>
|
||||
<th class="p-2 border bg-white">#</th>
|
||||
<th v-for="col in columns" :key="col" class="p-2 border text-left">{{ col }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">Loading…</td>
|
||||
</tr>
|
||||
<tr v-for="(r, idx) in rows" :key="idx" class="border-t hover:bg-gray-50">
|
||||
<td class="p-2 border text-gray-500">{{ idx + 1 }}</td>
|
||||
<td v-for="col in columns" :key="col" class="p-2 border whitespace-pre-wrap">{{ r[col] }}</td>
|
||||
</tr>
|
||||
<tr v-if="!loading && !rows.length">
|
||||
<td :colspan="columns.length + 1" class="p-4 text-center text-gray-500">No rows</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="flex-1 overflow-auto border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 bg-white z-10">
|
||||
<TableRow>
|
||||
<TableHead class="w-16">#</TableHead>
|
||||
<TableHead v-for="col in columns" :key="col">{{ col }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-if="loading">
|
||||
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
|
||||
Loading…
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-for="(r, idx) in rows" :key="idx">
|
||||
<TableCell class="text-gray-500 font-medium">{{ idx + 1 }}</TableCell>
|
||||
<TableCell v-for="col in columns" :key="col" class="whitespace-pre-wrap">
|
||||
{{ r[col] }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!loading && !rows.length">
|
||||
<TableCell :colspan="columns.length + 1" class="text-center text-gray-500">
|
||||
No rows
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">Showing up to {{ limit }} rows from source file. Header detection: {{ hasHeader ? 'header present' : 'no header' }}.</p>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<div class="text-xs text-gray-500 pt-3 border-t">
|
||||
Showing up to {{ limit }} rows from source file.
|
||||
Header detection: <span class="font-medium">{{ hasHeader ? 'header present' : 'no header' }}</span>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
contracts: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
|
||||
<DialogContent class="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Obstoječe pogodbe najdene v zgodovinskem uvozu</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="!contracts.length" class="py-12 text-center">
|
||||
<p class="text-sm text-gray-500">Ni zadetkov.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="item in contracts"
|
||||
:key="item.contract_uuid || item.reference"
|
||||
class="p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<code class="text-sm font-medium text-gray-900">{{ item.reference }}</code>
|
||||
<Badge variant="outline" class="text-[10px]">Najdena</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600">
|
||||
<span>{{ item.full_name || "—" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="item.case_uuid"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
as="a"
|
||||
:href="route('clientCase.show', { client_case: item.case_uuid })"
|
||||
class="shrink-0"
|
||||
>
|
||||
Odpri primer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 flex justify-end">
|
||||
<Button variant="secondary" @click="emit('close')">Zapri</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,6 +1,12 @@
|
||||
<script setup>
|
||||
import { ref, computed } from "vue";
|
||||
import Dropdown from "@/Components/Dropdown.vue";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
||||
import { Button } from '@/Components/ui/button';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/Components/ui/dialog';
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/Components/ui/accordion';
|
||||
|
||||
const props = defineProps({
|
||||
events: Array,
|
||||
@@ -8,8 +14,8 @@ const props = defineProps({
|
||||
limit: Number,
|
||||
});
|
||||
const emits = defineEmits(["update:limit", "refresh"]);
|
||||
function onLimit(e) {
|
||||
emits("update:limit", Number(e.target.value));
|
||||
function onLimit(val) {
|
||||
emits("update:limit", Number(val));
|
||||
emits("refresh");
|
||||
}
|
||||
|
||||
@@ -46,6 +52,32 @@ function toggleExpand(id) {
|
||||
expanded.value = new Set(expanded.value);
|
||||
}
|
||||
|
||||
// Entity details dialog
|
||||
const detailsDialog = ref(false);
|
||||
const selectedEvent = ref(null);
|
||||
|
||||
function hasEntityDetails(ev) {
|
||||
const ctx = tryJson(ev.context);
|
||||
return ctx && Array.isArray(ctx.entity_details) && ctx.entity_details.length > 0;
|
||||
}
|
||||
|
||||
function showEntityDetails(ev) {
|
||||
selectedEvent.value = ev;
|
||||
detailsDialog.value = true;
|
||||
}
|
||||
|
||||
function getEntityDetails(ev) {
|
||||
if (!ev) return [];
|
||||
const ctx = tryJson(ev.context);
|
||||
return ctx?.entity_details || [];
|
||||
}
|
||||
|
||||
function getRawData(ev) {
|
||||
if (!ev) return {};
|
||||
const ctx = tryJson(ev.context);
|
||||
return ctx?.raw_data || {};
|
||||
}
|
||||
|
||||
function isLong(msg) {
|
||||
return msg && String(msg).length > 160;
|
||||
}
|
||||
@@ -138,68 +170,72 @@ function formattedContext(ctx) {
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">Logs</h3>
|
||||
<div class="flex items-center flex-wrap gap-2 text-sm">
|
||||
<label class="text-gray-600">Show</label>
|
||||
<select :value="limit" class="border rounded p-1" @change="onLimit">
|
||||
<option :value="50">50</option>
|
||||
<option :value="100">100</option>
|
||||
<option :value="200">200</option>
|
||||
<option :value="500">500</option>
|
||||
</select>
|
||||
<label class="text-gray-600 ml-2">Level</label>
|
||||
<select v-model="levelFilter" class="border rounded p-1">
|
||||
<option v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
<span class="text-muted-foreground">Show</span>
|
||||
<Select :model-value="limit.toString()" @update:model-value="onLimit">
|
||||
<SelectTrigger class="w-20 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="200">200</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span class="text-muted-foreground ml-2">Level</span>
|
||||
<Select v-model="levelFilter">
|
||||
<SelectTrigger class="w-32 h-8">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="opt in levelOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click.prevent="$emit('refresh')"
|
||||
class="px-2 py-1 border rounded text-sm"
|
||||
:disabled="loading"
|
||||
>
|
||||
{{ loading ? "Refreshing…" : "Refresh" }}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded border">
|
||||
<table class="min-w-full bg-white text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col class="w-40" />
|
||||
<col class="w-20" />
|
||||
<col class="w-40" />
|
||||
<col />
|
||||
<col class="w-16" />
|
||||
</colgroup>
|
||||
<thead class="bg-gray-50 sticky top-0 z-10 shadow">
|
||||
<tr class="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 filteredEvents" :key="ev.id" class="border-t align-top">
|
||||
<td class="p-2 border whitespace-nowrap">
|
||||
<div class="overflow-x-auto max-h-[30rem] overflow-y-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHead class="w-[160px]">Time</TableHead>
|
||||
<TableHead class="w-[80px]">Level</TableHead>
|
||||
<TableHead class="w-[160px]">Event</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead class="w-[64px]">Row</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="ev in filteredEvents" :key="ev.id">
|
||||
<TableCell class="whitespace-nowrap">
|
||||
{{ new Date(ev.created_at).toLocaleString() }}
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<span
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
:variant="ev.level === 'error' ? 'destructive' : ev.level === 'warning' ? 'default' : 'secondary'"
|
||||
: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',
|
||||
'text-xs',
|
||||
ev.level === 'warning' ? 'bg-amber-100 text-amber-800 hover:bg-amber-100' : ''
|
||||
]"
|
||||
>{{ ev.level }}</span
|
||||
>
|
||||
</td>
|
||||
<td class="p-2 border break-words max-w-[9rem]">
|
||||
>{{ ev.level }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell class="max-w-[9rem]">
|
||||
<span class="block truncate" :title="ev.event">{{ ev.event }}</span>
|
||||
</td>
|
||||
<td class="p-2 border align-top max-w-[28rem]">
|
||||
</TableCell>
|
||||
<TableCell class="max-w-[28rem]">
|
||||
<div class="space-y-1 break-words">
|
||||
<div class="leading-snug whitespace-pre-wrap">
|
||||
<span v-if="!isLong(ev.message)">{{ ev.message }}</span>
|
||||
@@ -215,7 +251,15 @@ function formattedContext(ctx) {
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="ev.context" class="text-xs text-gray-600">
|
||||
<div v-if="ev.context" class="text-xs text-gray-600 flex items-center gap-2">
|
||||
<button
|
||||
v-if="hasEntityDetails(ev)"
|
||||
type="button"
|
||||
class="px-2 py-1 rounded border border-indigo-300 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 transition text-[11px] font-medium"
|
||||
@click="showEntityDetails(ev)"
|
||||
>
|
||||
📋 Entity Details
|
||||
</button>
|
||||
<Dropdown
|
||||
align="left"
|
||||
width="wide"
|
||||
@@ -255,14 +299,91 @@ function formattedContext(ctx) {
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-2 border">{{ ev.import_row_id ?? "—" }}</td>
|
||||
</tr>
|
||||
<tr v-if="!filteredEvents.length">
|
||||
<td class="p-3 text-center text-gray-500" colspan="5">No events yet</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</TableCell>
|
||||
<TableCell>{{ ev.import_row_id ?? "—" }}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow v-if="!filteredEvents.length">
|
||||
<TableCell colspan="5" class="text-center text-muted-foreground">No events yet</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<!-- Entity Details Dialog -->
|
||||
<Dialog v-model:open="detailsDialog">
|
||||
<DialogContent class="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Entity Processing Details</DialogTitle>
|
||||
<DialogDescription v-if="selectedEvent">
|
||||
Row {{ tryJson(selectedEvent.context)?.row || '—' }} - {{ selectedEvent.event }}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div v-if="selectedEvent" class="space-y-3 mt-4">
|
||||
<div
|
||||
v-for="(detail, idx) in getEntityDetails(selectedEvent)"
|
||||
:key="idx"
|
||||
class="p-3 rounded-lg border"
|
||||
:class="{
|
||||
'bg-red-50 border-red-200': detail.level === 'error',
|
||||
'bg-amber-50 border-amber-200': detail.level === 'warning',
|
||||
'bg-green-50 border-green-200': detail.level === 'info' && detail.action === 'inserted',
|
||||
'bg-blue-50 border-blue-200': detail.level === 'info' && detail.action === 'updated',
|
||||
'bg-gray-50 border-gray-200': detail.level === 'info' && detail.action === 'skipped'
|
||||
}"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="font-medium text-sm capitalize">{{ detail.entity }}</div>
|
||||
<Badge
|
||||
:variant="detail.level === 'error' ? 'destructive' : detail.level === 'warning' ? 'default' : 'secondary'"
|
||||
:class="[
|
||||
'text-xs',
|
||||
detail.level === 'warning' ? 'bg-amber-100 text-amber-800 hover:bg-amber-100' : '',
|
||||
detail.action === 'inserted' ? 'bg-green-100 text-green-800 hover:bg-green-100' : '',
|
||||
detail.action === 'updated' ? 'bg-blue-100 text-blue-800 hover:bg-blue-100' : '',
|
||||
detail.action === 'skipped' ? 'bg-gray-200 text-gray-700 hover:bg-gray-200' : ''
|
||||
]"
|
||||
>
|
||||
{{ detail.action }}{{ detail.count > 1 ? ` (${detail.count})` : '' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div v-if="detail.message" class="text-sm text-gray-700 mb-1">
|
||||
{{ detail.message }}
|
||||
</div>
|
||||
<div v-if="detail.errors && detail.errors.length" class="mt-2 space-y-1">
|
||||
<div class="text-xs font-medium text-red-700">Errors:</div>
|
||||
<div
|
||||
v-for="(err, errIdx) in detail.errors"
|
||||
:key="errIdx"
|
||||
class="text-xs text-red-600 pl-3"
|
||||
>
|
||||
• {{ err }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="detail.exception" class="mt-2 p-2 bg-red-100 rounded border border-red-200">
|
||||
<div class="text-xs font-semibold text-red-800 mb-1">Exception:</div>
|
||||
<div class="text-xs text-red-700">{{ detail.exception.message }}</div>
|
||||
<div v-if="detail.exception.file" class="text-xs text-red-600 mt-1">
|
||||
{{ detail.exception.file }}:{{ detail.exception.line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getEntityDetails(selectedEvent).length === 0" class="text-center text-muted-foreground py-4">
|
||||
No entity details available
|
||||
</div>
|
||||
|
||||
<!-- Raw Row Data Accordion -->
|
||||
<Accordion type="single" collapsible class="mt-4 border-t pt-4">
|
||||
<AccordionItem value="raw-data" class="border-b-0">
|
||||
<AccordionTrigger class="text-sm font-medium hover:no-underline py-2">
|
||||
📄 Raw Row Data (JSON)
|
||||
</AccordionTrigger>
|
||||
<AccordionContent>
|
||||
<pre class="text-xs bg-gray-900 text-gray-100 p-3 rounded overflow-x-auto mt-2">{{ JSON.stringify(getRawData(selectedEvent), null, 2) }}</pre>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<script setup>
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/Components/ui/select';
|
||||
import { Checkbox } from '@/Components/ui/checkbox';
|
||||
import { Input } from '@/Components/ui/input';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
import { ScrollArea } from '@/Components/ui/scroll-area';
|
||||
|
||||
const props = defineProps({
|
||||
rows: Array,
|
||||
entityOptions: Array,
|
||||
@@ -17,97 +24,145 @@ const emits = defineEmits(['update:rows','save'])
|
||||
|
||||
function duplicateTarget(row){
|
||||
if(!row || !row.entity || !row.field) return false
|
||||
// parent already marks duplicates in duplicateTargets set keyed as record.field
|
||||
return props.duplicateTargets?.has?.(row.entity + '.' + row.field) || false
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="show && rows?.length" class="pt-4">
|
||||
<h3 class="font-semibold mb-2">
|
||||
Detected Columns ({{ detected?.has_header ? 'header' : 'positional' }})
|
||||
<span class="ml-2 text-xs text-gray-500">detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}</span>
|
||||
</h3>
|
||||
<p v-if="detectedNote" class="text-xs text-gray-500 mb-2">{{ detectedNote }}</p>
|
||||
<div class="relative border rounded overflow-auto max-h-[420px]">
|
||||
<table class="min-w-full bg-white">
|
||||
<thead class="sticky top-0 z-10">
|
||||
<tr class="bg-gray-50/95 backdrop-blur 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">Meta key</th>
|
||||
<th class="p-2 border">Meta type</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 rows" :key="idx" class="border-t" :class="duplicateTarget(row) ? 'bg-red-50' : ''">
|
||||
<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', duplicateTarget(row) ? 'border-red-500 bg-red-50' : '']" :disabled="isCompleted">
|
||||
<option value="">—</option>
|
||||
<option v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<input
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="font-semibold">
|
||||
Detected Columns
|
||||
<Badge variant="outline" class="ml-2 text-[10px]">{{ detected?.has_header ? 'header' : 'positional' }}</Badge>
|
||||
</h3>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
detected: {{ detected?.columns?.length || 0 }}, rows: {{ rows.length }}, delimiter: {{ detected?.delimiter || 'auto' }}
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="detectedNote" class="text-xs text-muted-foreground mb-2">{{ detectedNote }}</p>
|
||||
<div class="relative border rounded-lg">
|
||||
<ScrollArea class="h-[420px]">
|
||||
<Table>
|
||||
<TableHeader class="sticky top-0 z-10 bg-background">
|
||||
<TableRow class="hover:bg-transparent">
|
||||
<TableHead class="w-[180px] bg-muted/95 backdrop-blur">Source column</TableHead>
|
||||
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Entity</TableHead>
|
||||
<TableHead class="w-[150px] bg-muted/95 backdrop-blur">Field</TableHead>
|
||||
<TableHead class="w-[140px] bg-muted/95 backdrop-blur">Meta key</TableHead>
|
||||
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Meta type</TableHead>
|
||||
<TableHead class="w-[120px] bg-muted/95 backdrop-blur">Transform</TableHead>
|
||||
<TableHead class="w-[130px] bg-muted/95 backdrop-blur">Apply mode</TableHead>
|
||||
<TableHead class="w-[60px] text-center bg-muted/95 backdrop-blur">Skip</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="(row, idx) in rows" :key="idx" :class="duplicateTarget(row) ? 'bg-destructive/10' : ''">
|
||||
<TableCell class="font-medium">{{ row.source_column }}</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.entity || ''" @update:model-value="(val) => row.entity = val || ''" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue placeholder="Select entity..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="opt in entityOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
:model-value="row.field || ''"
|
||||
@update:model-value="(val) => row.field = val || ''"
|
||||
:disabled="isCompleted"
|
||||
:class="duplicateTarget(row) ? 'border-destructive' : ''"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs" :class="duplicateTarget(row) ? 'border-destructive bg-destructive/10' : ''">
|
||||
<SelectValue placeholder="Select field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="f in fieldsForEntity(row.entity)" :key="f" :value="f">{{ f }}</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
v-if="row.field === 'meta'"
|
||||
v-model="(row.options ||= {}).key"
|
||||
type="text"
|
||||
class="border rounded p-1 w-full"
|
||||
class="h-8 text-xs"
|
||||
placeholder="e.g. monthly_rent"
|
||||
:disabled="isCompleted"
|
||||
/>
|
||||
<span v-else class="text-gray-400 text-xs">—</span>
|
||||
</td>
|
||||
<td class="p-2 border">
|
||||
<select
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
v-if="row.field === 'meta'"
|
||||
v-model="(row.options ||= {}).type"
|
||||
class="border rounded p-1 w-full"
|
||||
:model-value="(row.options ||= {}).type || 'string'"
|
||||
@update:model-value="(val) => (row.options ||= {}).type = val"
|
||||
:disabled="isCompleted"
|
||||
>
|
||||
<option :value="null">Default (string)</option>
|
||||
<option value="string">string</option>
|
||||
<option value="number">number</option>
|
||||
<option value="date">date</option>
|
||||
<option value="boolean">boolean</option>
|
||||
</select>
|
||||
<span v-else class="text-gray-400 text-xs">—</span>
|
||||
</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="keyref">Keyref</option>
|
||||
<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>
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="string">string</SelectItem>
|
||||
<SelectItem value="number">number</SelectItem>
|
||||
<SelectItem value="date">date</SelectItem>
|
||||
<SelectItem value="boolean">boolean</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span v-else class="text-muted-foreground text-xs">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.transform || 'none'" @update:model-value="(val) => row.transform = val === 'none' ? '' : val" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="trim">Trim</SelectItem>
|
||||
<SelectItem value="upper">Uppercase</SelectItem>
|
||||
<SelectItem value="lower">Lowercase</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select :model-value="row.apply_mode || 'both'" @update:model-value="(val) => row.apply_mode = val" :disabled="isCompleted">
|
||||
<SelectTrigger class="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="keyref">Keyref</SelectItem>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
<SelectItem value="insert">Insert only</SelectItem>
|
||||
<SelectItem value="update">Update only</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell class="text-center">
|
||||
<Checkbox :checked="row.skip" @update:checked="(val) => row.skip = val" :disabled="isCompleted" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
<div v-if="mappingSaved" class="text-sm text-emerald-700 mt-2 flex items-center gap-2">
|
||||
<Badge variant="default" class="bg-emerald-600">Saved</Badge>
|
||||
<span>{{ mappingSavedCount }} mappings saved</span>
|
||||
</div>
|
||||
<div v-else-if="mappingError" class="text-sm text-destructive mt-2">{{ mappingError }}</div>
|
||||
<div v-if="missingCritical?.length" class="mt-2">
|
||||
<Badge variant="destructive" class="text-xs">Missing critical: {{ missingCritical.join(', ') }}</Badge>
|
||||
</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 v-if="missingCritical?.length" class="text-xs text-amber-600 mt-1">Missing critical: {{ missingCritical.join(', ') }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { Skeleton } from "@/Components/ui/skeleton";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
contracts: { type: Array, default: () => [] },
|
||||
formatMoney: { type: Function, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
|
||||
<DialogContent class="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manjkajoče pogodbe (aktivne, ne-arhivirane)</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="loading" class="space-y-3 p-4">
|
||||
<Skeleton v-for="i in 5" :key="i" class="h-16 w-full" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!contracts.length" class="py-12 text-center">
|
||||
<p class="text-sm text-gray-500">Ni zadetkov.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y">
|
||||
<div
|
||||
v-for="row in contracts"
|
||||
:key="row.uuid"
|
||||
class="p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<code class="text-sm font-medium text-gray-900">{{
|
||||
row.reference
|
||||
}}</code>
|
||||
<Badge variant="secondary" class="text-[10px]">Aktivna</Badge>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 space-y-0.5">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium">Primer:</span>
|
||||
<span class="truncate">{{ row.full_name || "—" }}</span>
|
||||
</div>
|
||||
<div v-if="row.balance_amount != null" class="flex items-center gap-2">
|
||||
<span class="font-medium">Stanje:</span>
|
||||
<span class="font-mono">{{ formatMoney(row.balance_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
as="a"
|
||||
:href="route('clientCase.show', { client_case: row.case_uuid })"
|
||||
class="shrink-0"
|
||||
>
|
||||
Odpri primer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 flex justify-end">
|
||||
<Button variant="secondary" @click="emit('close')">Zapri</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,9 +1,14 @@
|
||||
<script setup>
|
||||
const props = defineProps({ result: [String, Object] })
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({ result: [String, Object] });
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="result" 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">{{ result }}</pre>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<h3 class="font-semibold">Import Result</h3>
|
||||
<Badge variant="default" class="bg-emerald-600">Complete</Badge>
|
||||
</div>
|
||||
<pre class="bg-muted border rounded-lg p-4 text-sm overflow-x-auto">{{ result }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,44 +1,53 @@
|
||||
<script setup>
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/Components/ui/table';
|
||||
import { Badge } from '@/Components/ui/badge';
|
||||
|
||||
const props = defineProps({ mappings: Array });
|
||||
</script>
|
||||
<template>
|
||||
<div v-if="mappings?.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>
|
||||
<th class="p-2 border">Options</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
<div class="overflow-x-auto rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Source column</TableHead>
|
||||
<TableHead>Target field</TableHead>
|
||||
<TableHead>Transform</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
<TableHead>Options</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow
|
||||
v-for="m in mappings"
|
||||
:key="m.id || m.source_column + m.target_field"
|
||||
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>
|
||||
<td class="p-2 border">
|
||||
<TableCell class="font-medium">{{ m.source_column }}</TableCell>
|
||||
<TableCell>{{ m.target_field }}</TableCell>
|
||||
<TableCell>
|
||||
<Badge v-if="m.transform" variant="outline" class="text-xs">{{ m.transform }}</Badge>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" class="text-xs">{{ m.apply_mode || "both" }}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<template v-if="m.options">
|
||||
<span v-if="m.options.key" class="inline-block mr-2"
|
||||
>key: <strong>{{ m.options.key }}</strong></span
|
||||
>
|
||||
<span v-if="m.options.type" class="inline-block"
|
||||
>type: <strong>{{ m.options.type }}</strong></span
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Badge v-if="m.options.key" variant="outline" class="text-[10px]">
|
||||
key: {{ m.options.key }}
|
||||
</Badge>
|
||||
<Badge v-if="m.options.type" variant="outline" class="text-[10px]">
|
||||
type: {{ m.options.type }}
|
||||
</Badge>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<span v-else class="text-muted-foreground">—</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
<script setup>
|
||||
import Multiselect from "vue-multiselect";
|
||||
import { computed } from "vue";
|
||||
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/Components/ui/select";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
isCompleted: Boolean,
|
||||
@@ -19,11 +23,11 @@ const emits = defineEmits([
|
||||
"preview",
|
||||
]);
|
||||
|
||||
function onHeaderChange(e) {
|
||||
emits("update:hasHeader", e.target.value === "true");
|
||||
function onHeaderChange(val) {
|
||||
emits("update:hasHeader", val === "true");
|
||||
}
|
||||
function onDelimiterMode(e) {
|
||||
emits("update:delimiterMode", e.target.value);
|
||||
function onDelimiterMode(val) {
|
||||
emits("update:delimiterMode", val);
|
||||
}
|
||||
function onDelimiterCustom(e) {
|
||||
emits("update:delimiterCustom", e.target.value);
|
||||
@@ -44,116 +48,119 @@ const selectedTemplateProxy = computed({
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-gray-700">Template</label>
|
||||
<Multiselect
|
||||
v-model="selectedTemplateProxy"
|
||||
:options="filteredTemplates"
|
||||
track-by="id"
|
||||
label="name"
|
||||
placeholder="Izberi predlogo..."
|
||||
:searchable="true"
|
||||
:allow-empty="true"
|
||||
class="mt-1"
|
||||
:custom-label="(o) => o.name"
|
||||
:disabled="filteredTemplates?.length === 0"
|
||||
:show-no-results="true"
|
||||
:clear-on-select="false"
|
||||
<Label class="text-sm font-medium">Template</Label>
|
||||
<Select
|
||||
:model-value="selectedTemplateProxy?.id?.toString()"
|
||||
@update:model-value="(val) => {
|
||||
const tpl = filteredTemplates.find(t => t.id.toString() === val);
|
||||
selectedTemplateProxy = tpl || null;
|
||||
}"
|
||||
>
|
||||
<template #option="{ option }">
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span v-if="option.source_type" class="ml-2 text-xs text-gray-500"
|
||||
>({{ option.source_type }})</span
|
||||
>
|
||||
<SelectTrigger class="mt-1">
|
||||
<SelectValue placeholder="Izberi predlogo...">
|
||||
<div v-if="selectedTemplateProxy" class="flex items-center gap-2">
|
||||
<span>{{ selectedTemplateProxy.name }}</span>
|
||||
<span v-if="selectedTemplateProxy.source_type" class="text-xs text-muted-foreground">({{ selectedTemplateProxy.source_type }})</span>
|
||||
<Badge variant="outline" class="text-[10px]">{{ selectedTemplateProxy.client_id ? 'Client' : 'Global' }}</Badge>
|
||||
</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 v-if="option.source_type" 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>
|
||||
<template #noResult>
|
||||
<div class="px-2 py-1 text-xs text-gray-500">Ni predlog.</div>
|
||||
</template>
|
||||
</Multiselect>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem v-for="option in filteredTemplates" :key="option.id" :value="option.id.toString()">
|
||||
<div class="flex items-center justify-between w-full gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<span>{{ option.name }}</span>
|
||||
<span v-if="option.source_type" class="text-xs text-muted-foreground">({{ option.source_type }})</span>
|
||||
</div>
|
||||
<Badge variant="outline" class="text-[10px]">{{
|
||||
option.client_id ? "Client" : "Global"
|
||||
}}</Badge>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div v-if="isCompleted" class="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
class="w-full sm:w-auto"
|
||||
@click="$emit('preview')"
|
||||
class="px-3 py-1.5 bg-indigo-600 text-white rounded text-sm hover:bg-indigo-500 w-full sm:w-auto"
|
||||
>
|
||||
Ogled CSV
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!isCompleted" class="flex flex-col gap-3">
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600">Header row</label>
|
||||
<select
|
||||
:value="hasHeader"
|
||||
@change="onHeaderChange"
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
<Label class="text-xs font-medium">Header row</Label>
|
||||
<Select
|
||||
:model-value="hasHeader.toString()"
|
||||
@update:model-value="onHeaderChange"
|
||||
>
|
||||
<option value="true">Has header</option>
|
||||
<option value="false">No header (positional)</option>
|
||||
</select>
|
||||
<SelectTrigger class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="true">Has header</SelectItem>
|
||||
<SelectItem value="false">No header (positional)</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<label class="block text-xs font-medium text-gray-600">Delimiter</label>
|
||||
<select
|
||||
:value="delimiterState.mode"
|
||||
@change="onDelimiterMode"
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
<Label class="text-xs font-medium">Delimiter</Label>
|
||||
<Select
|
||||
:model-value="delimiterState.mode"
|
||||
@update:model-value="onDelimiterMode"
|
||||
>
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value="comma">Comma ,</option>
|
||||
<option value="semicolon">Semicolon ;</option>
|
||||
<option value="tab">Tab \t</option>
|
||||
<option value="pipe">Pipe |</option>
|
||||
<option value="space">Space ␠</option>
|
||||
<option value="custom">Custom…</option>
|
||||
</select>
|
||||
<SelectTrigger class="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem value="auto">Auto-detect</SelectItem>
|
||||
<SelectItem value="comma">Comma ,</SelectItem>
|
||||
<SelectItem value="semicolon">Semicolon ;</SelectItem>
|
||||
<SelectItem value="tab">Tab \t</SelectItem>
|
||||
<SelectItem value="pipe">Pipe |</SelectItem>
|
||||
<SelectItem value="space">Space ␠</SelectItem>
|
||||
<SelectItem value="custom">Custom…</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="delimiterState.mode === 'custom'" class="flex items-end gap-3">
|
||||
<div class="w-40">
|
||||
<label class="block text-xs font-medium text-gray-600">Custom delimiter</label>
|
||||
<input
|
||||
:value="delimiterState.custom"
|
||||
<Label class="text-xs font-medium">Custom delimiter</Label>
|
||||
<Input
|
||||
:model-value="delimiterState.custom"
|
||||
@input="onDelimiterCustom"
|
||||
maxlength="4"
|
||||
placeholder=","
|
||||
class="mt-1 block w-full border rounded p-2 text-sm"
|
||||
class="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-else class="text-xs text-gray-500">
|
||||
<p v-else class="text-xs text-muted-foreground">
|
||||
Template default: {{ selectedTemplateOption?.meta?.delimiter || "auto" }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="!isCompleted"
|
||||
class="px-3 py-1.5 bg-emerald-600 text-white rounded text-sm"
|
||||
:disabled="!form.import_template_id"
|
||||
<Button
|
||||
v-if="!isCompleted && form.import_template_id"
|
||||
variant="default"
|
||||
@click="$emit('apply-template')"
|
||||
class="w-full"
|
||||
>
|
||||
{{ templateApplied ? "Ponovno uporabi predlogo" : "Uporabi predlogo" }}
|
||||
</button>
|
||||
{{ templateApplied ? 'Re-apply Template' : 'Apply Template' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
<script setup>
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/Components/ui/dialog";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/Components/ui/table";
|
||||
import { Skeleton } from "@/Components/ui/skeleton";
|
||||
import { ArrowDownTrayIcon } from "@heroicons/vue/24/outline";
|
||||
|
||||
const props = defineProps({
|
||||
show: { type: Boolean, default: false },
|
||||
loading: { type: Boolean, default: false },
|
||||
columns: { type: Array, default: () => [] },
|
||||
rows: { type: Array, default: () => [] },
|
||||
importId: { type: Number, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
function downloadCsv() {
|
||||
if (!props.importId) return;
|
||||
window.location.href = route("imports.missing-keyref-csv", { import: props.importId });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :open="show" @update:open="(val) => !val && emit('close')">
|
||||
<DialogContent class="max-w-6xl max-h-[85vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<DialogTitle>Vrstice z neobstoječim contract.reference (KEYREF)</DialogTitle>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@click="downloadCsv"
|
||||
class="gap-2"
|
||||
>
|
||||
<ArrowDownTrayIcon class="h-4 w-4" />
|
||||
Prenesi CSV
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div class="flex-1 overflow-auto">
|
||||
<div v-if="loading" class="space-y-3 p-4">
|
||||
<Skeleton v-for="i in 10" :key="i" class="h-12 w-full" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="!rows.length" class="py-12 text-center">
|
||||
<p class="text-sm text-gray-500">Ni zadetkov.</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-24"># vrstica</TableHead>
|
||||
<TableHead v-for="(c, i) in columns" :key="i">{{ c }}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow v-for="r in rows" :key="r.id">
|
||||
<TableCell class="font-medium text-gray-500">{{ r.row_number }}</TableCell>
|
||||
<TableCell
|
||||
v-for="(c, i) in columns"
|
||||
:key="i"
|
||||
class="whitespace-pre-wrap wrap-break-word"
|
||||
>
|
||||
{{ r.values?.[i] ?? "" }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t pt-4 flex justify-end gap-2">
|
||||
<Button variant="secondary" @click="emit('close')">Zapri</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -331,7 +331,12 @@ watch(
|
||||
/>
|
||||
|
||||
<!-- Import Mode Settings -->
|
||||
<ImportModeSettings :form="form" :entities="entities" />
|
||||
<ImportModeSettings
|
||||
:form="form"
|
||||
:entities="entities"
|
||||
:actions="props.actions"
|
||||
:decisions="props.decisions"
|
||||
/>
|
||||
|
||||
<!-- Unassigned Mappings -->
|
||||
<UnassignedMappings
|
||||
|
||||
@@ -160,7 +160,7 @@ function 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="grid grid-cols-1 sm:grid-cols-6 gap-2 items-center">
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">Izvor</Label>
|
||||
<Input v-model="m.source_column" class="text-sm" />
|
||||
@@ -196,6 +196,18 @@ function getEntityMappings(entity) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<Label class="text-xs">Group</Label>
|
||||
<Input
|
||||
:value="m.options?.group ?? ''"
|
||||
@input="e => {
|
||||
if (!m.options) m.options = {};
|
||||
m.options.group = e.target.value || null;
|
||||
}"
|
||||
class="text-sm"
|
||||
placeholder="1, 2, ..."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Button
|
||||
@@ -233,7 +245,7 @@ function getEntityMappings(entity) {
|
||||
<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="grid grid-cols-1 sm:grid-cols-5 gap-3">
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Izvorno polje</Label>
|
||||
<Input
|
||||
@@ -289,6 +301,13 @@ function getEntityMappings(entity) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label class="text-xs">Group</Label>
|
||||
<Input
|
||||
v-model="(newRows[entity] ||= {}).group"
|
||||
placeholder="1, 2, ..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button @click="addRow(entity)" size="sm">Dodaj preslikavo</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/Components/ui/card";
|
||||
import { Label } from "@/Components/ui/label";
|
||||
import { Checkbox } from "@/Components/ui/checkbox";
|
||||
@@ -8,6 +9,17 @@ import { Badge } from "@/Components/ui/badge";
|
||||
const props = defineProps({
|
||||
form: { type: Object, required: true },
|
||||
entities: { type: Array, default: () => [] },
|
||||
actions: { type: Array, default: () => [] },
|
||||
decisions: { type: Array, default: () => [] },
|
||||
});
|
||||
|
||||
const hasActivities = computed(() => {
|
||||
return Array.isArray(props.entities) && props.entities.includes('activities');
|
||||
});
|
||||
|
||||
const decisionsForActivitiesAction = computed(() => {
|
||||
const act = (props.actions || []).find((a) => a.id === props.form.meta.activity_action_id);
|
||||
return act?.decisions || [];
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -68,6 +80,47 @@ const props = defineProps({
|
||||
<Badge variant="secondary" class="bg-emerald-100 text-emerald-800">Plačila</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activities Settings -->
|
||||
<div v-if="hasActivities" class="space-y-4 pt-4 border-t">
|
||||
<div class="text-sm font-medium">Nastavitve aktivnosti</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<Label for="activity_action">Dejanje za aktivnosti</Label>
|
||||
<Select v-model="form.meta.activity_action_id">
|
||||
<SelectTrigger id="activity_action">
|
||||
<SelectValue placeholder="Izberi dejanje" />
|
||||
</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="activity_decision">Odločitev za aktivnosti</Label>
|
||||
<Select v-model="form.meta.activity_decision_id" :disabled="!form.meta.activity_action_id">
|
||||
<SelectTrigger id="activity_decision">
|
||||
<SelectValue placeholder="Izberi odločitev" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem :value="null">(brez)</SelectItem>
|
||||
<SelectItem v-for="d in decisionsForActivitiesAction" :key="d.id" :value="d.id">
|
||||
{{ d.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p v-if="!form.meta.activity_action_id" class="text-xs text-muted-foreground">
|
||||
Najprej izberi dejanje, nato odločitev.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Te nastavitve se uporabljajo za aktivnosti, ki so uvožene iz CSV (npr. opombe, zgodovinske aktivnosti).
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user