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>