Added call later, option to limit auto mail so for a client person email you can limit which decision activity will be send to that specific email and moved SMS packages from admin panel to default app view

This commit is contained in:
Simon Pocrnjič
2026-03-08 21:42:39 +01:00
parent c16dd51199
commit b0d2aa93ab
32 changed files with 1103 additions and 174 deletions
@@ -1,5 +1,5 @@
<script setup>
import { ref, computed, watch } from "vue";
import { ref, computed, watch, onUnmounted } from "vue";
import {
Dialog,
DialogContent,
@@ -9,7 +9,7 @@ import {
} from "@/Components/ui/dialog";
import { Button } from "@/Components/ui/button";
import { Badge } from "../ui/badge";
import { Loader2 } from "lucide-vue-next";
import { Loader2, RotateCcwIcon } from "lucide-vue-next";
import axios from "axios";
const props = defineProps({
@@ -26,6 +26,157 @@ const loading = ref(false);
const previewGenerating = ref(false);
const previewError = ref("");
// Image viewer zoom & pan state
const containerRef = ref(null);
const imageRef = ref(null);
const imageScale = ref(1);
const translateX = ref(0);
const translateY = ref(0);
const fitScale = ref(1);
const isDragging = ref(false);
const hasMoved = ref(false);
const dragStartX = ref(0);
const dragStartY = ref(0);
const dragStartTX = ref(0);
const dragStartTY = ref(0);
const MAX_SCALE = 8;
const ZOOM_FACTOR = 2;
const imageCursorClass = computed(() => {
if (isDragging.value && hasMoved.value) return "cursor-grabbing";
if (imageScale.value > fitScale.value + 0.01) return "cursor-grab";
return "cursor-zoom-in";
});
const initImageView = () => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return;
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth || cW;
const iH = img.naturalHeight || cH;
const fs = Math.min(cW / iW, cH / iH);
fitScale.value = fs;
imageScale.value = fs;
translateX.value = (cW - iW * fs) / 2;
translateY.value = (cH - iH * fs) / 2;
};
const resetImageView = () => {
initImageView();
};
const clampTranslate = (tx, ty, scale) => {
const container = containerRef.value;
const img = imageRef.value;
if (!container || !img) return { tx, ty };
const cW = container.clientWidth;
const cH = container.clientHeight;
const iW = img.naturalWidth * scale;
const iH = img.naturalHeight * scale;
// When image fills the container: clamp so image edges stay within container.
// When image is smaller than container: keep it centered.
const minX = iW >= cW ? cW - iW : (cW - iW) / 2;
const maxX = iW >= cW ? 0 : (cW - iW) / 2;
const minY = iH >= cH ? cH - iH : (cH - iH) / 2;
const maxY = iH >= cH ? 0 : (cH - iH) / 2;
return {
tx: Math.min(maxX, Math.max(minX, tx)),
ty: Math.min(maxY, Math.max(minY, ty)),
};
};
const zoomAt = (mx, my, factor) => {
const img = imageRef.value;
const iW = img?.naturalWidth ?? 1;
const iH = img?.naturalHeight ?? 1;
const raw = imageScale.value * factor;
const newScale = Math.min(MAX_SCALE, Math.max(fitScale.value, raw));
if (newScale === imageScale.value) return;
let tx = mx - ((mx - translateX.value) / imageScale.value) * newScale;
let ty = my - ((my - translateY.value) / imageScale.value) * newScale;
const clamped = clampTranslate(tx, ty, newScale);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
imageScale.value = newScale;
};
const mousePos = (e) => {
const rect = containerRef.value.getBoundingClientRect();
return { mx: e.clientX - rect.left, my: e.clientY - rect.top };
};
const handleImageLoad = () => {
initImageView();
};
const handleClick = (e) => {
if (hasMoved.value) return;
const { mx, my } = mousePos(e);
zoomAt(mx, my, ZOOM_FACTOR);
};
const handleContextMenu = (e) => {
e.preventDefault();
if (hasMoved.value) return;
if (imageScale.value <= fitScale.value + 0.01) return;
const { mx, my } = mousePos(e);
zoomAt(mx, my, 1 / ZOOM_FACTOR);
};
const handleWheel = (e) => {
e.preventDefault();
const { mx, my } = mousePos(e);
zoomAt(mx, my, e.deltaY < 0 ? 1.2 : 1 / 1.2);
};
const onMouseMove = (e) => {
if (!isDragging.value) return;
const dx = e.clientX - dragStartX.value;
const dy = e.clientY - dragStartY.value;
if (!hasMoved.value && (Math.abs(dx) > 3 || Math.abs(dy) > 3)) {
hasMoved.value = true;
}
if (hasMoved.value) {
const clamped = clampTranslate(
dragStartTX.value + dx,
dragStartTY.value + dy,
imageScale.value
);
translateX.value = clamped.tx;
translateY.value = clamped.ty;
}
};
const onMouseUp = () => {
isDragging.value = false;
// Delay reset so the click/contextmenu handler that fires after mouseup can still read hasMoved
setTimeout(() => {
hasMoved.value = false;
}, 0);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
};
const handleMouseDown = (e) => {
if (e.button !== 0) return;
isDragging.value = true;
hasMoved.value = false;
dragStartX.value = e.clientX;
dragStartY.value = e.clientY;
dragStartTX.value = translateX.value;
dragStartTY.value = translateY.value;
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
};
onUnmounted(() => {
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
});
const fileExtension = computed(() => {
if (props.filename) {
return props.filename.split(".").pop()?.toLowerCase() || "";
@@ -118,6 +269,10 @@ watch(
previewGenerating.value = false;
previewError.value = "";
docxPreviewUrl.value = "";
imageScale.value = 1;
translateX.value = 0;
translateY.value = 0;
fitScale.value = 1;
}
},
{ immediate: true }
@@ -179,11 +334,52 @@ watch(
<!-- Image Viewer -->
<template v-else-if="viewerType === 'image' && props.src">
<img
:src="props.src"
:alt="props.title"
class="max-w-full max-h-full mx-auto object-contain"
/>
<div
ref="containerRef"
class="relative h-full overflow-hidden select-none"
:class="imageCursorClass"
@click="handleClick"
@contextmenu="handleContextMenu"
@mousedown="handleMouseDown"
@wheel.prevent="handleWheel"
>
<img
ref="imageRef"
:src="props.src"
:alt="props.title"
class="absolute top-0 left-0 max-w-none pointer-events-none"
:style="{
transformOrigin: '0 0',
transform: `translate(${translateX}px, ${translateY}px) scale(${imageScale})`,
transition: isDragging ? 'none' : 'transform 0.12s ease',
}"
@load="handleImageLoad"
/>
<!-- Zoom level badge -->
<div
class="absolute bottom-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded pointer-events-none"
>
{{ Math.round(imageScale * 100) }}%
</div>
<!-- Reset button -->
<Button
v-if="imageScale > fitScale + 0.01"
size="icon-sm"
variant="secondary"
class="absolute top-2 right-2 opacity-70 hover:opacity-100"
title="Ponastavi pogled"
@click.stop="resetImageView"
>
<RotateCcwIcon class="h-3 w-3" />
</Button>
<!-- Hint -->
<div
v-if="imageScale <= fitScale + 0.01"
class="absolute bottom-2 left-1/2 -translate-x-1/2 bg-black/40 text-white text-xs px-2 py-1 rounded pointer-events-none select-none"
>
Klik za povečavo · Desni klik / kolesce za pomanjšavo · Povleči za premik
</div>
</div>
</template>
<!-- Text/CSV/XML Viewer -->
@@ -3,7 +3,8 @@ import { computed, ref, watch } from "vue";
import { useForm, Field as FormField } from "vee-validate";
import { toTypedSchema } from "@vee-validate/zod";
import * as z from "zod";
import { router } from "@inertiajs/vue3";
import { router, usePage } from "@inertiajs/vue3";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import CreateDialog from "../Dialogs/CreateDialog.vue";
import UpdateDialog from "../Dialogs/UpdateDialog.vue";
import SectionTitle from "../SectionTitle.vue";
@@ -27,12 +28,22 @@ const props = defineProps({
const emit = defineEmits(["close"]);
// Decisions with auto_mail = true from shared Inertia data
const page = usePage();
const decisionOptions = computed(() =>
(page.props.auto_mail_decisions ?? []).map((d) => ({
value: String(d.id),
label: d.name,
}))
);
// Zod schema for form validation
const formSchema = toTypedSchema(
z.object({
value: z.string().email("E-pošta mora biti veljavna.").min(1, "E-pošta je obvezna."),
label: z.string().optional(),
receive_auto_mails: z.boolean().optional(),
decision_ids: z.array(z.string()).optional().default([]),
})
);
@@ -43,9 +54,13 @@ const form = useForm({
value: "",
label: "",
receive_auto_mails: false,
decision_ids: [],
},
});
// Whether to limit sending to specific decisions (UI-only toggle)
const limitToDecisions = ref(false);
const processing = ref(false);
const close = () => {
@@ -57,22 +72,44 @@ const close = () => {
};
const resetForm = () => {
limitToDecisions.value = false;
form.resetForm({
values: {
value: "",
label: "",
receive_auto_mails: false,
decision_ids: [],
},
});
};
// When auto mails is disabled, collapse the decision filter
watch(
() => form.values.receive_auto_mails,
(val) => {
if (!val) {
limitToDecisions.value = false;
}
}
);
// When limit toggle is turned off, clear the selection
watch(limitToDecisions, (val) => {
if (!val) {
form.setFieldValue("decision_ids", []);
}
});
const create = async () => {
processing.value = true;
const { values } = form;
const payload = {
...form.values,
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
};
router.post(
route("person.email.create", props.person),
values,
payload,
{
preserveScroll: true,
onSuccess: () => {
@@ -98,11 +135,14 @@ const create = async () => {
const update = async () => {
processing.value = true;
const { values } = form;
const payload = {
...form.values,
decision_ids: limitToDecisions.value ? (form.values.decision_ids ?? []) : [],
};
router.put(
route("person.email.update", { person: props.person, email_id: props.id }),
values,
payload,
{
preserveScroll: true,
onSuccess: () => {
@@ -136,10 +176,13 @@ watch(
const list = Array.isArray(props.person?.emails) ? props.person.emails : [];
const email = list.find((e) => e.id === props.id);
if (email) {
const existingDecisionIds = (email.preferences?.decision_ids ?? []).map(String);
limitToDecisions.value = existingDecisionIds.length > 0;
form.setValues({
value: email.value ?? email.email ?? email.address ?? "",
label: email.label ?? "",
receive_auto_mails: !!email.receive_auto_mails,
decision_ids: existingDecisionIds,
});
} else {
resetForm();
@@ -228,6 +271,36 @@ const onConfirm = () => {
</div>
</FormItem>
</FormField>
<!-- Limit to specific decisions only shown when receive_auto_mails is on and decisions exist -->
<template v-if="(props.person?.client || isClientContext) && form.values.receive_auto_mails && decisionOptions.length > 0">
<div class="flex flex-row items-start space-x-3 space-y-0">
<Switch
:model-value="limitToDecisions"
@update:model-value="(val) => (limitToDecisions = val)"
/>
<div class="space-y-1 leading-none">
<label class="text-sm font-medium leading-none cursor-pointer" @click="limitToDecisions = !limitToDecisions">
Omeji na posamezne odločitve
</label>
</div>
</div>
<FormField v-if="limitToDecisions" v-slot="{ value, handleChange }" name="decision_ids">
<FormItem>
<FormLabel>Odločitve, za katere se pošlje e-pošta</FormLabel>
<FormControl>
<AppMultiSelect
:model-value="value ?? []"
:items="decisionOptions"
placeholder="Izberi odločitve..."
@update:model-value="handleChange"
/>
</FormControl>
<FormMessage />
</FormItem>
</FormField>
</template>
</div>
</form>
</component>
-8
View File
@@ -15,7 +15,6 @@ import {
InboxIcon,
AtSignIcon,
BookUserIcon,
MessageSquareIcon,
ArrowLeftIcon,
} from "lucide-vue-next";
import Dropdown from "@/Components/Dropdown.vue";
@@ -211,13 +210,6 @@ const navGroups = computed(() => [
icon: Settings2Icon,
active: ["admin.sms-profiles.index"],
},
{
key: "admin.packages.index",
label: "SMS paketi",
route: "admin.packages.index",
icon: MessageSquareIcon,
active: ["admin.packages.index", "admin.packages.show"],
},
],
},
]);
+16
View File
@@ -26,6 +26,8 @@ import { SettingsIcon } from "lucide-vue-next";
import { ShieldUserIcon } from "lucide-vue-next";
import { SmartphoneIcon } from "lucide-vue-next";
import { TabletSmartphoneIcon } from "lucide-vue-next";
import { PhoneCallIcon } from "lucide-vue-next";
import { PackageIcon } from "lucide-vue-next";
const props = defineProps({
title: String,
@@ -157,6 +159,13 @@ const rawMenuGroups = [
routeName: "segments.index",
active: ["segments.index"],
},
{
key: "call-laters",
icon: PhoneCallIcon,
title: "Pokliči kasneje",
routeName: "callLaters.index",
active: ["callLaters.index"],
},
],
},
{
@@ -212,6 +221,13 @@ const rawMenuGroups = [
routeName: "settings",
active: ["settings", "settings.*"],
},
{
key: "packages",
icon: PackageIcon,
title: "SMS paketi",
routeName: "packages.index",
active: ["packages.index", "packages.show", "packages.create"],
},
// Admin panel (roles & permissions management)
// Only shown if current user has admin role or manage-settings permission.
// We'll filter it out below if not authorized.
-6
View File
@@ -107,12 +107,6 @@ const cards = [
route: "admin.sms-logs.index",
icon: InboxIcon,
},
{
title: "SMS paketi",
description: "Kreiranje in pošiljanje serijskih SMS paketov",
route: "admin.packages.index",
icon: MessageSquareIcon,
},
],
},
];
+297
View File
@@ -0,0 +1,297 @@
<script setup>
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import DataTable from "@/Components/DataTable/DataTableNew2.vue";
import AppCard from "@/Components/app/ui/card/AppCard.vue";
import CardTitle from "@/Components/ui/card/CardTitle.vue";
import { Button } from "@/Components/ui/button";
import { Input } from "@/Components/ui/input";
import InputLabel from "@/Components/InputLabel.vue";
import Pagination from "@/Components/Pagination.vue";
import {
PhoneCallIcon,
CheckIcon,
Filter,
ExternalLinkIcon,
MoreHorizontalIcon,
} from "lucide-vue-next";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/Components/ui/dropdown-menu";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
const props = defineProps({
callLaters: Object,
filters: Object,
});
const search = ref(props.filters?.search || "");
const dateFrom = ref(props.filters?.date_from || "");
const dateTo = ref(props.filters?.date_to || "");
const filterPopoverOpen = ref(false);
const appliedFilterCount = computed(() => {
let count = 0;
if (search.value?.trim()) count += 1;
if (dateFrom.value) count += 1;
if (dateTo.value) count += 1;
return count;
});
function applyFilters() {
filterPopoverOpen.value = false;
const params = {};
if (search.value?.trim()) {
params.search = search.value.trim();
}
if (dateFrom.value) {
params.date_from = dateFrom.value;
}
if (dateTo.value) {
params.date_to = dateTo.value;
}
router.get(route("callLaters.index"), params, {
preserveState: true,
replace: true,
preserveScroll: true,
});
}
function clearFilters() {
search.value = "";
dateFrom.value = "";
dateTo.value = "";
applyFilters();
}
function markDone(item) {
router.patch(
route("callLaters.complete", item.id),
{},
{
preserveScroll: true,
}
);
}
function openAndComplete(item) {
router.patch(
route("callLaters.complete", item.id),
{},
{
preserveScroll: false,
onSuccess: () => {
if (item.client_case?.uuid) {
router.visit(route("clientCase.show", { client_case: item.client_case.uuid }));
}
},
}
);
}
function isOverdue(item) {
if (!item.call_back_at) return false;
return new Date(item.call_back_at) < new Date();
}
function fmtDateTime(value) {
if (!value) return "-";
const d = new Date(value);
if (isNaN(d.getTime())) return value;
const day = String(d.getDate()).padStart(2, "0");
const month = String(d.getMonth() + 1).padStart(2, "0");
const year = d.getFullYear();
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
return `${day}.${month}.${year} ${hours}:${minutes}`;
}
const columns = [
{ key: "person", label: "Stranka / Primer", sortable: false },
{ key: "contract", label: "Pogodba", sortable: false },
{ key: "call_back_at", label: "Datum klica", sortable: false },
{ key: "user", label: "Agent", sortable: false },
{ key: "note", label: "Opomba", sortable: false },
{ key: "actions", label: "", sortable: false, class: "w-12" },
];
</script>
<template>
<AppLayout title="Pokliči kasneje">
<template #header></template>
<div class="py-6">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<AppCard
title=""
padding="none"
class="p-0! gap-0"
header-class="py-3! px-4 gap-0 text-muted-foreground"
body-class=""
>
<template #header>
<div class="flex items-center gap-2">
<PhoneCallIcon :size="18" />
<CardTitle class="uppercase">Pokliči kasneje</CardTitle>
</div>
</template>
<DataTable
:columns="columns"
:data="callLaters.data || []"
:meta="callLaters"
:search="search"
route-name="callLaters.index"
:show-toolbar="true"
:show-pagination="false"
:hoverable="true"
row-key="id"
empty-text="Ni zakazanih klicev."
:row-class="(row) => (isOverdue(row) ? 'bg-red-50 dark:bg-red-950/20' : '')"
>
<template #toolbar-filters>
<AppPopover
v-model:open="filterPopoverOpen"
align="start"
content-class="w-[420px]"
>
<template #trigger>
<Button variant="outline" size="sm" class="gap-2">
<Filter class="h-4 w-4" />
Filtri
<span
v-if="appliedFilterCount > 0"
class="ml-1 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"
>
{{ appliedFilterCount }}
</span>
</Button>
</template>
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium text-sm">Filtri klicev</h4>
</div>
<div class="space-y-3">
<div class="space-y-1.5">
<InputLabel>Iskanje (stranka)</InputLabel>
<Input
v-model="search"
type="text"
placeholder="Ime stranke..."
@keydown.enter="applyFilters"
/>
</div>
<div class="space-y-1.5">
<InputLabel>Datum od</InputLabel>
<Input v-model="dateFrom" type="date" />
</div>
<div class="space-y-1.5">
<InputLabel>Datum do</InputLabel>
<Input v-model="dateTo" type="date" />
</div>
<div class="flex justify-end gap-2 pt-2 border-t">
<Button
type="button"
variant="outline"
size="sm"
:disabled="appliedFilterCount === 0"
@click="clearFilters"
>
Počisti
</Button>
<Button type="button" size="sm" @click="applyFilters">
Uporabi
</Button>
</div>
</div>
</div>
</AppPopover>
</template>
<template #cell-person="{ row }">
<div>
<Link
v-if="row.client_case"
:href="route('clientCase.show', { client_case: row.client_case.uuid })"
class="font-medium text-indigo-600 hover:underline"
>
{{ row.client_case.person?.full_name || "-" }}
</Link>
<span v-else class="text-muted-foreground">-</span>
</div>
</template>
<template #cell-contract="{ row }">
<span v-if="row.contract">{{ row.contract.reference }}</span>
<span v-else class="text-muted-foreground">-</span>
</template>
<template #cell-call_back_at="{ row }">
<span
:class="[
'font-medium',
isOverdue(row) ? 'text-red-600 dark:text-red-400' : '',
]"
>
{{ fmtDateTime(row.call_back_at) }}
</span>
<span v-if="isOverdue(row)" class="ml-2 text-xs text-red-500 font-semibold">
Zamuda
</span>
</template>
<template #cell-user="{ row }">
<span v-if="row.user">{{ row.user.name }}</span>
<span v-else class="text-muted-foreground">-</span>
</template>
<template #cell-note="{ row }">
<span class="line-clamp-2 text-sm text-muted-foreground">
{{ row.activity?.note || "-" }}
</span>
</template>
<template #cell-actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button size="icon" variant="ghost" class="h-8 w-8">
<MoreHorizontalIcon class="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem @click="markDone(row)">
<CheckIcon class="mr-2 h-4 w-4" />
Opravljeno
</DropdownMenuItem>
<DropdownMenuItem
v-if="row.client_case?.uuid"
@click="openAndComplete(row)"
>
<ExternalLinkIcon class="mr-2 h-4 w-4" />
Odpri in opravi
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTable>
<div class="border-t border-gray-200 p-4">
<Pagination
:links="callLaters.links"
:from="callLaters.from"
:to="callLaters.to"
:total="callLaters.total"
:per-page="callLaters.per_page || 50"
:last-page="callLaters.last_page"
:current-page="callLaters.current_page"
/>
</div>
</AppCard>
</div>
</div>
</AppLayout>
</template>
@@ -58,6 +58,8 @@ const form = useInertiaForm({
send_auto_mail: true,
attach_documents: false,
attachment_document_ids: [],
call_back_at_date: null,
call_back_at_time: null,
});
watch(
@@ -127,6 +129,20 @@ const store = async () => {
const isMultipleContracts = contractUuids && contractUuids.length > 1;
const buildCallBackAt = (date, time) => {
if (!date) return null;
const t = time || '00:00';
const [h, m] = t.split(':');
const d = date instanceof Date ? date : new Date(date);
if (isNaN(d.getTime())) return null;
const y = d.getFullYear();
const mo = String(d.getMonth() + 1).padStart(2, '0');
const dy = String(d.getDate()).padStart(2, '0');
const hh = String(Number(h || 0)).padStart(2, '0');
const mm = String(Number(m || 0)).padStart(2, '0');
return `${y}-${mo}-${dy} ${hh}:${mm}:00`;
};
form
.transform((data) => ({
...data,
@@ -138,11 +154,16 @@ const store = async () => {
templateAllowsAttachments.value && data.attach_documents && !isMultipleContracts
? data.attachment_document_ids
: [],
call_back_at: hasCallLaterEvent.value
? buildCallBackAt(data.call_back_at_date, data.call_back_at_time)
: null,
call_back_at_date: undefined,
call_back_at_time: undefined,
}))
.post(route("clientCase.activity.store", props.client_case), {
onSuccess: () => {
close();
form.reset("due_date", "amount", "note", "contract_uuids");
form.reset("due_date", "amount", "note", "contract_uuids", "call_back_at_date", "call_back_at_time");
emit("saved");
},
});
@@ -156,6 +177,22 @@ const currentDecision = () => {
decisions.value.find((d) => d.id === form.decision_id) || decisions.value[0] || null
);
};
const hasCallLaterEvent = computed(() => {
const d = currentDecision();
if (!d) return false;
return Array.isArray(d.events) && d.events.some((e) => e.key === 'add_call_later');
});
watch(
() => hasCallLaterEvent.value,
(has) => {
if (!has) {
form.call_back_at_date = null;
form.call_back_at_time = null;
}
}
);
const showSendAutoMail = () => {
const d = currentDecision();
return !!(d && d.auto_mail && d.email_template_id);
@@ -409,6 +446,26 @@ watch(
/>
</div>
<div v-if="hasCallLaterEvent" class="space-y-2">
<Label>Datum in ura povratnega klica</Label>
<div class="flex gap-2">
<DatePicker
v-model="form.call_back_at_date"
format="dd.MM.yyyy"
:error="form.errors.call_back_at"
class="flex-1"
/>
<input
v-model="form.call_back_at_time"
type="time"
class="flex-1 border rounded-md px-3 py-2 text-sm bg-background focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
<p v-if="form.errors.call_back_at" class="text-xs text-destructive">
{{ form.errors.call_back_at }}
</p>
</div>
<div class="space-y-2">
<Label for="activityAmount">Znesek</Label>
<CurrencyInput
@@ -1,5 +1,5 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router, useForm } from "@inertiajs/vue3";
import { ref, computed, nextTick } from "vue";
import axios from "axios";
@@ -112,9 +112,9 @@ function submitCreate() {
})),
};
router.post(route("admin.packages.store"), payload, {
router.post(route("packages.store"), payload, {
onSuccess: () => {
router.visit(route("admin.packages.index"));
router.visit(route("packages.index"));
},
});
}
@@ -202,7 +202,7 @@ async function loadContracts(url = null) {
if (onlyValidated.value) params.append("only_validated", "1");
params.append("per_page", perPage.value);
const target = url || `${route("admin.packages.contracts")}?${params.toString()}`;
const target = url || `${route("packages.contracts")}?${params.toString()}`;
const { data: json } = await axios.get(target, {
headers: { "X-Requested-With": "XMLHttpRequest" },
});
@@ -268,7 +268,7 @@ function goToPage(page) {
params.append("per_page", perPage.value);
params.append("page", page);
const url = `${route("admin.packages.contracts")}?${params.toString()}`;
const url = `${route("packages.contracts")}?${params.toString()}`;
loadContracts(url);
}
@@ -312,9 +312,9 @@ function submitCreateFromContracts() {
};
creatingFromContracts.value = true;
router.post(route("admin.packages.store-from-contracts"), payload, {
router.post(route("packages.store-from-contracts"), payload, {
onSuccess: () => {
router.visit(route("admin.packages.index"));
router.visit(route("packages.index"));
},
onError: (errors) => {
const first = errors && Object.values(errors)[0];
@@ -337,11 +337,11 @@ const numbersCount = computed(() => {
</script>
<template>
<AdminLayout title="Ustvari SMS paket">
<AppLayout title="Ustvari SMS paket">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<Link :href="route('admin.packages.index')">
<Link :href="route('packages.index')">
<Button variant="ghost" size="sm">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
@@ -520,7 +520,7 @@ const numbersCount = computed(() => {
</div>
<div class="flex justify-end gap-2">
<Button
@click="router.visit(route('admin.packages.index'))"
@click="router.visit(route('packages.index'))"
variant="outline"
>
Prekliči
@@ -703,7 +703,7 @@ const numbersCount = computed(() => {
Izbrano: {{ selectedContractIds.size }}
</Badge>
<Button
@click="router.visit(route('admin.packages.index'))"
@click="router.visit(route('packages.index'))"
variant="outline"
>
Prekliči
@@ -806,5 +806,5 @@ const numbersCount = computed(() => {
</Card>
</TabsContent>
</Tabs>
</AdminLayout>
</AppLayout>
</template>
@@ -1,5 +1,5 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { ref } from "vue";
import { Card, CardHeader, CardTitle } from "@/Components/ui/card";
@@ -48,7 +48,7 @@ function getStatusVariant(status) {
}
function goShow(id) {
router.visit(route("admin.packages.show", id));
router.visit(route("packages.show", id));
}
function openDeleteDialog(pkg) {
@@ -60,7 +60,7 @@ function openDeleteDialog(pkg) {
function confirmDelete() {
if (!packageToDelete.value) return;
deletingId.value = packageToDelete.value.id;
router.delete(route("admin.packages.destroy", packageToDelete.value.id), {
router.delete(route("packages.destroy", packageToDelete.value.id), {
onSuccess: () => {
router.reload({ only: ["packages"] });
},
@@ -74,7 +74,7 @@ function confirmDelete() {
</script>
<template>
<AdminLayout title="SMS paketi">
<AppLayout title="SMS paketi">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
@@ -82,7 +82,7 @@ function confirmDelete() {
<PackageIcon class="h-5 w-5 text-muted-foreground" />
<CardTitle>SMS paketi</CardTitle>
</div>
<Link :href="route('admin.packages.create')">
<Link :href="route('packages.create')">
<Button>
<PlusIcon class="h-4 w-4" />
Nov paket
@@ -109,7 +109,7 @@ function confirmDelete() {
:columns="columns"
:data="packages.data"
:meta="packages"
route-name="admin.packages.index"
route-name="packages.index"
>
<template #cell-name="{ row }">
<span class="text-sm">{{ row.name ?? "—" }}</span>
@@ -172,5 +172,5 @@ function confirmDelete() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</AdminLayout>
</AppLayout>
</template>
@@ -1,5 +1,5 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import AppLayout from "@/Layouts/AppLayout.vue";
import { Link, router } from "@inertiajs/vue3";
import { onMounted, onUnmounted, ref, computed } from "vue";
import {
@@ -88,14 +88,14 @@ function reload() {
function dispatchPkg() {
router.post(
route("admin.packages.dispatch", props.package.id),
route("packages.dispatch", props.package.id),
{},
{ onSuccess: reload }
);
}
function cancelPkg() {
router.post(
route("admin.packages.cancel", props.package.id),
route("packages.cancel", props.package.id),
{},
{ onSuccess: reload }
);
@@ -132,7 +132,7 @@ async function copyText(text) {
</script>
<template>
<AdminLayout :title="`Paket #${package.id}`">
<AppLayout :title="`Paket #${package.id}`">
<Card class="mb-4">
<CardHeader>
<div class="flex items-center justify-between">
@@ -147,7 +147,7 @@ async function copyText(text) {
</div>
<div class="flex items-center gap-2">
<Button variant="ghost" size="sm" as-child>
<Link :href="route('admin.packages.index')">
<Link :href="route('packages.index')">
<ArrowLeftIcon class="h-4 w-4 mr-2" />
Nazaj
</Link>
@@ -281,7 +281,7 @@ async function copyText(text) {
:columns="columns"
:data="items.data"
:meta="items"
route-name="admin.packages.show"
route-name="packages.show"
:route-params="{ id: package.id }"
>
<template #cell-target="{ row }">
@@ -333,5 +333,5 @@ async function copyText(text) {
<div v-if="refreshing" class="mt-2 text-xs text-muted-foreground">
Osveževanje ...
</div>
</AdminLayout>
</AppLayout>
</template>
@@ -1,5 +1,4 @@
<script setup>
// flowbite-vue table imports removed; using DataTableClient
import {
Dialog,
DialogContent,
@@ -27,7 +26,7 @@ import { Input } from "@/Components/ui/input";
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { Button } from "@/Components/ui/button";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
@@ -60,16 +59,13 @@ const segmentOptions = computed(() =>
}))
);
// DataTable state
const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "#", sortable: true, class: "w-16" },
{ key: "name", label: "Ime", sortable: true },
{ key: "color_tag", label: "Barva", sortable: false },
{ key: "segment", label: "Segment", sortable: false },
{ key: "decisions", label: "Odločitve", sortable: false, class: "w-32" },
{ key: "actions", label: "", sortable: false, class: "w-12" },
];
const form = useForm({
@@ -231,18 +227,12 @@ const destroyAction = () => {
<Button @click="openCreateDrawer">+ Dodaj akcijo</Button>
</div>
<div>
<DataTableClient
<DataTableNew2
:columns="columns"
:rows="filtered"
:sort="sort"
:search="''"
:page="page"
:pageSize="pageSize"
:data="filtered"
:pageSize="25"
:showToolbar="false"
:showPagination="true"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
>
<template #cell-color_tag="{ row }">
<div class="flex items-center gap-2">
@@ -262,7 +252,7 @@ const destroyAction = () => {
{{ row.segment?.name || "" }}
</span>
</template>
<template #actions="{ row }">
<template #cell-actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
@@ -285,7 +275,7 @@ const destroyAction = () => {
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTableClient>
</DataTableNew2>
</div>
<Dialog v-model:open="drawerEdit">
@@ -1,6 +1,5 @@
<script setup>
// flowbite-vue table imports removed; using DataTableClient
import { EditIcon, TrashBinIcon, DottedMenu } from "@/Utilities/Icons";
import { DottedMenu } from "@/Utilities/Icons";
import {
Dialog,
DialogContent,
@@ -30,11 +29,11 @@ import {
import AppCombobox from "@/Components/app/ui/AppCombobox.vue";
import AppMultiSelect from "@/Components/app/ui/AppMultiSelect.vue";
import { Button } from "@/Components/ui/button";
import DataTableClient from "@/Components/DataTable/DataTableClient.vue";
import DataTableNew2 from "@/Components/DataTable/DataTableNew2.vue";
import InlineColorPicker from "@/Components/InlineColorPicker.vue";
import Dropdown from "@/Components/Dropdown.vue";
import AppPopover from "@/Components/app/ui/AppPopover.vue";
import { FilterIcon, Trash2, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
import { FilterIcon, MoreHorizontal, Pencil, Trash } from "lucide-vue-next";
import {
DropdownMenu,
DropdownMenuContent,
@@ -64,10 +63,6 @@ const selectedEvents = ref([]);
const actionOptions = ref([]);
// DataTable state
const sort = ref({ key: null, direction: null });
const page = ref(1);
const pageSize = ref(25);
const columns = [
{ key: "id", label: "#", sortable: true },
{ key: "name", label: "Ime", sortable: true },
@@ -75,6 +70,7 @@ const columns = [
{ key: "events", label: "Dogodki", sortable: false },
{ key: "belongs", label: "Pripada akcijam", sortable: false },
{ key: "auto_mail", label: "Auto mail", sortable: false },
{ key: "actions", label: "", sortable: false, class: "w-12" },
];
const form = useForm({
@@ -191,6 +187,8 @@ function defaultConfigForKey(key) {
return { archive_setting_id: null, reactivate: false };
case "end_field_job":
return {};
case "add_call_later":
return {};
default:
return {};
}
@@ -466,18 +464,12 @@ const destroyDecision = () => {
</div>
</div>
<div>
<DataTableClient
<DataTableNew2
:columns="columns"
:rows="filtered"
:sort="sort"
:search="''"
:page="page"
:pageSize="pageSize"
:data="filtered"
:pageSize="25"
:showToolbar="false"
:showPagination="true"
@update:sort="(v) => (sort = v)"
@update:page="(v) => (page = v)"
@update:pageSize="(v) => (pageSize = v)"
>
<template #cell-color_tag="{ row }">
<div class="flex items-center gap-2">
@@ -494,14 +486,13 @@ const destroyDecision = () => {
</template>
<template #cell-events="{ row }">
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600">{{ row.events?.length ?? 0 }}</span>
<Dropdown align="left" width="64" :close-on-content-click="false">
<template #trigger>
<button
type="button"
class="p-1 rounded hover:bg-gray-100 border border-transparent hover:border-gray-200"
>
<DottedMenu size="sm" css="text-gray-600" />
{{ row.events?.length ?? 0 }}
</button>
</template>
<template #content>
@@ -549,7 +540,7 @@ const destroyDecision = () => {
</span>
</div>
</template>
<template #actions="{ row }">
<template #cell-actions="{ row }">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<Button variant="ghost" size="icon">
@@ -572,7 +563,7 @@ const destroyDecision = () => {
</DropdownMenuContent>
</DropdownMenu>
</template>
</DataTableClient>
</DataTableNew2>
</div>
<Dialog v-model:open="drawerEdit">
<DialogContent class="max-w-2xl max-h-[90vh] overflow-y-auto">
@@ -752,6 +743,11 @@ const destroyDecision = () => {
Ta dogodek nima dodatnih nastavitev.
</p>
</template>
<template v-else-if="eventKey(ev) === 'add_call_later'">
<p class="text-sm text-muted-foreground">
Datum in ura povratnega klica se vneseta ob ustvarjanju aktivnosti.
</p>
</template>
<template v-else>
<!-- Fallback advanced editor for unknown event keys -->
<InputLabel :for="`cfg-${idx}`" value="Napredna nastavitev (JSON)" />
@@ -981,6 +977,11 @@ const destroyDecision = () => {
Ta dogodek nima dodatnih nastavitev.
</p>
</template>
<template v-else-if="eventKey(ev) === 'add_call_later'">
<p class="text-sm text-muted-foreground">
Datum in ura povratnega klica se vneseta ob ustvarjanju aktivnosti.
</p>
</template>
<template v-else>
<InputLabel :for="`ccfg-${idx}`" value="Napredna nastavitev (JSON)" />
<textarea