This commit is contained in:
Simon Pocrnjič
2025-12-26 22:39:58 +01:00
parent f8623a6071
commit dea7432deb
55 changed files with 7977 additions and 1983 deletions
+303 -271
View File
@@ -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>