Dashboard final version, TODO: update main sidebar menu
This commit is contained in:
@@ -471,7 +471,7 @@ const availableSegmentsCount = computed(() => {
|
||||
>
|
||||
<!-- Toolbar Actions -->
|
||||
<template #toolbar-actions v-if="edit">
|
||||
<Button variant="outline" @click="onCreate"> Nova </Button>
|
||||
<Button variant="outline" @click="onCreate">Nova pogodba</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@click="onAttachSegment"
|
||||
|
||||
@@ -4,7 +4,7 @@ import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { onBeforeMount, ref, computed } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import ContractDrawer from "./Partials/ContractDrawer.vue";
|
||||
import ContractTable from "./Partials/ContractTable.vue";
|
||||
import ActivityDrawer from "./Partials/ActivityDrawer.vue";
|
||||
@@ -15,15 +15,10 @@ import DocumentUploadDialog from "@/Components/DocumentsTable/DocumentUploadDial
|
||||
import DocumentViewerDialog from "@/Components/DocumentsTable/DocumentViewerDialog.vue";
|
||||
import { classifyDocument } from "@/Services/documents";
|
||||
import { router, useForm, usePage } from "@inertiajs/vue3";
|
||||
import { AngleDownIcon, AngleUpIcon } from "@/Utilities/Icons";
|
||||
import Pagination from "@/Components/Pagination.vue";
|
||||
import DeleteDialog from "@/Components/Dialogs/DeleteDialog.vue";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { DropdownMenu } from "@/Components/ui/dropdown-menu";
|
||||
import DropdownMenuContent from "@/Components/ui/dropdown-menu/DropdownMenuContent.vue";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -246,7 +241,7 @@ const submitAttachSegment = () => {
|
||||
<template>
|
||||
<AppLayout title="Client case">
|
||||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="pt-6">
|
||||
<!-- Client details -->
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<!-- Current segment badge (right aligned, above the card) -->
|
||||
@@ -260,8 +255,8 @@ const submitAttachSegment = () => {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Card class="border-l-4 border-blue-500">
|
||||
<div class="mx-auto max-w-4x1 p-3 flex justify-between items-center">
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<div class="p-3 flex justify-between items-center">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
<a class="hover:text-blue-500" :href="route('client.show', client)">
|
||||
@@ -269,16 +264,14 @@ const submitAttachSegment = () => {
|
||||
</a>
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<Badge variant="secondary" class="bg-blue-500 text-white dark:bg-blue-600">
|
||||
Naročnik
|
||||
</Badge>
|
||||
<Badge class="bg-blue-500 text-white"> Naročnik </Badge>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1" :hidden="clientDetails">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
@@ -316,7 +309,7 @@ const submitAttachSegment = () => {
|
||||
</div>
|
||||
<div class="pt-1">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Card class="border-l-4 border-red-400">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { ref } from "vue";
|
||||
import { Link, router } from "@inertiajs/vue3";
|
||||
import DataTable from "@/Components/DataTable/DataTable.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/Components/ui/select";
|
||||
} from "@/Components/ui/select"; // kept in case elsewhere but segment filter replaced
|
||||
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
|
||||
import PersonInfoGrid from "@/Components/PersonInfo/PersonInfoGrid.vue";
|
||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import DateRangePicker from "@/Components/DateRangePicker.vue";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { ButtonGroup } from "@/Components/ui/button-group";
|
||||
import AppPopover from "@/Components/app/ui/AppPopover.vue";
|
||||
import { Filter, LinkIcon } from "lucide-vue-next";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import InputLabel from "@/Components/InputLabel.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
@@ -25,14 +34,29 @@ const props = defineProps({
|
||||
types: Object,
|
||||
});
|
||||
|
||||
const page = usePage();
|
||||
// Expose as a callable computed: use in templates as hasPerm('permission-slug')
|
||||
const hasPerm = computed(() => (permission) =>
|
||||
hasPermission(page.props.auth?.user, permission)
|
||||
);
|
||||
|
||||
const dateRange = ref({
|
||||
start: props.filters?.from || null,
|
||||
end: props.filters?.to || null,
|
||||
});
|
||||
const search = ref(props.filters?.search || "");
|
||||
const selectedSegment = ref(props.filters?.segment || null);
|
||||
// Multi-segment selection (backwards compatible if legacy single 'segment' present)
|
||||
const selectedSegments = ref(
|
||||
Array.isArray(props.filters?.segments)
|
||||
? props.filters.segments.map((s) => String(s))
|
||||
: props.filters?.segment
|
||||
? [String(props.filters.segment)]
|
||||
: []
|
||||
);
|
||||
const filterPopoverOpen = ref(false);
|
||||
|
||||
function applyDateFilter() {
|
||||
filterPopoverOpen.value = false;
|
||||
const params = Object.fromEntries(
|
||||
new URLSearchParams(window.location.search).entries()
|
||||
);
|
||||
@@ -51,32 +75,27 @@ function applyDateFilter() {
|
||||
} else {
|
||||
delete params.search;
|
||||
}
|
||||
if (selectedSegment.value) {
|
||||
params.segment = String(selectedSegment.value);
|
||||
if (selectedSegments.value.length > 0) {
|
||||
// join as comma list for backend; adjust server parsing accordingly
|
||||
params.segments = selectedSegments.value.join(",");
|
||||
} else {
|
||||
delete params.segments;
|
||||
}
|
||||
delete params.page;
|
||||
// remove legacy single segment param if present
|
||||
delete params.segment;
|
||||
router.get(route("client.contracts", { uuid: props.client.uuid }), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
only: ['contracts'],
|
||||
only: ["contracts"],
|
||||
});
|
||||
}
|
||||
|
||||
function clearDateFilter() {
|
||||
dateRange.value = { start: null, end: null };
|
||||
selectedSegment.value = null;
|
||||
applyDateFilter();
|
||||
}
|
||||
|
||||
function handleDateRangeUpdate() {
|
||||
applyDateFilter();
|
||||
}
|
||||
|
||||
function handleSegmentChange(value) {
|
||||
selectedSegment.value = value;
|
||||
selectedSegments.value = [];
|
||||
search.value = "";
|
||||
applyDateFilter();
|
||||
}
|
||||
|
||||
@@ -111,132 +130,184 @@ function formatDate(value) {
|
||||
<AppLayout title="Pogodbe">
|
||||
<template #header></template>
|
||||
<!-- Header card (matches Client/Show header style) -->
|
||||
<div class="pt-12">
|
||||
<div class="pt-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
</div>
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<div class="p-3 flex justify-between items-center">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<Badge class="bg-blue-500 text-white"> Naročnik </Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client details card (separate container) -->
|
||||
<div class="pt-1">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<div class="mx-auto max-w-4x1 px-2">
|
||||
<PersonInfoGrid :types="types" :person="client.person" />
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
:person="client.person"
|
||||
:edit="hasPerm('client-edit')"
|
||||
></PersonInfoGrid>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contracts list card -->
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1 py-3">
|
||||
<div class="mb-4">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
as-child
|
||||
:variant="route().current('client.show') ? 'default' : 'ghost'"
|
||||
>
|
||||
<Link :href="route('client.show', { uuid: client.uuid })">
|
||||
Primeri
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
as-child
|
||||
:variant="route().current('client.contracts') ? 'default' : 'ghost'"
|
||||
>
|
||||
<Link :href="route('client.contracts', { uuid: client.uuid })">
|
||||
Pogodbe
|
||||
</Link>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1">
|
||||
<div class="px-3 py-4 flex flex-row items-center gap-3">
|
||||
<Link
|
||||
:class="
|
||||
cn(
|
||||
'border border-gray-200 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground ',
|
||||
route().current('client.show')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
:href="route('client.show', { uuid: client.uuid })"
|
||||
>
|
||||
Primeri
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:class="
|
||||
cn(
|
||||
'border border-gray-200 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground ',
|
||||
route().current('client.contracts')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
:href="route('client.contracts', { uuid: client.uuid })"
|
||||
>
|
||||
Pogodbe
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
:show-search="true"
|
||||
:show-page-size="true"
|
||||
:show-filters="true"
|
||||
:has-active-filters="!!(dateRange?.start || dateRange?.end || selectedSegment)"
|
||||
:columns="[
|
||||
{ key: 'select', label: '', sortable: false, width: '50px' },
|
||||
{ key: 'reference', label: 'Referenca', sortable: false },
|
||||
{ key: 'customer', label: 'Stranka', sortable: false },
|
||||
{ key: 'start', label: 'Začetek', sortable: false },
|
||||
{ key: 'segment', label: 'Segment', sortable: false },
|
||||
{ key: 'balance', label: 'Stanje', sortable: false, align: 'right' },
|
||||
]"
|
||||
:rows="contracts.data || []"
|
||||
:meta="{ current_page: contracts.current_page, per_page: contracts.per_page, total: contracts.total, last_page: contracts.last_page, from: contracts.from, to: contracts.to, links: contracts.links }"
|
||||
:data="contracts.data || []"
|
||||
:meta="{
|
||||
current_page: contracts.current_page,
|
||||
per_page: contracts.per_page,
|
||||
total: contracts.total,
|
||||
last_page: contracts.last_page,
|
||||
from: contracts.from,
|
||||
to: contracts.to,
|
||||
links: contracts.links,
|
||||
}"
|
||||
route-name="client.contracts"
|
||||
:route-params="{ uuid: client.uuid }"
|
||||
:search="search"
|
||||
row-key="uuid"
|
||||
:only-props="['contracts']"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
page-param-name="contracts_page"
|
||||
per-page-param-name="contracts_per_page"
|
||||
:show-toolbar="true"
|
||||
>
|
||||
<template #toolbar-filters>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Datumska območja</label>
|
||||
<DateRangePicker
|
||||
v-model="dateRange"
|
||||
format="dd.MM.yyyy"
|
||||
@update:model-value="handleDateRangeUpdate"
|
||||
placeholder="Izberi datumska območja"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium">Segment</label>
|
||||
<Select
|
||||
:model-value="selectedSegment"
|
||||
@update:model-value="handleSegmentChange"
|
||||
>
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Vsi segmenti" />
|
||||
</SelectTrigger>
|
||||
<SelectContent class="w-[var(--radix-select-trigger-width)]">
|
||||
<SelectItem :value="null">Vsi segmenti</SelectItem>
|
||||
<SelectItem
|
||||
v-for="segment in segments"
|
||||
:key="segment.id"
|
||||
:value="String(segment.id)"
|
||||
>
|
||||
{{ segment.name }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="flex justify-end pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="!dateRange?.start && !dateRange?.end && !selectedSegment"
|
||||
@click="clearDateFilter"
|
||||
>
|
||||
Počisti
|
||||
<AppPopover
|
||||
v-model:open="filterPopoverOpen"
|
||||
align="start"
|
||||
content-class="w-[400px]"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button variant="outline" size="sm" class="gap-2">
|
||||
<Filter class="h-4 w-4" />
|
||||
Filtri
|
||||
<span
|
||||
v-if="
|
||||
dateRange?.start || dateRange?.end || selectedSegments?.length
|
||||
"
|
||||
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
|
||||
>
|
||||
{{
|
||||
[
|
||||
dateRange?.start || dateRange?.end ? 1 : 0,
|
||||
selectedSegments?.length ? 1 : 0,
|
||||
].reduce((a, b) => a + b, 0)
|
||||
}}
|
||||
</span>
|
||||
</Button>
|
||||
</template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h4 class="font-medium text-sm">Filtri pogodb</h4>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Izberite filtre za prikaz pogodb
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Iskanje</InputLabel>
|
||||
<Input
|
||||
v-model="search"
|
||||
type="text"
|
||||
placeholder="Išči po referenci, stranki..."
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Datumska območja</InputLabel>
|
||||
<DateRangePicker
|
||||
v-model="dateRange"
|
||||
format="dd.MM.yyyy"
|
||||
placeholder="Izberi datumska območja"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<InputLabel>Segmenti</InputLabel>
|
||||
<AppMultiSelect
|
||||
v-model="selectedSegments"
|
||||
:items="
|
||||
segments.map((s) => ({ value: String(s.id), label: s.name }))
|
||||
"
|
||||
placeholder="Vsi segmenti"
|
||||
search-placeholder="Išči segment..."
|
||||
empty-text="Ni segmentov"
|
||||
chip-variant="secondary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="
|
||||
!dateRange?.start &&
|
||||
!dateRange?.end &&
|
||||
selectedSegments.length === 0 &&
|
||||
search === ''
|
||||
"
|
||||
@click="clearDateFilter"
|
||||
>
|
||||
Počisti
|
||||
</Button>
|
||||
<Button type="button" size="sm" @click="applyDateFilter">
|
||||
Uporabi
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppPopover>
|
||||
</template>
|
||||
<template #cell-reference="{ row }">
|
||||
<Link
|
||||
:href="route('clientCase.show', caseShowParams(row))"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.reference }}
|
||||
</Link>
|
||||
|
||||
+139
-127
@@ -5,8 +5,6 @@ import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import CreateDialog from "@/Components/Dialogs/CreateDialog.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faUserGroup } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { Card, CardHeader, CardTitle, CardContent } from "@/Components/ui/card";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
@@ -30,7 +28,8 @@ import { useForm } from "vee-validate";
|
||||
import { toTypedSchema } from "@vee-validate/zod";
|
||||
import * as z from "zod";
|
||||
import ActionMessage from "@/Components/ActionMessage.vue";
|
||||
import { Mail, Plug2Icon } from "lucide-vue-next";
|
||||
import { Mail, Plug2Icon, Plus } from "lucide-vue-next";
|
||||
import { Separator } from "@/Components/ui/separator";
|
||||
|
||||
const props = defineProps({
|
||||
clients: Object,
|
||||
@@ -164,13 +163,13 @@ const fmtCurrency = (v) => {
|
||||
<template>
|
||||
<AppLayout title="Client">
|
||||
<template #header> </template>
|
||||
<div class="py-12">
|
||||
<div class="py-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardHeader class="p-5">
|
||||
<CardTitle>Naročniki</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent class="p-0">
|
||||
<DataTable
|
||||
:columns="[
|
||||
{ key: 'nu', label: 'Št.', sortable: false },
|
||||
@@ -193,11 +192,16 @@ const fmtCurrency = (v) => {
|
||||
:show-toolbar="true"
|
||||
:hoverable="true"
|
||||
row-key="uuid"
|
||||
:striped="true"
|
||||
empty-text="Ni najdenih naročnikov."
|
||||
>
|
||||
<template #toolbar-add>
|
||||
<Button @click="openDrawerCreateClient">
|
||||
<Plug2Icon class="w-4 h-4 mr-2" /> Dodaj
|
||||
<template #toolbar-actions>
|
||||
<Button
|
||||
variant="outline"
|
||||
v-if="hasPerm('client-edit')"
|
||||
@click="openDrawerCreateClient"
|
||||
>
|
||||
<Plus class="w-4 h-4" /> Novi naročnik
|
||||
</Button>
|
||||
</template>
|
||||
<template #cell-nu="{ row }">
|
||||
@@ -206,7 +210,7 @@ const fmtCurrency = (v) => {
|
||||
<template #cell-name="{ row }">
|
||||
<Link
|
||||
:href="route('client.show', { uuid: row.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.person?.full_name || "-" }}
|
||||
</Link>
|
||||
@@ -249,7 +253,7 @@ const fmtCurrency = (v) => {
|
||||
<div class="space-y-4">
|
||||
<FormField v-slot="{ componentField }" name="full_name">
|
||||
<FormItem>
|
||||
<FormLabel>Naziv</FormLabel>
|
||||
<FormLabel>Naziv *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="fullname"
|
||||
@@ -262,129 +266,137 @@ const fmtCurrency = (v) => {
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem>
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="taxnumber"
|
||||
type="text"
|
||||
autocomplete="tax-number"
|
||||
placeholder="Davčna številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="social_security_number">
|
||||
<FormItem>
|
||||
<FormLabel>Matična / Emšo</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="socialSecurityNumber"
|
||||
type="text"
|
||||
autocomplete="social-security-number"
|
||||
placeholder="Matična / Emšo"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="address.address">
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
autocomplete="address"
|
||||
placeholder="Naslov"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="address.country">
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="addressCountry"
|
||||
type="text"
|
||||
autocomplete="address-country"
|
||||
placeholder="Država"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="address.type_id">
|
||||
<FormItem>
|
||||
<FormLabel>Vrsta naslova</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<CardTitle class="full">Osebni podatki</CardTitle>
|
||||
<Separator />
|
||||
<div class="flex flex-row gap-2">
|
||||
<FormField v-slot="{ componentField }" name="tax_number">
|
||||
<FormItem class="flex-1">
|
||||
<FormLabel>Davčna</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi vrsto naslova" />
|
||||
</SelectTrigger>
|
||||
<Input
|
||||
id="taxnumber"
|
||||
type="text"
|
||||
autocomplete="tax-number"
|
||||
placeholder="Davčna številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem :value="1">Stalni</SelectItem>
|
||||
<SelectItem :value="2">Začasni</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="phone.country_code">
|
||||
<FormItem>
|
||||
<FormLabel>Koda države tel.</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormField v-slot="{ componentField }" name="social_security_number">
|
||||
<FormItem class="flex-1">
|
||||
<FormLabel>Matična / Emšo</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi kodo države" />
|
||||
</SelectTrigger>
|
||||
<Input
|
||||
id="socialSecurityNumber"
|
||||
type="text"
|
||||
autocomplete="social-security-number"
|
||||
placeholder="Matična / Emšo"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="00386">+386 (Slovenija)</SelectItem>
|
||||
<SelectItem value="00385">+385 (Hrvaška)</SelectItem>
|
||||
<SelectItem value="0039">+39 (Italija)</SelectItem>
|
||||
<SelectItem value="0036">+36 (Madžarska)</SelectItem>
|
||||
<SelectItem value="0043">+43 (Avstrija)</SelectItem>
|
||||
<SelectItem value="00381">+381 (Srbija)</SelectItem>
|
||||
<SelectItem value="00387">+387 (Bosna in Hercegovina)</SelectItem>
|
||||
<SelectItem value="00382">+382 (Črna gora)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<CardTitle class="full">Naslov</CardTitle>
|
||||
<Separator />
|
||||
<div class="grid sm:grid-cols-2 gap-2">
|
||||
<FormField v-slot="{ componentField }" name="address.address">
|
||||
<FormItem>
|
||||
<FormLabel>Naslov</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="address"
|
||||
type="text"
|
||||
autocomplete="address"
|
||||
placeholder="Naslov"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="phone.nu">
|
||||
<FormItem>
|
||||
<FormLabel>Telefonska št.</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="phoneNu"
|
||||
type="text"
|
||||
autocomplete="phone-nu"
|
||||
placeholder="Telefonska številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<FormField v-slot="{ componentField }" name="address.country">
|
||||
<FormItem>
|
||||
<FormLabel>Država</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="addressCountry"
|
||||
type="text"
|
||||
autocomplete="address-country"
|
||||
placeholder="Država"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ value, handleChange }" name="address.type_id">
|
||||
<FormItem>
|
||||
<FormLabel>Vrsta naslova</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi vrsto naslova" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent align="end" position="popper">
|
||||
<SelectItem :value="1">Stalni</SelectItem>
|
||||
<SelectItem :value="2">Začasni</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<CardTitle class="full">Naslov</CardTitle>
|
||||
<Separator />
|
||||
<div class="flex flex-row gap-2">
|
||||
<FormField v-slot="{ value, handleChange }" name="phone.country_code">
|
||||
<FormItem class="flex-1/3">
|
||||
<FormLabel>Koda države tel.</FormLabel>
|
||||
<Select :model-value="value" @update:model-value="handleChange">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Izberi kodo države" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="00386">+386 (Slovenija)</SelectItem>
|
||||
<SelectItem value="00385">+385 (Hrvaška)</SelectItem>
|
||||
<SelectItem value="0039">+39 (Italija)</SelectItem>
|
||||
<SelectItem value="0036">+36 (Madžarska)</SelectItem>
|
||||
<SelectItem value="0043">+43 (Avstrija)</SelectItem>
|
||||
<SelectItem value="00381">+381 (Srbija)</SelectItem>
|
||||
<SelectItem value="00387">+387 (Bosna in Hercegovina)</SelectItem>
|
||||
<SelectItem value="00382">+382 (Črna gora)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
|
||||
<FormField v-slot="{ componentField }" name="phone.nu">
|
||||
<FormItem class="flex-2/3">
|
||||
<FormLabel>Telefonska št.</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
id="phoneNu"
|
||||
type="text"
|
||||
autocomplete="phone-nu"
|
||||
placeholder="Telefonska številka"
|
||||
v-bind="componentField"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</FormField>
|
||||
</div>
|
||||
<FormField v-slot="{ componentField }" name="description">
|
||||
<FormItem>
|
||||
<FormLabel>Opis</FormLabel>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import { computed, ref } from "vue";
|
||||
import { Link, usePage } from "@inertiajs/vue3";
|
||||
import { Link, router, usePage } from "@inertiajs/vue3";
|
||||
import SectionTitle from "@/Components/SectionTitle.vue";
|
||||
import PersonInfoGrid from "@/Components/PersonInfo/PersonInfoGrid.vue";
|
||||
import FormCreateCase from "./Partials/FormCreateCase.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTable.vue";
|
||||
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
|
||||
import { hasPermission } from "@/Services/permissions";
|
||||
import { Button } from "@/Components/ui/button";
|
||||
import { ButtonGroup } from "@/Components/ui/button-group";
|
||||
import ActionMenuItem from "@/Components/DataTable/ActionMenuItem.vue";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { Card } from "@/Components/ui/card";
|
||||
import Badge from "@/Components/ui/badge/Badge.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/Components/ui/input";
|
||||
|
||||
const props = defineProps({
|
||||
client: Object,
|
||||
@@ -34,72 +37,92 @@ const drawerCreateCase = ref(false);
|
||||
const openDrawerCreateCase = () => {
|
||||
drawerCreateCase.value = true;
|
||||
};
|
||||
|
||||
function applySearch() {
|
||||
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("client.show", { uuid: props.client.uuid }), params, {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
preserveScroll: true,
|
||||
only: ["client_cases"],
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Client">
|
||||
<template #header></template>
|
||||
<div class="pt-12">
|
||||
<div class="pt-6">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
</div>
|
||||
<Card class="border-l-4 border-blue-400">
|
||||
<div class="p-3 flex justify-between items-center">
|
||||
<SectionTitle>
|
||||
<template #title>
|
||||
{{ client.person.full_name }}
|
||||
</template>
|
||||
</SectionTitle>
|
||||
<Badge class="bg-blue-500 text-white"> Naročnik </Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-1">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div
|
||||
class="bg-white overflow-hidden shadow-xl sm:rounded-lg border-l-4 border-blue-400"
|
||||
>
|
||||
<div class="mx-auto max-w-4x1 px-2">
|
||||
<Card>
|
||||
<div class="mx-auto max-w-4x1 p-3">
|
||||
<PersonInfoGrid
|
||||
:types="types"
|
||||
:person="client.person"
|
||||
:edit="hasPerm('client-edit')"
|
||||
></PersonInfoGrid>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="px-3 bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1 py-3">
|
||||
<div class="mb-4">
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
as-child
|
||||
:variant="route().current('client.show') ? 'default' : 'ghost'"
|
||||
>
|
||||
<Link :href="route('client.show', { uuid: client.uuid })">
|
||||
Primeri
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
as-child
|
||||
:variant="route().current('client.contracts') ? 'default' : 'ghost'"
|
||||
>
|
||||
<Link :href="route('client.contracts', { uuid: client.uuid })">
|
||||
Pogodbe
|
||||
</Link>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
<div class="bg-white overflow-hidden shadow-xl sm:rounded-lg">
|
||||
<div class="mx-auto max-w-4x1">
|
||||
<div class="px-3 py-4 flex flex-row items-center gap-3">
|
||||
<Link
|
||||
:class="
|
||||
cn(
|
||||
'border border-gray-200 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground ',
|
||||
route().current('client.show')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
:href="route('client.show', { uuid: client.uuid })"
|
||||
>
|
||||
Primeri
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
:class="
|
||||
cn(
|
||||
'border border-gray-200 py-2 px-3 rounded-md hover:bg-accent hover:text-accent-foreground ',
|
||||
route().current('client.contracts')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: ''
|
||||
)
|
||||
"
|
||||
:href="route('client.contracts', { uuid: client.uuid })"
|
||||
>
|
||||
Pogodbe
|
||||
</Link>
|
||||
</div>
|
||||
<DataTable
|
||||
:show-search="true"
|
||||
:show-page-size="true"
|
||||
:show-add="true"
|
||||
:columns="[
|
||||
{ key: 'nu', label: 'Št.', sortable: false, class: 'w-40' },
|
||||
{ key: 'case', label: 'Primer', sortable: false },
|
||||
@@ -117,7 +140,7 @@ const openDrawerCreateCase = () => {
|
||||
align: 'right',
|
||||
},
|
||||
]"
|
||||
:rows="client_cases.data || []"
|
||||
:data="client_cases.data || []"
|
||||
:meta="{
|
||||
current_page: client_cases.current_page,
|
||||
per_page: client_cases.per_page,
|
||||
@@ -130,16 +153,32 @@ const openDrawerCreateCase = () => {
|
||||
route-name="client.show"
|
||||
:route-params="{ uuid: client.uuid }"
|
||||
row-key="uuid"
|
||||
:search="search"
|
||||
:only-props="['client_cases']"
|
||||
:page-size-options="[10, 15, 25, 50, 100]"
|
||||
:show-toolbar="true"
|
||||
>
|
||||
<template #toolbar-add>
|
||||
<ActionMenuItem
|
||||
<template #toolbar-filters>
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Input
|
||||
v-model="search"
|
||||
placeholder="Išči po primeru, davčni, osebi..."
|
||||
class="w-[260px]"
|
||||
@keydown.enter="applySearch"
|
||||
/>
|
||||
<Button size="sm" variant="outline" @click="applySearch">Išči</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #toolbar-actions>
|
||||
<Button
|
||||
v-if="hasPerm('case-edit')"
|
||||
label="Dodaj primer"
|
||||
:icon="faPlus"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="gap-2"
|
||||
@click="openDrawerCreateCase"
|
||||
/>
|
||||
>
|
||||
<FontAwesomeIcon :icon="faPlus" class="h-4 w-4" />
|
||||
Dodaj primer
|
||||
</Button>
|
||||
</template>
|
||||
<template #cell-nu="{ row }">
|
||||
{{ row.person?.nu || "-" }}
|
||||
@@ -147,7 +186,7 @@ const openDrawerCreateCase = () => {
|
||||
<template #cell-case="{ row }">
|
||||
<Link
|
||||
:href="route('clientCase.show', { client_case: row.uuid })"
|
||||
class="text-indigo-600 hover:underline"
|
||||
class="font-semibold hover:underline text-primary-700"
|
||||
>
|
||||
{{ row.person?.full_name || "-" }}
|
||||
</Link>
|
||||
|
||||
@@ -171,16 +171,14 @@ function safeCaseHref(uuid, segment = null) {
|
||||
>
|
||||
<FontAwesomeIcon :icon="k.icon" class="w-5 h-5" />
|
||||
</span>
|
||||
<span
|
||||
class="text-[11px] text-gray-400 uppercase tracking-wide"
|
||||
>{{ k.label }}</span
|
||||
>
|
||||
<span class="text-[11px] text-gray-400 uppercase tracking-wide">{{
|
||||
k.label
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-end gap-2">
|
||||
<span
|
||||
class="text-2xl font-semibold tracking-tight text-gray-900"
|
||||
>{{ props.kpis?.[k.key] ?? "—" }}</span
|
||||
>
|
||||
<span class="text-2xl font-semibold tracking-tight text-gray-900">{{
|
||||
props.kpis?.[k.key] ?? "—"
|
||||
}}</span>
|
||||
<span
|
||||
class="text-[10px] text-indigo-500 opacity-0 group-hover:opacity-100 transition"
|
||||
>Odpri →</span
|
||||
@@ -250,20 +248,13 @@ function safeCaseHref(uuid, segment = null) {
|
||||
<div class="grid lg:grid-cols-3 gap-8">
|
||||
<!-- Activity Feed -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-5 flex flex-col gap-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-5 flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase"
|
||||
>
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase">
|
||||
Aktivnost
|
||||
</h3>
|
||||
</div>
|
||||
<ul
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
v-if="activities"
|
||||
>
|
||||
<ul class="divide-y divide-gray-100 text-sm" v-if="activities">
|
||||
<li
|
||||
v-for="a in activityItems"
|
||||
:key="a.id"
|
||||
@@ -296,11 +287,7 @@ function safeCaseHref(uuid, segment = null) {
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-else class="animate-pulse space-y-2">
|
||||
<li
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<li v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</ul>
|
||||
<div class="pt-1 flex justify-between items-center text-[11px]">
|
||||
<Link
|
||||
@@ -320,19 +307,13 @@ function safeCaseHref(uuid, segment = null) {
|
||||
<!-- Right side panels -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- SMS Overview -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
SMS stanje
|
||||
</h3>
|
||||
<div v-if="props.smsStats?.length" class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead
|
||||
class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider"
|
||||
>
|
||||
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Profil</th>
|
||||
<th class="px-3 py-2 text-left">Bilanca</th>
|
||||
@@ -349,9 +330,7 @@ function safeCaseHref(uuid, segment = null) {
|
||||
class="border-t last:border-b"
|
||||
>
|
||||
<td class="px-3 py-2">
|
||||
<span class="font-medium text-gray-900">{{
|
||||
p.name
|
||||
}}</span>
|
||||
<span class="font-medium text-gray-900">{{ p.name }}</span>
|
||||
<span
|
||||
class="ml-2 text-[11px]"
|
||||
:class="p.active ? 'text-emerald-600' : 'text-gray-400'"
|
||||
@@ -371,18 +350,12 @@ function safeCaseHref(uuid, segment = null) {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-500">
|
||||
Ni podatkov o SMS.
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-500">Ni podatkov o SMS.</div>
|
||||
</div>
|
||||
|
||||
<!-- System Health -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
System Health
|
||||
</h3>
|
||||
<div
|
||||
@@ -390,17 +363,13 @@ function safeCaseHref(uuid, segment = null) {
|
||||
class="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm"
|
||||
>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-[11px] uppercase text-gray-400"
|
||||
>Queue backlog</span
|
||||
>
|
||||
<span class="text-[11px] uppercase text-gray-400">Queue backlog</span>
|
||||
<span class="font-semibold text-gray-800">{{
|
||||
systemHealth.queue_backlog ?? "—"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-[11px] uppercase text-gray-400"
|
||||
>Failed jobs</span
|
||||
>
|
||||
<span class="text-[11px] uppercase text-gray-400">Failed jobs</span>
|
||||
<span class="font-semibold text-gray-800">{{
|
||||
systemHealth.failed_jobs ?? "—"
|
||||
}}</span>
|
||||
@@ -422,30 +391,20 @@ function safeCaseHref(uuid, segment = null) {
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-[11px] uppercase text-gray-400"
|
||||
>Generated</span
|
||||
>
|
||||
<span class="text-[11px] uppercase text-gray-400">Generated</span>
|
||||
<span class="font-semibold text-gray-800">{{
|
||||
new Date(systemHealth.generated_at).toLocaleTimeString()
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="grid sm:grid-cols-4 gap-4 animate-pulse">
|
||||
<div
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="h-10 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 4" :key="n" class="h-10 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed Field Jobs Trend (7 dni) -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Zaključena terenska dela (7 dni)
|
||||
</h3>
|
||||
<div v-if="trends" class="h-24">
|
||||
@@ -479,18 +438,11 @@ function safeCaseHref(uuid, segment = null) {
|
||||
</div>
|
||||
|
||||
<!-- Stale Cases -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Stari primeri brez aktivnosti
|
||||
</h3>
|
||||
<ul
|
||||
v-if="staleCases"
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
>
|
||||
<ul v-if="staleCases" class="divide-y divide-gray-100 text-sm">
|
||||
<li
|
||||
v-for="c in staleCases"
|
||||
:key="c.id"
|
||||
@@ -511,8 +463,7 @@ function safeCaseHref(uuid, segment = null) {
|
||||
{{ formatStaleDaysLabel(c.days_without_activity ?? c.days_stale) }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="text-[10px] px-2 py-0.5 rounded bg-amber-50 text-amber-600"
|
||||
<span class="text-[10px] px-2 py-0.5 rounded bg-amber-50 text-amber-600"
|
||||
>Stale</span
|
||||
>
|
||||
</li>
|
||||
@@ -524,27 +475,16 @@ function safeCaseHref(uuid, segment = null) {
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="space-y-2 animate-pulse">
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Field Jobs Assigned Today -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Današnje dodelitve terenskih
|
||||
</h3>
|
||||
<ul
|
||||
v-if="fieldJobsAssignedToday"
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
>
|
||||
<ul v-if="fieldJobsAssignedToday" class="divide-y divide-gray-100 text-sm">
|
||||
<li
|
||||
v-for="f in fieldJobsAssignedToday"
|
||||
:key="f.id"
|
||||
@@ -567,10 +507,7 @@ function safeCaseHref(uuid, segment = null) {
|
||||
<span v-else class="text-gray-700">{{
|
||||
f.contract.reference || f.contract.uuid?.slice(0, 8)
|
||||
}}</span>
|
||||
<span
|
||||
v-if="f.contract.person_full_name"
|
||||
class="text-gray-500"
|
||||
>
|
||||
<span v-if="f.contract.person_full_name" class="text-gray-500">
|
||||
– {{ f.contract.person_full_name }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -595,27 +532,16 @@ function safeCaseHref(uuid, segment = null) {
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="space-y-2 animate-pulse">
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imports In Progress -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Uvozi v teku
|
||||
</h3>
|
||||
<ul
|
||||
v-if="importsInProgress"
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
>
|
||||
<ul v-if="importsInProgress" class="divide-y divide-gray-100 text-sm">
|
||||
<li v-for="im in importsInProgress" :key="im.id" class="py-2 space-y-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="font-medium text-gray-700 truncate">
|
||||
@@ -626,9 +552,7 @@ function safeCaseHref(uuid, segment = null) {
|
||||
>{{ im.status }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="w-full h-2 bg-gray-100 rounded overflow-hidden"
|
||||
>
|
||||
<div class="w-full h-2 bg-gray-100 rounded overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-indigo-500"
|
||||
:style="{ width: (im.progress_pct || 0) + '%' }"
|
||||
@@ -647,27 +571,16 @@ function safeCaseHref(uuid, segment = null) {
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="space-y-2 animate-pulse">
|
||||
<div
|
||||
v-for="n in 4"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 4" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Document Templates -->
|
||||
<div
|
||||
class="bg-white border rounded-xl shadow-sm p-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4"
|
||||
>
|
||||
<div class="bg-white border rounded-xl shadow-sm p-6">
|
||||
<h3 class="text-sm font-semibold tracking-wide text-gray-700 uppercase mb-4">
|
||||
Aktivne predloge dokumentov
|
||||
</h3>
|
||||
<ul
|
||||
v-if="activeTemplates"
|
||||
class="divide-y divide-gray-100 text-sm"
|
||||
>
|
||||
<ul v-if="activeTemplates" class="divide-y divide-gray-100 text-sm">
|
||||
<li
|
||||
v-for="t in activeTemplates"
|
||||
:key="t.id"
|
||||
@@ -695,11 +608,7 @@ function safeCaseHref(uuid, segment = null) {
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="space-y-2 animate-pulse">
|
||||
<div
|
||||
v-for="n in 5"
|
||||
:key="n"
|
||||
class="h-5 bg-gray-100 rounded"
|
||||
/>
|
||||
<div v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script setup>
|
||||
import AppLayout from "@/Layouts/AppLayout.vue";
|
||||
import SimpleKpiCard from "./Partials/SimpleKpiCard.vue";
|
||||
import ActivityFeed from "./Partials/ActivityFeed.vue";
|
||||
import SmsOverview from "./Partials/SmsOverview.vue";
|
||||
import CompletedFieldJobsTrend from "./Partials/CompletedFieldJobsTrend.vue";
|
||||
import FieldJobsAssignedToday from "./Partials/FieldJobsAssignedToday.vue";
|
||||
import { Users, FileText, Banknote, CalendarCheck } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
kpis: Object,
|
||||
activities: Array,
|
||||
trends: Object,
|
||||
systemHealth: Object,
|
||||
staleCases: Array,
|
||||
fieldJobsAssignedToday: Array,
|
||||
importsInProgress: Array,
|
||||
activeTemplates: Array,
|
||||
smsStats: Array,
|
||||
});
|
||||
|
||||
// Format balance as currency
|
||||
const formatBalance = (amount) => {
|
||||
if (amount == null) return "—";
|
||||
return new Intl.NumberFormat("sl-SI", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout title="Nadzorna plošča">
|
||||
<template #header> </template>
|
||||
|
||||
<div class="max-w-7xl mx-auto space-y-10 py-6">
|
||||
<!-- KPI Cards Grid -->
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<SimpleKpiCard
|
||||
label="Aktivni stranke"
|
||||
:value="kpis?.active_clients"
|
||||
:icon="Users"
|
||||
/>
|
||||
<SimpleKpiCard
|
||||
label="Aktivne pogodbe"
|
||||
:value="kpis?.active_contracts"
|
||||
:icon="FileText"
|
||||
icon-bg="bg-chart-2/10"
|
||||
icon-color="text-chart-2"
|
||||
/>
|
||||
<SimpleKpiCard
|
||||
label="Skupno stanje"
|
||||
:value="formatBalance(kpis?.total_balance)"
|
||||
:icon="Banknote"
|
||||
icon-bg="bg-chart-3/10"
|
||||
icon-color="text-chart-3"
|
||||
/>
|
||||
<SimpleKpiCard
|
||||
label="Aktivne obljube"
|
||||
:value="kpis?.active_promises"
|
||||
:icon="CalendarCheck"
|
||||
icon-bg="bg-chart-4/10"
|
||||
icon-color="text-chart-4"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 gap-8">
|
||||
<!-- Activity Feed -->
|
||||
<div class="lg:col-span-1 space-y-4">
|
||||
<ActivityFeed :activities="activities" :systemHealth="systemHealth" />
|
||||
<!-- Field Jobs Assigned Today -->
|
||||
<FieldJobsAssignedToday :fieldJobsAssignedToday="fieldJobsAssignedToday" />
|
||||
</div>
|
||||
|
||||
<!-- Right side panels -->
|
||||
<div class="lg:col-span-2 space-y-8">
|
||||
<!-- SMS Overview -->
|
||||
<SmsOverview :smsStats="smsStats" />
|
||||
|
||||
<!-- Completed Field Jobs Trend (7 dni) -->
|
||||
<CompletedFieldJobsTrend :trends="trends" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,108 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from "vue";
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { faArrowUpRightFromSquare } from "@fortawesome/free-solid-svg-icons";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import Badge from "@/Components/ui/badge/Badge.vue";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/Components/ui/item";
|
||||
import { BadgeCheckIcon, ChevronRightIcon, Radar, Radio, RssIcon } from "lucide-vue-next";
|
||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||
|
||||
const props = defineProps({
|
||||
activities: Array,
|
||||
systemHealth: Object,
|
||||
});
|
||||
|
||||
function buildRelated(a) {
|
||||
const links = [];
|
||||
if (a.client_case_uuid || a.client_case_id) {
|
||||
const caseParam = a.client_case_uuid || a.client_case_id;
|
||||
try {
|
||||
const href = String(route("clientCase.show", { client_case: caseParam }));
|
||||
links.push({
|
||||
type: "client_case",
|
||||
label: "Primer",
|
||||
href,
|
||||
});
|
||||
} catch (e) {
|
||||
links.push({
|
||||
type: "client_case",
|
||||
label: "Primer",
|
||||
href: `/client-cases/${caseParam}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return links;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log((props.activities || []).map((a) => ({ ...a, links: buildRelated(a) })));
|
||||
});
|
||||
|
||||
const activityItems = computed(() =>
|
||||
(props.activities || []).map((a) => ({ ...a, links: buildRelated(a) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-2"
|
||||
header-class="py-3! px-4 border-b text-muted-foreground gap-0"
|
||||
body-class="flex flex-col gap-4"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Radio size="20" />
|
||||
<CardTitle class="uppercase"> Aktivnost </CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<ScrollArea class="h-96 w-full">
|
||||
<div class="flex flex-col gap-1 px-1" v-if="activities">
|
||||
<Item v-for="a in activityItems" :key="a.id" variant="outline" size="sm" as-child>
|
||||
<a :href="a.links[0].href ?? ''">
|
||||
<ItemMedia>
|
||||
<span class="w-2 h-2 mt-2 rounded-full bg-primary" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>{{ a.note || "Dogodek" }}</ItemTitle>
|
||||
<ItemDescription>
|
||||
{{ new Date(a.created_at).toLocaleString() }}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
<div v-if="!activities?.length" class="py-4 text-xs text-gray-500 text-center">
|
||||
Ni zabeleženih aktivnosti.
|
||||
</div>
|
||||
</div>
|
||||
<ul v-else class="animate-pulse space-y-2">
|
||||
<li v-for="n in 5" :key="n" class="h-5 bg-gray-100 rounded" />
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
|
||||
<div class="flex justify-between items-center text-[11px] p-2">
|
||||
<Link
|
||||
:href="route('dashboard')"
|
||||
class="inline-flex items-center gap-1 font-medium text-primary hover:underline"
|
||||
>Več kmalu <FontAwesomeIcon :icon="faArrowUpRightFromSquare" class="w-3 h-3"
|
||||
/></Link>
|
||||
<span v-if="systemHealth" class="text-gray-400"
|
||||
>Posodobljeno {{ new Date(systemHealth.generated_at).toLocaleTimeString() }}</span
|
||||
>
|
||||
</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
@@ -0,0 +1,173 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import { VisAxis, VisGroupedBar, VisXYContainer } from "@unovis/vue";
|
||||
import {
|
||||
ChartAutoLegend,
|
||||
ChartContainer,
|
||||
ChartTooltipContent,
|
||||
ChartCrosshair,
|
||||
provideChartContext,
|
||||
componentToString,
|
||||
} from "@/Components/ui/chart";
|
||||
import AppChartDisplay from "@/Components/app/ui/charts/AppChartDisplay.vue";
|
||||
|
||||
const props = defineProps({
|
||||
trends: Object,
|
||||
});
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (
|
||||
!props.trends?.labels ||
|
||||
!props.trends?.field_jobs_completed ||
|
||||
!props.trends?.field_jobs
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return props.trends.labels.map((label, i) => ({
|
||||
date: new Date(label),
|
||||
dateLabel: label,
|
||||
completed: props.trends.field_jobs_completed[i] || 0,
|
||||
assigned: props.trends.field_jobs[i] || 0,
|
||||
}));
|
||||
});
|
||||
|
||||
const chartConfig = {
|
||||
completed: {
|
||||
label: "Zaključeni",
|
||||
color: "var(--chart-1)",
|
||||
},
|
||||
assigned: {
|
||||
label: "Dodeljeni",
|
||||
color: "var(--chart-2)",
|
||||
},
|
||||
};
|
||||
|
||||
// Provide chart context at component root so legend (outside ChartContainer) can access it
|
||||
provideChartContext(chartConfig);
|
||||
|
||||
// (No gradients needed for bar chart)
|
||||
|
||||
// Active series keys controlled by auto legend
|
||||
const activeKeys = ref(["completed", "assigned"]);
|
||||
const activeSeries = computed(() => activeKeys.value);
|
||||
const yAccessors = computed(() => activeSeries.value.map((key) => (d) => d[key]));
|
||||
|
||||
// Prevent all series from being disabled (Unovis crosshair needs at least one component with x accessor)
|
||||
let _lastNonEmpty = [...activeKeys.value];
|
||||
watch(activeKeys, (val, oldVal) => {
|
||||
if (val.length === 0) {
|
||||
// revert to previous non-empty selection
|
||||
activeKeys.value = _lastNonEmpty.length ? _lastNonEmpty : oldVal;
|
||||
} else {
|
||||
_lastNonEmpty = [...val];
|
||||
}
|
||||
});
|
||||
|
||||
// Crosshair template using componentToString to render advanced tooltip component
|
||||
const crosshairTemplate = componentToString(chartConfig, ChartTooltipContent, {
|
||||
labelKey: "dateLabel",
|
||||
labelFormatter: (x) => crosshairLabelFormatter(x),
|
||||
});
|
||||
|
||||
const totalCompleted = computed(() => {
|
||||
return chartData.value.reduce((sum, item) => sum + item.completed, 0);
|
||||
});
|
||||
|
||||
const totalAssigned = computed(() => {
|
||||
return chartData.value.reduce((sum, item) => sum + item.assigned, 0);
|
||||
});
|
||||
|
||||
// Formatter for tooltip title (date) and potential item labels
|
||||
const crosshairLabelFormatter = (value) => {
|
||||
// Handle Date objects or parsable date strings
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (isNaN(date)) return value?.toString?.() ?? "";
|
||||
return date.toLocaleDateString("sl-SI", { month: "long", day: "numeric" });
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppChartDisplay name="ChartLine" class="md:col-span-2 lg:col-span-3">
|
||||
<Card class="p-0">
|
||||
<CardHeader class="flex flex-col items-stretch border-b p-0! sm:flex-row">
|
||||
<div class="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
|
||||
<CardTitle>Terenska dela - Pregled</CardTitle>
|
||||
<CardDescription>Zadnjih 7 dni</CardDescription>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<div
|
||||
class="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">Zaključeni</span>
|
||||
<span class="text-lg font-bold leading-none sm:text-3xl">
|
||||
{{ totalCompleted.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
|
||||
>
|
||||
<span class="text-xs text-muted-foreground">Dodeljeni</span>
|
||||
<span class="text-lg font-bold leading-none sm:text-3xl">
|
||||
{{ totalAssigned.toLocaleString() }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="px-2 sm:px-6 sm:pt-6 pb-4">
|
||||
<div v-if="chartData.length" class="w-full aspect-auto h-[250px]">
|
||||
<ChartContainer :config="chartConfig" class="h-full">
|
||||
<VisXYContainer
|
||||
:data="chartData"
|
||||
:height="250"
|
||||
:margin="{ left: 5, right: 5 }"
|
||||
>
|
||||
<VisGroupedBar
|
||||
:x="(d) => d.date"
|
||||
:y="yAccessors"
|
||||
:color="(d, i) => chartConfig[activeSeries[i]].color"
|
||||
:bar-padding="0.3"
|
||||
/>
|
||||
|
||||
<VisAxis
|
||||
type="x"
|
||||
:tick-line="false"
|
||||
:grid-line="false"
|
||||
:num-ticks="7"
|
||||
:tick-format="
|
||||
(d) => {
|
||||
const date = new Date(d);
|
||||
return date.toLocaleDateString('sl-SI', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
"
|
||||
/>
|
||||
<VisAxis type="y" :num-ticks="4" :tick-line="false" :grid-line="true" />
|
||||
<ChartCrosshair
|
||||
:index="'date'"
|
||||
:template="crosshairTemplate"
|
||||
:colors="[chartConfig.completed.color, chartConfig.assigned.color]"
|
||||
/>
|
||||
</VisXYContainer>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
<div v-else class="h-[250px] animate-pulse bg-gray-100 rounded" />
|
||||
</CardContent>
|
||||
<div class="border-t px-6 py-2 flex justify-center">
|
||||
<ChartAutoLegend
|
||||
v-model:activeKeys="activeKeys"
|
||||
:order="['completed', 'assigned']"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</AppChartDisplay>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import { Link } from "@inertiajs/vue3";
|
||||
import { computed } from "vue";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/Components/ui/card";
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import { ChevronRightIcon, MapIcon, UserRound } from "lucide-vue-next";
|
||||
import { ScrollArea } from "@/Components/ui/scroll-area";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemMedia,
|
||||
ItemTitle,
|
||||
} from "@/Components/ui/item";
|
||||
import { Badge } from "@/Components/ui/badge";
|
||||
|
||||
const props = defineProps({
|
||||
fieldJobsAssignedToday: Array,
|
||||
});
|
||||
|
||||
// Robust time formatter to avoid fixed 02:00:00 (timezone / fallback issues)
|
||||
function formatJobTime(ts) {
|
||||
if (!ts) return "";
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const pad = (n) => n.toString().padStart(2, "0");
|
||||
const h = pad(d.getHours());
|
||||
const m = pad(d.getMinutes());
|
||||
const s = d.getSeconds();
|
||||
return s ? `${h}:${m}:${pad(s)}` : `${h}:${m}`;
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// Safely build a client case href using Ziggy when available, with a plain fallback.
|
||||
function safeCaseHref(uuid, segment = null) {
|
||||
if (!uuid) {
|
||||
return "#";
|
||||
}
|
||||
try {
|
||||
const params = { client_case: uuid };
|
||||
if (segment != null) {
|
||||
params.segment = segment;
|
||||
}
|
||||
return String(route("clientCase.show", params));
|
||||
} catch (e) {
|
||||
return segment != null
|
||||
? `/client-cases/${uuid}?segment=${segment}`
|
||||
: `/client-cases/${uuid}`;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-2"
|
||||
header-class="py-3! px-4 border-b gap-0 text-muted-foreground"
|
||||
body-class="flex flex-col gap-4"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<MapIcon size="18" />
|
||||
<CardTitle class="text-muted-foreground uppercase"> Današnji teren </CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
<ScrollArea
|
||||
class="h-96 w-full"
|
||||
v-if="fieldJobsAssignedToday && fieldJobsAssignedToday.length > 0"
|
||||
>
|
||||
<div class="flex flex-col gap-1 px-1">
|
||||
<Item
|
||||
v-for="f in fieldJobsAssignedToday"
|
||||
:key="f.id"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
as-child
|
||||
>
|
||||
<a :href="safeCaseHref(f.contract.client_case_uuid, f.contract.segment_id)">
|
||||
<ItemMedia>
|
||||
<span class="w-2 h-2 mt-2 rounded-full bg-primary" />
|
||||
</ItemMedia>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<span>{{ f.contract.person_full_name }}</span>
|
||||
</ItemTitle>
|
||||
<ItemDescription class="flex gap-1">
|
||||
<Badge>{{ f.contract.reference }}</Badge>
|
||||
<Badge variant="outline">{{ formatJobTime(f.created_at) }}</Badge>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ChevronRightIcon class="size-4" />
|
||||
</ItemActions>
|
||||
</a>
|
||||
</Item>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div
|
||||
v-if="!fieldJobsAssignedToday?.length"
|
||||
class="py-4 text-xs text-gray-500 text-center"
|
||||
>
|
||||
Ni zabeleženih primerov.
|
||||
</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { Card, CardContent } from "@/Components/ui/card";
|
||||
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
value: [String, Number],
|
||||
icon: Object,
|
||||
iconBg: {
|
||||
type: String,
|
||||
default: "bg-primary/10",
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: "text-primary",
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="hover:border-primary/30 hover:shadow transition">
|
||||
<CardContent class="px-4 py-5 flex items-center gap-4">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center justify-center h-10 w-10 rounded-md transition',
|
||||
iconBg,
|
||||
iconColor,
|
||||
]"
|
||||
>
|
||||
<component :is="icon" class="w-5 h-5" />
|
||||
</span>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs text-muted-foreground uppercase tracking-wide truncate">
|
||||
{{ label }}
|
||||
</p>
|
||||
<p
|
||||
v-if="!loading"
|
||||
class="text-2xl font-semibold tracking-tight text-foreground mt-1"
|
||||
>
|
||||
{{ value ?? "—" }}
|
||||
</p>
|
||||
<div v-else class="h-8 w-20 bg-muted animate-pulse rounded mt-1" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup>
|
||||
import AppCard from "@/Components/app/ui/card/AppCard.vue";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/Components/ui/card";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
ItemTitle,
|
||||
} from "@/Components/ui/item";
|
||||
import {
|
||||
MessageSquare,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
MessageCircle,
|
||||
Smartphone,
|
||||
} from "lucide-vue-next";
|
||||
|
||||
const props = defineProps({
|
||||
smsStats: { type: Array, required: false },
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard
|
||||
title=""
|
||||
padding="none"
|
||||
class="p-0! gap-2"
|
||||
header-class="py-3! px-4 border-b gap-0 text-muted-foreground"
|
||||
body-class=""
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center gap-2">
|
||||
<Smartphone size="18" />
|
||||
<CardTitle class="uppercase">SMS stanje </CardTitle>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="smsStats?.length">
|
||||
<div v-for="p in smsStats" :key="p.id" class="flex flex-col rounded-lg bg-card">
|
||||
<Item variant="outline" size="lg" class="rounded-t-none border-t-0">
|
||||
<ItemContent class="gap-0">
|
||||
<ItemTitle
|
||||
class="w-full flex flex-row items-center justify-between border-b py-2 px-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-base">{{ p.name }}</span>
|
||||
<span
|
||||
class="px-2 py-0.5 text-xs font-medium rounded-full"
|
||||
:class="
|
||||
p.active
|
||||
? 'bg-emerald-100 text-emerald-700'
|
||||
: 'bg-gray-100 text-gray-500'
|
||||
"
|
||||
>
|
||||
{{ p.active ? "Aktiven" : "Neaktiven" }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-right flex-1">
|
||||
<div class="text-xs text-muted-foreground">Bilanca</div>
|
||||
<div class="text-xl font-bold">{{ p.balance ?? "—" }}</div>
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<!-- Stats grid -->
|
||||
<ItemDescription>
|
||||
<div class="grid grid-cols-4 divide-x">
|
||||
<div class="flex flex-row items-center justify-between p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted-foreground text-xs">Skupaj</span>
|
||||
<span class="text-xl font-bold">{{ p.today?.total ?? 0 }}</span>
|
||||
</div>
|
||||
<MessageSquare class="h-5 w-5 text-gray-500" />
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-between p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted-foreground text-xs">Poslano</span>
|
||||
<span class="text-xl font-bold">{{ p.today?.sent ?? 0 }}</span>
|
||||
</div>
|
||||
<Send class="h-5 w-5 text-sky-600" />
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-between p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted-foreground text-xs">Delivered</span>
|
||||
<span class="text-xl font-bold">{{ p.today?.delivered ?? 0 }}</span>
|
||||
</div>
|
||||
<CheckCircle class="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
<div class="flex flex-row items-center justify-between p-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-muted-foreground text-xs">Failed</span>
|
||||
<span class="text-xl font-bold">{{ p.today?.failed ?? 0 }}</span>
|
||||
</div>
|
||||
<XCircle class="h-5 w-5 text-rose-600" />
|
||||
</div>
|
||||
</div>
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-sm text-gray-500 p-4">Ni podatkov o SMS.</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
Reference in New Issue
Block a user