SMS service

This commit is contained in:
Simon Pocrnjič
2025-10-24 21:39:10 +02:00
parent 3a2eed7dda
commit 930ac83604
52 changed files with 3830 additions and 36 deletions
+225 -2
View File
@@ -3,7 +3,7 @@ import { FwbBadge } from "flowbite-vue";
import { EditIcon, PlusIcon, UserEditIcon, TrashBinIcon } from "@/Utilities/Icons";
import CusTab from "./CusTab.vue";
import CusTabs from "./CusTabs.vue";
import { provide, ref, watch } from "vue";
import { provide, ref, watch, computed } from "vue";
import axios from "axios";
import PersonUpdateForm from "./PersonUpdateForm.vue";
import AddressCreateForm from "./AddressCreateForm.vue";
@@ -14,6 +14,8 @@ import EmailUpdateForm from "./EmailUpdateForm.vue";
import TrrCreateForm from "./TrrCreateForm.vue";
import TrrUpdateForm from "./TrrUpdateForm.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import DialogModal from "@/Components/DialogModal.vue";
import { router, usePage } from "@inertiajs/vue3";
const props = defineProps({
person: Object,
@@ -32,6 +34,14 @@ const props = defineProps({
phone_types: [],
},
},
// Enable sending SMS buttons (only pass true for ClientCase person context)
enableSms: { type: Boolean, default: false },
// Required when enableSms=true to scope route correctly
clientCaseUuid: { type: String, default: null },
// Optional overrides; if not provided, falls back to Inertia page props
smsProfiles: { type: Array, default: () => [] },
smsSenders: { type: Array, default: () => [] },
smsTemplates: { type: Array, default: () => [] },
});
const drawerUpdatePerson = ref(false);
@@ -187,6 +197,123 @@ const getTRRs = (p) => {
if (Array.isArray(p?.bankAccounts)) return p.bankAccounts;
return [];
};
// SMS dialog state and actions (ClientCase person only)
const showSmsDialog = ref(false);
const smsTargetPhone = ref(null);
const smsMessage = ref("");
const smsSending = ref(false);
// Page-level props fallback for SMS metadata
const page = usePage();
// In Inertia Vue 3, page.props is already a reactive object (not a Ref),
// so do NOT access .value here; otherwise, you'll get undefined and empty lists.
const pageProps = computed(() => page?.props ?? {});
const pageSmsProfiles = computed(() => {
const fromProps =
Array.isArray(props.smsProfiles) && props.smsProfiles.length
? props.smsProfiles
: null;
return fromProps ?? pageProps.value?.sms_profiles ?? [];
});
const pageSmsSenders = computed(() => {
const fromProps =
Array.isArray(props.smsSenders) && props.smsSenders.length ? props.smsSenders : null;
return fromProps ?? pageProps.value?.sms_senders ?? [];
});
const pageSmsTemplates = computed(() => {
const fromProps =
Array.isArray(props.smsTemplates) && props.smsTemplates.length
? props.smsTemplates
: null;
return fromProps ?? pageProps.value?.sms_templates ?? [];
});
// Selections
const selectedProfileId = ref(null);
const selectedSenderId = ref(null);
const deliveryReport = ref(false);
const selectedTemplateId = ref(null);
const sendersForSelectedProfile = computed(() => {
if (!selectedProfileId.value) return pageSmsSenders.value;
return (pageSmsSenders.value || []).filter(
(s) => s.profile_id === selectedProfileId.value
);
});
watch(selectedProfileId, () => {
// Clear sender selection if it doesn't belong to the chosen profile
if (!selectedSenderId.value) return;
const ok = sendersForSelectedProfile.value.some((s) => s.id === selectedSenderId.value);
if (!ok) selectedSenderId.value = null;
});
watch(selectedTemplateId, () => {
if (!selectedTemplateId.value) return;
const tpl = (pageSmsTemplates.value || []).find(
(t) => t.id === selectedTemplateId.value
);
if (tpl && typeof tpl.content === "string") {
smsMessage.value = tpl.content;
}
});
const openSmsDialog = (phone) => {
if (!props.enableSms || !props.clientCaseUuid) return;
smsTargetPhone.value = phone;
smsMessage.value = "";
showSmsDialog.value = true;
// Defaults
selectedProfileId.value =
(pageSmsProfiles.value && pageSmsProfiles.value[0]?.id) || null;
// If profile has default sender, try to preselect it
if (selectedProfileId.value) {
const prof = (pageSmsProfiles.value || []).find(
(p) => p.id === selectedProfileId.value
);
if (prof && prof.default_sender_id) {
selectedSenderId.value = prof.default_sender_id;
} else {
selectedSenderId.value = null;
}
} else {
selectedSenderId.value = null;
}
deliveryReport.value = false;
selectedTemplateId.value = null;
};
const closeSmsDialog = () => {
showSmsDialog.value = false;
smsTargetPhone.value = null;
smsMessage.value = "";
};
const submitSms = () => {
if (!smsTargetPhone.value || !smsMessage.value || !props.clientCaseUuid) {
return;
}
smsSending.value = true;
router.post(
route("clientCase.phone.sms", {
client_case: props.clientCaseUuid,
phone_id: smsTargetPhone.value.id,
}),
{
message: smsMessage.value,
template_id: selectedTemplateId.value,
profile_id: selectedProfileId.value,
sender_id: selectedSenderId.value,
delivery_report: !!deliveryReport.value,
},
{
preserveScroll: true,
onFinish: () => {
smsSending.value = false;
closeSmsDialog();
},
}
);
};
</script>
<template>
@@ -303,9 +430,20 @@ const getTRRs = (p) => {
<div class="text-sm leading-5 md:text-sm text-gray-500 flex justify-between">
<div class="flex">
<FwbBadge title type="yellow">+{{ phone.country_code }}</FwbBadge>
<FwbBadge>{{ phone.type.name }}</FwbBadge>
<FwbBadge>{{
phone && phone.type && phone.type.name ? phone.type.name : "—"
}}</FwbBadge>
</div>
<div class="flex items-center gap-2">
<!-- Send SMS only in ClientCase person context -->
<button
v-if="enableSms && clientCaseUuid"
@click="openSmsDialog(phone)"
title="Pošlji SMS"
class="text-indigo-600 hover:text-indigo-800 text-xs border border-indigo-200 px-2 py-0.5 rounded"
>
SMS
</button>
<button>
<EditIcon
@click="operDrawerAddPhone(true, phone.id)"
@@ -510,4 +648,89 @@ const getTRRs = (p) => {
@close="closeConfirm"
@confirm="onConfirmDelete"
/>
<!-- Send SMS dialog -->
<DialogModal :show="showSmsDialog" @close="closeSmsDialog">
<template #title>Pošlji SMS</template>
<template #content>
<div class="space-y-2">
<p class="text-sm text-gray-600">
Prejemnik: <span class="font-mono">{{ smsTargetPhone?.nu }}</span>
<span v-if="smsTargetPhone?.country_code" class="ml-2 text-xs text-gray-500"
>CC +{{ smsTargetPhone.country_code }}</span
>
</p>
<!-- Profile & Sender selectors -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-2">
<div>
<label class="block text-sm font-medium text-gray-700">Profil</label>
<select
v-model="selectedProfileId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="p in pageSmsProfiles" :key="p.id" :value="p.id">
{{ p.name || "Profil #" + p.id }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">Pošiljatelj</label>
<select
v-model="selectedSenderId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="s in sendersForSelectedProfile" :key="s.id" :value="s.id">
{{ s.name || s.phone || "Sender #" + s.id }}
</option>
</select>
</div>
</div>
<!-- Template selector -->
<div>
<label class="block text-sm font-medium text-gray-700">Predloga</label>
<select
v-model="selectedTemplateId"
class="mt-1 block w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
>
<option :value="null"></option>
<option v-for="t in pageSmsTemplates" :key="t.id" :value="t.id">
{{ t.name || "Predloga #" + t.id }}
</option>
</select>
</div>
<label class="block text-sm font-medium text-gray-700">Vsebina sporočila</label>
<textarea
v-model="smsMessage"
rows="4"
class="w-full rounded border-gray-300 focus:border-indigo-500 focus:ring-indigo-500"
placeholder="Vpišite SMS vsebino..."
></textarea>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 mt-1">
<input
type="checkbox"
v-model="deliveryReport"
class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
Zahtevaj poročilo o dostavi
</label>
</div>
</template>
<template #footer>
<button class="px-3 py-1 rounded border mr-2" @click="closeSmsDialog">
Prekliči
</button>
<button
class="px-3 py-1 rounded bg-indigo-600 text-white disabled:opacity-50"
:disabled="smsSending || !smsMessage"
@click="submitSms"
>
Pošlji
</button>
</template>
</DialogModal>
</template>
+40
View File
@@ -15,6 +15,8 @@ import {
faAt,
faInbox,
faFileLines,
faMessage,
faAddressBook,
} from "@fortawesome/free-solid-svg-icons";
import Dropdown from "@/Components/Dropdown.vue";
import DropdownLink from "@/Components/DropdownLink.vue";
@@ -134,6 +136,44 @@ const navGroups = computed(() => [
},
],
},
{
key: "sms",
label: "SMS",
items: [
{
key: "admin.sms-templates.index",
label: "SMS predloge",
route: "admin.sms-templates.index",
icon: faFileLines,
active: [
"admin.sms-templates.index",
"admin.sms-templates.create",
"admin.sms-templates.edit",
],
},
{
key: "admin.sms-logs.index",
label: "SMS dnevniki",
route: "admin.sms-logs.index",
icon: faInbox,
active: ["admin.sms-logs.index", "admin.sms-logs.show"],
},
{
key: "admin.sms-senders.index",
label: "SMS pošiljatelji",
route: "admin.sms-senders.index",
icon: faAddressBook,
active: ["admin.sms-senders.index"],
},
{
key: "admin.sms-profiles.index",
label: "SMS profili",
route: "admin.sms-profiles.index",
icon: faGears,
active: ["admin.sms-profiles.index"],
},
],
},
]);
function isActive(patterns) {
+30 -1
View File
@@ -2,7 +2,7 @@
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { Link } from '@inertiajs/vue3'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { faUserGroup, faKey, faGears, faFileWord, faEnvelopeOpenText, faInbox, faAt } from '@fortawesome/free-solid-svg-icons'
import { faUserGroup, faKey, faGears, faFileWord, faEnvelopeOpenText, faInbox, faAt, faAddressBook, faFileLines } from '@fortawesome/free-solid-svg-icons'
const cards = [
{
@@ -62,6 +62,35 @@ const cards = [
},
],
},
{
category: 'Komunikacije',
items: [
{
title: 'SMS profili',
description: 'Nastavitve SMS profilov, testno pošiljanje in stanje kreditov',
route: 'admin.sms-profiles.index',
icon: faGears,
},
{
title: 'SMS pošiljatelji',
description: 'Upravljanje nazivov pošiljateljev (Sender ID) za SMS profile',
route: 'admin.sms-senders.index',
icon: faAddressBook,
},
{
title: 'SMS predloge',
description: 'Tekstovne predloge za SMS obvestila in opomnike',
route: 'admin.sms-templates.index',
icon: faFileLines,
},
{
title: 'SMS dnevniki',
description: 'Pregled poslanih SMSov in statusov',
route: 'admin.sms-logs.index',
icon: faInbox,
},
],
},
]
</script>
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Head, Link, router } from "@inertiajs/vue3";
import { ref, watch } from "vue";
const props = defineProps({
logs: { type: Object, required: true },
profiles: { type: Array, default: () => [] },
templates: { type: Array, default: () => [] },
filters: { type: Object, default: () => ({}) },
});
const f = ref({
status: props.filters.status ?? "",
profile_id: props.filters.profile_id ?? "",
template_id: props.filters.template_id ?? "",
search: props.filters.search ?? "",
from: props.filters.from ?? "",
to: props.filters.to ?? "",
});
function reload() {
const query = Object.fromEntries(
Object.entries(f.value).filter(([_, v]) => v !== null && v !== undefined && v !== "")
);
router.get(route("admin.sms-logs.index"), query, { preserveScroll: true, preserveState: true });
}
watch(
() => [f.value.status, f.value.profile_id, f.value.template_id, f.value.from, f.value.to],
() => reload()
);
function clearFilters() {
f.value = { status: "", profile_id: "", template_id: "", search: "", from: "", to: "" };
reload();
}
</script>
<template>
<AdminLayout title="SMS dnevniki">
<Head title="SMS dnevniki" />
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-semibold text-gray-800">SMS dnevniki</h1>
</div>
<div class="rounded-lg border bg-white p-4 shadow-sm mb-4">
<div class="grid grid-cols-1 md:grid-cols-6 gap-3">
<div>
<label class="label">Status</label>
<select v-model="f.status" class="input">
<option value="">Vsi</option>
<option value="queued">queued</option>
<option value="sent">sent</option>
<option value="delivered">delivered</option>
<option value="failed">failed</option>
</select>
</div>
<div>
<label class="label">Profil</label>
<select v-model="f.profile_id" class="input">
<option value="">Vsi</option>
<option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="label">Predloga</label>
<select v-model="f.template_id" class="input">
<option value="">Vse</option>
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div>
<label class="label">Od</label>
<input type="date" v-model="f.from" class="input" />
</div>
<div>
<label class="label">Do</label>
<input type="date" v-model="f.to" class="input" />
</div>
<div>
<label class="label">Iskanje</label>
<input
type="text"
v-model="f.search"
class="input"
placeholder="to, sender, provider id, message"
@keyup.enter="reload"
/>
</div>
</div>
<div class="mt-3 flex items-center gap-2">
<button type="button" class="px-3 py-1.5 rounded border text-sm bg-gray-50 hover:bg-gray-100" @click="reload">Filtriraj</button>
<button type="button" class="px-3 py-1.5 rounded border text-sm bg-white hover:bg-gray-50" @click="clearFilters">Počisti</button>
</div>
</div>
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-3 py-2 text-left">Čas</th>
<th class="px-3 py-2 text-left">Prejemnik</th>
<th class="px-3 py-2 text-left">Sender</th>
<th class="px-3 py-2 text-left">Profil</th>
<th class="px-3 py-2 text-left">Predloga</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-left">Cena</th>
<th class="px-3 py-2 text-left">Provider ID</th>
<th class="px-3 py-2">&nbsp;</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs.data" :key="log.id" class="border-t last:border-b hover:bg-gray-50">
<td class="px-3 py-2">{{ new Date(log.created_at).toLocaleString() }}</td>
<td class="px-3 py-2">{{ log.to_number }}</td>
<td class="px-3 py-2">{{ log.sender || '—' }}</td>
<td class="px-3 py-2">{{ log.profile?.name || '—' }}</td>
<td class="px-3 py-2">{{ log.template?.slug || log.template?.name || '—' }}</td>
<td class="px-3 py-2">
<span :class="{
'text-amber-600': log.status === 'queued',
'text-sky-700': log.status === 'sent',
'text-emerald-700': log.status === 'delivered',
'text-rose-700': log.status === 'failed',
}">{{ log.status }}</span>
</td>
<td class="px-3 py-2">{{ log.cost != null ? (Number(log.cost).toFixed(2) + ' ' + (log.currency || '')) : '—' }}</td>
<td class="px-3 py-2">{{ log.provider_message_id || '—' }}</td>
<td class="px-3 py-2 text-right">
<Link :href="route('admin.sms-logs.show', log.id)" class="text-xs px-2 py-1 rounded border text-gray-700 bg-gray-50 hover:bg-gray-100">Ogled</Link>
</td>
</tr>
<tr v-if="!logs.data || logs.data.length === 0">
<td colspan="9" class="px-3 py-6 text-center text-sm text-gray-500">Ni vnosov.</td>
</tr>
</tbody>
</table>
<div class="px-3 py-2 border-t flex items-center justify-between text-xs text-gray-600">
<div>
Prikaz {{ logs.from || 0 }}{{ logs.to || 0 }} od {{ logs.total || 0 }}
</div>
<div class="flex items-center gap-1">
<Link
v-for="l in logs.links"
:key="l.label + l.url"
:href="l.url || '#'"
v-html="l.label"
class="px-2 py-1 rounded border"
:class="[l.active ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-white hover:bg-gray-50']"
preserve-scroll
preserve-state
/>
</div>
</div>
</div>
</AdminLayout>
</template>
<style scoped>
.input { width: 100%; border-radius: 0.375rem; border: 1px solid #d1d5db; padding: 0.5rem 0.75rem; font-size: 0.875rem; line-height: 1.25rem; }
.input:focus { outline: 2px solid transparent; outline-offset: 2px; border-color: #6366f1; box-shadow: 0 0 0 1px #6366f1; }
.label { display: block; font-size: 0.65rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: #6b7280; margin-bottom: 0.25rem; }
</style>
+80
View File
@@ -0,0 +1,80 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Head, Link } from "@inertiajs/vue3";
const props = defineProps({ log: { type: Object, required: true } });
</script>
<template>
<AdminLayout title="SMS log">
<Head title="SMS log" />
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<Link :href="route('admin.sms-logs.index')" class="text-sm text-indigo-600 hover:underline"> Nazaj na dnevnike</Link>
<div class="text-gray-700 text-sm">#{{ log.id }}</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div class="lg:col-span-2 space-y-4">
<div class="rounded-lg border bg-white p-4 shadow-sm">
<div class="font-semibold text-gray-800 mb-2">Sporočilo</div>
<pre class="text-sm whitespace-pre-wrap">{{ log.message }}</pre>
</div>
<div class="rounded-lg border bg-white p-4 shadow-sm">
<div class="font-semibold text-gray-800 mb-2">Meta</div>
<pre class="text-xs whitespace-pre-wrap">{{ JSON.stringify(log.meta || {}, null, 2) }}</pre>
</div>
</div>
<div class="space-y-4">
<div class="rounded-lg border bg-white p-4 shadow-sm">
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div class="text-gray-500">Prejemnik</div>
<div class="text-gray-800">{{ log.to_number }}</div>
<div class="text-gray-500">Sender</div>
<div class="text-gray-800">{{ log.sender || '—' }}</div>
<div class="text-gray-500">Profil</div>
<div class="text-gray-800">{{ log.profile?.name || '—' }}</div>
<div class="text-gray-500">Predloga</div>
<div class="text-gray-800">{{ log.template?.slug || log.template?.name || '—' }}</div>
<div class="text-gray-500">Status</div>
<div class="text-gray-800">{{ log.status }}</div>
<div class="text-gray-500">Cena</div>
<div class="text-gray-800">{{ log.cost != null ? (Number(log.cost).toFixed(2) + ' ' + (log.currency || '')) : '—' }}</div>
<div class="text-gray-500">Provider ID</div>
<div class="text-gray-800">{{ log.provider_message_id || '—' }}</div>
<div class="text-gray-500">Čas</div>
<div class="text-gray-800">{{ new Date(log.created_at).toLocaleString() }}</div>
<div class="text-gray-500">Sent</div>
<div class="text-gray-800">{{ log.sent_at ? new Date(log.sent_at).toLocaleString() : '—' }}</div>
<div class="text-gray-500">Delivered</div>
<div class="text-gray-800">{{ log.delivered_at ? new Date(log.delivered_at).toLocaleString() : '—' }}</div>
<div class="text-gray-500">Failed</div>
<div class="text-gray-800">{{ log.failed_at ? new Date(log.failed_at).toLocaleString() : '—' }}</div>
<div class="text-gray-500">Napaka (koda)</div>
<div class="text-gray-800">{{ log.error_code || '—' }}</div>
<div class="text-gray-500">Napaka (opis)</div>
<div class="text-gray-800">{{ log.error_message || '—' }}</div>
</div>
</div>
</div>
</div>
</AdminLayout>
</template>
<style scoped>
.label { display: block; font-size: 0.65rem; font-weight: 600; letter-spacing: 0.05em; text-transform: uppercase; color: #6b7280; margin-bottom: 0.25rem; }
</style>
@@ -0,0 +1,292 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import DialogModal from "@/Components/DialogModal.vue";
import { Head, useForm, router } from "@inertiajs/vue3";
import { ref, watch } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faPlus, faPaperPlane, faCoins, faTags, faFlask } from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
initialProfiles: { type: Array, default: () => [] },
});
const profiles = ref(props.initialProfiles || []);
// Keep local ref in sync with Inertia prop changes (after router navigations)
watch(
() => props.initialProfiles,
(val) => {
profiles.value = val || [];
}
);
// Create profile modal
const createOpen = ref(false);
const createForm = useForm({
name: "",
active: true,
api_username: "",
api_password: "",
});
function openCreate() {
createForm.reset();
createForm.active = true;
createOpen.value = true;
}
async function submitCreate() {
try {
createForm.processing = true;
const payload = {
name: createForm.name,
active: !!createForm.active,
api_username: createForm.api_username,
api_password: createForm.api_password,
};
await router.post(route("admin.sms-profiles.store"), payload, {
preserveScroll: true,
onSuccess: () => {
createOpen.value = false;
},
});
} finally {
createForm.processing = false;
}
}
// Test send modal
const testOpen = ref(false);
const testTarget = ref(null);
const testResult = ref(null);
const testForm = useForm({
to: "",
message: "",
sender_id: null,
delivery_report: true,
country_code: null,
});
function openTest(p) {
testForm.reset();
testTarget.value = p;
testResult.value = null;
testOpen.value = true;
}
async function submitTest() {
if (!testTarget.value) return;
try {
testForm.processing = true;
const payload = {
to: testForm.to,
message: testForm.message,
sender_id: testForm.sender_id,
delivery_report: !!testForm.delivery_report,
country_code: testForm.country_code,
};
await router.post(route("admin.sms-profiles.test-send", testTarget.value.id), payload, {
preserveScroll: true,
onSuccess: () => {
testResult.value = null;
testOpen.value = false;
},
});
} finally {
testForm.processing = false;
}
}
// Balance & Price
const balances = ref({});
const quotes = ref({});
function fetchBalance(p) {
window.axios.post(route("admin.sms-profiles.balance", p.id)).then((r) => {
balances.value[p.id] = r.data.balance;
});
}
function fetchPrice(p) {
window.axios.post(route("admin.sms-profiles.price", p.id)).then((r) => {
quotes.value[p.id] = r.data.quotes || [];
});
}
const formatDateTime = (s) => (s ? new Date(s).toLocaleString() : "—");
</script>
<template>
<AdminLayout title="SMS profili">
<Head title="SMS profili" />
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-semibold text-gray-800 flex items-center gap-3">
SMS profili
<span class="text-xs font-medium text-gray-400">({{ profiles.length }})</span>
</h1>
<button
@click="openCreate"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow"
>
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nov profil
</button>
</div>
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-3 py-2 text-left">Ime</th>
<th class="px-3 py-2 text-left">Uporabnik</th>
<th class="px-3 py-2">Aktiven</th>
<th class="px-3 py-2">Pošiljatelji</th>
<th class="px-3 py-2">Bilanca</th>
<th class="px-3 py-2">Cena</th>
<th class="px-3 py-2">Akcije</th>
</tr>
</thead>
<tbody>
<tr v-for="p in profiles" :key="p.id" class="border-t last:border-b hover:bg-gray-50">
<td class="px-3 py-2 font-medium text-gray-800">{{ p.name }}</td>
<td class="px-3 py-2">{{ p.api_username }}</td>
<td class="px-3 py-2 text-center">
<span :class="p.active ? 'text-emerald-600' : 'text-rose-600'">{{ p.active ? 'Da' : 'Ne' }}</span>
</td>
<td class="px-3 py-2 text-xs text-gray-600">
<span v-if="(p.senders||[]).length === 0"></span>
<span v-else>
{{ p.senders.map(s => s.sname).join(', ') }}
</span>
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<button @click="fetchBalance(p)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-amber-700 border-amber-300 bg-amber-50 hover:bg-amber-100">
<FontAwesomeIcon :icon="faCoins" class="w-3.5 h-3.5" /> Pridobi
</button>
<span class="text-xs text-gray-600">{{ balances[p.id] ?? '—' }}</span>
</div>
</td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<button @click="fetchPrice(p)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-700 border-indigo-300 bg-indigo-50 hover:bg-indigo-100">
<FontAwesomeIcon :icon="faTags" class="w-3.5 h-3.5" /> Cene
</button>
<span class="text-xs text-gray-600 truncate max-w-[200px]" :title="(quotes[p.id]||[]).join(', ')">
{{ (quotes[p.id] || []).join(', ') || '—' }}
</span>
</div>
</td>
<td class="px-3 py-2 flex items-center gap-2">
<button
@click="openTest(p)"
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-emerald-700 border-emerald-300 bg-emerald-50 hover:bg-emerald-100"
title="Pošlji test SMS"
>
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5" /> Test SMS
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Profile Modal -->
<DialogModal :show="createOpen" max-width="2xl" @close="() => (createOpen = false)">
<template #title> Nov SMS profil </template>
<template #content>
<form @submit.prevent="submitCreate" id="create-sms-profile" class="space-y-5">
<div class="grid gap-4 grid-cols-2">
<div>
<label class="label">Ime</label>
<input v-model="createForm.name" type="text" class="input" />
</div>
<div>
<label class="label">Aktivno</label>
<select v-model="createForm.active" class="input">
<option :value="true">Da</option>
<option :value="false">Ne</option>
</select>
</div>
<div>
<label class="label">API uporabnik</label>
<input v-model="createForm.api_username" type="text" class="input" />
</div>
<div>
<label class="label">API geslo</label>
<input v-model="createForm.api_password" type="password" class="input" autocomplete="new-password" />
</div>
</div>
</form>
</template>
<template #footer>
<button type="button" @click="() => (createOpen = false)" class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50">Prekliči</button>
<button form="create-sms-profile" type="submit" :disabled="createForm.processing" class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50">Shrani</button>
</template>
</DialogModal>
<!-- Test Send Modal -->
<DialogModal :show="testOpen" max-width="2xl" @close="() => (testOpen = false)">
<template #title> Testni SMS </template>
<template #content>
<div class="space-y-4">
<div class="grid gap-4 grid-cols-2">
<div>
<label class="label">Prejemnik (E.164)</label>
<input v-model="testForm.to" type="text" class="input" placeholder="+386..." />
</div>
<div>
<label class="label">Državna koda (opcijsko)</label>
<input v-model="testForm.country_code" type="text" class="input" placeholder="SI" />
</div>
<div class="col-span-2">
<label class="label">Sporočilo</label>
<textarea v-model="testForm.message" class="input" rows="4"></textarea>
</div>
<div>
<label class="label">Dostavna poročila</label>
<select v-model="testForm.delivery_report" class="input">
<option :value="true">Da</option>
<option :value="false">Ne</option>
</select>
</div>
</div>
<!-- Result details removed; rely on flash message after redirect -->
</div>
</template>
<template #footer>
<button type="button" @click="() => (testOpen = false)" class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50">Zapri</button>
<button type="button" @click="submitTest" :disabled="testForm.processing || !testTarget" class="px-4 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50">
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5 mr-1" /> Pošlji test
</button>
</template>
</DialogModal>
</AdminLayout>
</template>
<style scoped>
.input {
width: 100%;
border-radius: 0.375rem;
border: 1px solid var(--tw-color-gray-300, #d1d5db);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.input:focus {
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-color: #6366f1;
border-color: #6366f1;
box-shadow: 0 0 0 1px #6366f1;
}
.label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.25rem;
}
</style>
@@ -0,0 +1,199 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import DialogModal from "@/Components/DialogModal.vue";
import { Head, useForm, router } from "@inertiajs/vue3";
import { ref, computed } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faPlus, faPen, faTrash, faToggleOn, faToggleOff } from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
initialSenders: { type: Array, default: () => [] },
profiles: { type: Array, default: () => [] },
});
// Use props directly so Inertia navigations refresh the list automatically
const senders = computed(() => props.initialSenders || []);
const profileById = computed(() => Object.fromEntries((props.profiles || []).map(p => [p.id, p])));
// Create/Edit modal
const editOpen = ref(false);
const editing = ref(null);
const form = useForm({
profile_id: null,
sname: "",
phone_number: "",
description: "",
active: true,
});
function openCreate() {
editing.value = null;
form.reset();
form.active = true;
editOpen.value = true;
}
function openEdit(s) {
editing.value = s;
form.reset();
form.profile_id = s.profile_id;
form.sname = s.sname;
form.phone_number = s.phone_number || "";
form.description = s.description;
form.active = !!s.active;
editOpen.value = true;
}
async function submitEdit() {
try {
form.processing = true;
const payload = {
profile_id: form.profile_id,
sname: (form.sname || "").trim() || null,
phone_number: form.phone_number || null,
description: form.description || null,
active: !!form.active,
};
if (editing.value) {
await router.put(route("admin.sms-senders.update", editing.value.id), payload, {
preserveScroll: true,
});
} else {
await router.post(route("admin.sms-senders.store"), payload, {
preserveScroll: true,
});
}
editOpen.value = false;
} finally {
form.processing = false;
}
}
async function toggleActive(s) {
await router.post(route("admin.sms-senders.toggle", s.id), {}, { preserveScroll: true });
}
async function destroySender(s) {
if (!confirm(`Izbrišem pošiljatelja "${s.sname}"?`)) return;
await router.delete(route("admin.sms-senders.destroy", s.id), { preserveScroll: true });
}
</script>
<template>
<AdminLayout title="SMS pošiljatelji">
<Head title="SMS pošiljatelji" />
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-semibold text-gray-800">SMS pošiljatelji</h1>
<button @click="openCreate" class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow">
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nov pošiljatelj
</button>
</div>
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-3 py-2 text-left">Pošiljatelj</th>
<th class="px-3 py-2 text-left">Številka</th>
<th class="px-3 py-2 text-left">Profil</th>
<th class="px-3 py-2">Aktiven</th>
<th class="px-3 py-2 text-left">Opis</th>
<th class="px-3 py-2">Akcije</th>
</tr>
</thead>
<tbody>
<tr v-for="s in senders" :key="s.id" class="border-t last:border-b hover:bg-gray-50">
<td class="px-3 py-2 font-medium text-gray-800">{{ s.sname }}</td>
<td class="px-3 py-2 text-gray-700">{{ s.phone_number || '—' }}</td>
<td class="px-3 py-2">{{ profileById[s.profile_id]?.name || '—' }}</td>
<td class="px-3 py-2 text-center">
<span :class="s.active ? 'text-emerald-600' : 'text-rose-600'">{{ s.active ? 'Da' : 'Ne' }}</span>
</td>
<td class="px-3 py-2 text-gray-600">{{ s.description || '—' }}</td>
<td class="px-3 py-2">
<div class="flex items-center gap-2">
<button @click="openEdit(s)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-700 border-indigo-300 bg-indigo-50 hover:bg-indigo-100">
<FontAwesomeIcon :icon="faPen" class="w-3.5 h-3.5" /> Uredi
</button>
<button @click="toggleActive(s)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-amber-700 border-amber-300 bg-amber-50 hover:bg-amber-100">
<FontAwesomeIcon :icon="s.active ? faToggleOn : faToggleOff" class="w-3.5 h-3.5" />
</button>
<button @click="destroySender(s)" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-rose-700 border-rose-300 bg-rose-50 hover:bg-rose-100">
<FontAwesomeIcon :icon="faTrash" class="w-3.5 h-3.5" /> Izbriši
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<DialogModal :show="editOpen" max-width="2xl" @close="() => (editOpen = false)">
<template #title> {{ editing ? 'Uredi pošiljatelja' : 'Nov pošiljatelj' }} </template>
<template #content>
<form @submit.prevent="submitEdit" id="edit-sms-sender" class="space-y-5">
<div class="grid gap-4 grid-cols-2">
<div>
<label class="label">Profil</label>
<select v-model="form.profile_id" class="input">
<option :value="null" disabled>Izberi profil</option>
<option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="label">Pošiljatelj (Sender ID) opcijsko</label>
<input v-model="form.sname" type="text" class="input" placeholder="npr. TEREN" />
</div>
<div>
<label class="label">Številka pošiljatelja (opcijsko)</label>
<input v-model="form.phone_number" type="text" class="input" placeholder="npr. +38640123456" />
</div>
<div class="col-span-2">
<label class="label">Opis (opcijsko)</label>
<input v-model="form.description" type="text" class="input" />
</div>
<div>
<label class="label">Aktivno</label>
<select v-model="form.active" class="input">
<option :value="true">Da</option>
<option :value="false">Ne</option>
</select>
</div>
</div>
</form>
</template>
<template #footer>
<button type="button" @click="() => (editOpen = false)" class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50">Prekliči</button>
<button form="edit-sms-sender" type="submit" :disabled="form.processing" class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50">Shrani</button>
</template>
</DialogModal>
</AdminLayout>
</template>
<style scoped>
.input {
width: 100%;
border-radius: 0.375rem;
border: 1px solid var(--tw-color-gray-300, #d1d5db);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.input:focus {
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-color: #6366f1;
border-color: #6366f1;
box-shadow: 0 0 0 1px #6366f1;
}
.label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.25rem;
}
</style>
@@ -0,0 +1,263 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import { Head, Link, useForm, router } from "@inertiajs/vue3";
import { ref, computed, watch } from "vue";
const props = defineProps({
template: { type: Object, default: null },
profiles: { type: Array, default: () => [] },
senders: { type: Array, default: () => [] },
actions: { type: Array, default: () => [] },
});
const form = useForm({
name: props.template?.name ?? "",
slug: props.template?.slug ?? "",
content: props.template?.content ?? "",
variables_json: props.template?.variables_json ?? {},
is_active: props.template?.is_active ?? true,
default_profile_id: props.template?.default_profile_id ?? null,
default_sender_id: props.template?.default_sender_id ?? null,
allow_custom_body: props.template?.allow_custom_body ?? false,
action_id: props.template?.action_id ?? null,
decision_id: props.template?.decision_id ?? null,
});
const sendersByProfile = computed(() => {
const map = {};
(props.senders || []).forEach((s) => {
if (!map[s.profile_id]) map[s.profile_id] = [];
map[s.profile_id].push(s);
});
return map;
});
function currentSendersForProfile(pid) {
if (!pid) return [];
return (sendersByProfile.value[pid] || []).filter((s) => s.active);
}
function submit() {
if (props.template?.id) {
form.put(route("admin.sms-templates.update", props.template.id));
} else {
form.post(route("admin.sms-templates.store"), {
onSuccess: (page) => {
// If backend redirected, nothing to do. If returned JSON, navigate to edit.
try {
const id = page?.props?.template?.id; // in case of redirect props
if (id) {
router.visit(route("admin.sms-templates.edit", id));
}
} catch {}
},
onFinish: () => {},
preserveScroll: true,
});
}
}
// Test send panel
const testForm = useForm({
to: "",
variables: {},
profile_id: props.template?.default_profile_id ?? null,
sender_id: props.template?.default_sender_id ?? null,
country_code: null,
delivery_report: true,
custom_content: "",
});
const testResult = ref(null);
watch(
() => testForm.profile_id,
() => {
// reset sender when profile changes
testForm.sender_id = null;
}
);
async function submitTest() {
if (!props.template?.id) {
alert("Najprej shranite predlogo.");
return;
}
const payload = {
to: testForm.to,
variables: testForm.variables || {},
profile_id: testForm.profile_id || null,
sender_id: testForm.sender_id || null,
country_code: testForm.country_code || null,
delivery_report: !!testForm.delivery_report,
custom_content: testForm.custom_content || null,
};
await router.post(route("admin.sms-templates.send-test", props.template.id), payload, {
preserveScroll: true,
onSuccess: () => {
testResult.value = null;
},
});
}
</script>
<template>
<AdminLayout :title="props.template ? 'Uredi SMS predlogo' : 'Nova SMS predloga'">
<Head :title="props.template ? 'Uredi SMS predlogo' : 'Nova SMS predloga'" />
<div class="mb-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<Link :href="route('admin.sms-templates.index')" class="text-sm text-indigo-600 hover:underline"> Nazaj na seznam</Link>
<div class="text-gray-700 text-sm" v-if="props.template">#{{ props.template.id }}</div>
</div>
<button
type="button"
@click="submit"
:disabled="form.processing"
class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50"
>
Shrani
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Form -->
<div class="lg:col-span-2 rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">Ime</label>
<input v-model="form.name" type="text" class="input" />
</div>
<div>
<label class="label">Slug</label>
<input v-model="form.slug" type="text" class="input" placeholder="npr. payment_reminder" />
</div>
<div class="md:col-span-2">
<label class="label">Vsebina</label>
<textarea v-model="form.content" rows="8" class="input" placeholder="Pozdravljen {{ person.first_name }}, ..."></textarea>
<div class="text-[11px] text-gray-500 mt-1">
Uporabite placeholderje npr. <code>{first_name}</code> ali
<code v-pre>{{ person.first_name }}</code> ob pošiljanju se vrednosti nadomestijo.
</div>
</div>
<div class="md:col-span-2">
<label class="label">Dovoli lastno besedilo</label>
<select v-model="form.allow_custom_body" class="input">
<option :value="true">Da</option>
<option :value="false">Ne</option>
</select>
<div class="text-[11px] text-gray-500 mt-1">Če je omogočeno, lahko pošiljatelj namesto vsebine predloge napiše poljubno besedilo.</div>
</div>
<div>
<label class="label">Privzet profil</label>
<select v-model="form.default_profile_id" class="input">
<option :value="null"></option>
<option v-for="p in props.profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="label">Privzet sender</label>
<select v-model="form.default_sender_id" class="input" :disabled="!form.default_profile_id">
<option :value="null"></option>
<option v-for="s in currentSendersForProfile(form.default_profile_id)" :key="s.id" :value="s.id">
{{ s.sname }}
</option>
</select>
</div>
<div>
<label class="label">Aktivno</label>
<select v-model="form.is_active" class="input">
<option :value="true">Da</option>
<option :value="false">Ne</option>
</select>
</div>
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">Akcija po pošiljanju</label>
<select v-model="form.action_id" class="input">
<option :value="null">(brez)</option>
<option v-for="a in props.actions" :key="a.id" :value="a.id">{{ a.name }}</option>
</select>
</div>
<div>
<label class="label">Odločitev</label>
<select v-model="form.decision_id" class="input" :disabled="!form.action_id">
<option :value="null">(brez)</option>
<option v-for="d in (props.actions.find(x => x.id === form.action_id)?.decisions || [])" :key="d.id" :value="d.id">{{ d.name }}</option>
</select>
</div>
</div>
</div>
</div>
<!-- Test send -->
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-4">
<div class="font-semibold text-gray-800">Testno pošiljanje</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="label">Prejemnik (E.164)</label>
<input v-model="testForm.to" type="text" class="input" placeholder="+386..." />
</div>
<div>
<label class="label">Državna koda</label>
<input v-model="testForm.country_code" type="text" class="input" placeholder="SI" />
</div>
<div>
<label class="label">Profil</label>
<select v-model="testForm.profile_id" class="input">
<option :value="null">(privzeti)</option>
<option v-for="p in props.profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="label">Sender</label>
<select v-model="testForm.sender_id" class="input" :disabled="!testForm.profile_id">
<option :value="null">(privzeti)</option>
<option v-for="s in currentSendersForProfile(testForm.profile_id)" :key="s.id" :value="s.id">{{ s.sname }}</option>
</select>
</div>
<div>
<label class="label">Dostavna poročila</label>
<select v-model="testForm.delivery_report" class="input">
<option :value="true">Da</option>
<option :value="false">Ne</option>
</select>
</div>
<div v-if="form.allow_custom_body" class="md:col-span-2">
<label class="label">Lastno besedilo (opcijsko)</label>
<textarea v-model="testForm.custom_content" rows="4" class="input" placeholder="Če je izpolnjeno, bo namesto predloge poslano to besedilo."></textarea>
</div>
</div>
<div class="flex items-center justify-end gap-2">
<button type="button" @click="submitTest" :disabled="testForm.processing || !props.template" class="px-3 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50">Pošlji test</button>
</div>
<!-- Result details removed in favor of flash messages after redirect -->
</div>
</div>
</AdminLayout>
</template>
<style scoped>
.input {
width: 100%;
border-radius: 0.375rem;
border: 1px solid #d1d5db;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.input:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: #6366f1;
box-shadow: 0 0 0 1px #6366f1;
}
.label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.25rem;
}
</style>
@@ -0,0 +1,332 @@
<script setup>
import AdminLayout from "@/Layouts/AdminLayout.vue";
import DialogModal from "@/Components/DialogModal.vue";
import { Head, useForm, Link, router } from "@inertiajs/vue3";
import { computed, ref } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faPlus, faPen, faTrash, faPaperPlane } from "@fortawesome/free-solid-svg-icons";
const props = defineProps({
initialTemplates: { type: Array, default: () => [] },
profiles: { type: Array, default: () => [] },
senders: { type: Array, default: () => [] },
});
const templates = computed(() => props.initialTemplates || []);
const profilesById = computed(() =>
Object.fromEntries((props.profiles || []).map((p) => [p.id, p]))
);
const sendersById = computed(() =>
Object.fromEntries((props.senders || []).map((s) => [s.id, s]))
);
const sendersByProfile = computed(() => {
const map = {};
(props.senders || []).forEach((s) => {
if (!map[s.profile_id]) map[s.profile_id] = [];
map[s.profile_id].push(s);
});
return map;
});
// No manual reload; Inertia visits will refresh props
// Create/Edit modal
const editOpen = ref(false);
const editing = ref(null);
const form = useForm({
name: "",
slug: "",
content: "",
variables_json: {},
is_active: true,
default_profile_id: null,
default_sender_id: null,
});
function openCreate() {
editing.value = null;
form.reset();
form.is_active = true;
editOpen.value = true;
}
function openEdit(t) {
editing.value = t;
form.reset();
form.name = t.name;
form.slug = t.slug;
form.content = t.content;
form.variables_json = t.variables_json || {};
form.is_active = !!t.is_active;
form.default_profile_id = t.default_profile_id || null;
form.default_sender_id = t.default_sender_id || null;
editOpen.value = true;
}
async function submitEdit() {
try {
form.processing = true;
const payload = {
name: form.name,
slug: form.slug,
content: form.content,
variables_json: form.variables_json || {},
is_active: !!form.is_active,
default_profile_id: form.default_profile_id || null,
default_sender_id: form.default_sender_id || null,
};
if (editing.value) {
await router.put(route("admin.sms-templates.update", editing.value.id), payload, {
preserveScroll: true,
});
} else {
await router.post(route("admin.sms-templates.store"), payload, {
preserveScroll: true,
});
}
editOpen.value = false;
} finally {
form.processing = false;
}
}
async function destroyTemplate(t) {
if (!confirm(`Izbrišem SMS predlogo "${t.name}"?`)) return;
await router.delete(route("admin.sms-templates.destroy", t.id), {
preserveScroll: true,
});
}
// Test send modal
const testOpen = ref(false);
const testTarget = ref(null);
const testForm = useForm({
to: "",
variables: {},
profile_id: null,
sender_id: null,
country_code: null,
delivery_report: true,
});
const testResult = ref(null);
function openTest(t) {
testTarget.value = t;
testForm.reset();
testForm.delivery_report = true;
testForm.profile_id = t.default_profile_id || null;
testForm.sender_id = t.default_sender_id || null;
testResult.value = null;
testOpen.value = true;
}
async function submitTest() {
if (!testTarget.value) return;
const payload = {
to: testForm.to,
variables: testForm.variables || {},
profile_id: testForm.profile_id || null,
sender_id: testForm.sender_id || null,
country_code: testForm.country_code || null,
delivery_report: !!testForm.delivery_report,
};
await router.post(
route("admin.sms-templates.send-test", testTarget.value.id),
payload,
{
preserveScroll: true,
onSuccess: () => {
testOpen.value = false;
testResult.value = null;
},
}
);
}
function currentSenders() {
const pid = form.default_profile_id;
if (!pid) return [];
return (sendersByProfile.value[pid] || []).filter((s) => s.active);
}
function currentSendersForTest() {
const pid = testForm.profile_id;
if (!pid) return [];
return (sendersByProfile.value[pid] || []).filter((s) => s.active);
}
</script>
<template>
<AdminLayout title="SMS predloge">
<Head title="SMS predloge" />
<div class="flex items-center justify-between mb-6">
<h1 class="text-xl font-semibold text-gray-800">SMS predloge</h1>
<Link
:href="route('admin.sms-templates.create')"
class="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-500 shadow"
>
<FontAwesomeIcon :icon="faPlus" class="w-4 h-4" /> Nova predloga
</Link>
</div>
<div class="rounded-lg border bg-white overflow-hidden shadow-sm">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase tracking-wider">
<tr>
<th class="px-3 py-2 text-left">Ime</th>
<th class="px-3 py-2 text-left">Slug</th>
<th class="px-3 py-2 text-left">Privzet profil/sender</th>
<th class="px-3 py-2">Aktivno</th>
<th class="px-3 py-2">Akcije</th>
</tr>
</thead>
<tbody>
<tr
v-for="t in templates"
:key="t.id"
class="border-t last:border-b hover:bg-gray-50"
>
<td class="px-3 py-2 font-medium text-gray-800">{{ t.name }}</td>
<td class="px-3 py-2 text-gray-600">{{ t.slug }}</td>
<td class="px-3 py-2 text-gray-600">
<span>{{ profilesById[t.default_profile_id]?.name || "—" }}</span>
<span v-if="t.default_sender_id">
/ {{ sendersById[t.default_sender_id]?.sname }}</span
>
</td>
<td class="px-3 py-2 text-center">
<span :class="t.is_active ? 'text-emerald-600' : 'text-rose-600'">{{
t.is_active ? "Da" : "Ne"
}}</span>
</td>
<td class="px-3 py-2 flex items-center gap-2">
<Link
:href="route('admin.sms-templates.edit', t.id)"
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-indigo-700 border-indigo-300 bg-indigo-50 hover:bg-indigo-100"
>
<FontAwesomeIcon :icon="faPen" class="w-3.5 h-3.5" /> Uredi
</Link>
<button
@click="openTest(t)"
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-emerald-700 border-emerald-300 bg-emerald-50 hover:bg-emerald-100"
>
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5" /> Test
</button>
<button
@click="destroyTemplate(t)"
class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border text-rose-700 border-rose-300 bg-rose-50 hover:bg-rose-100"
>
<FontAwesomeIcon :icon="faTrash" class="w-3.5 h-3.5" /> Izbriši
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Edit/Create now handled on dedicated page -->
<!-- Test Send Modal -->
<DialogModal :show="testOpen" max-width="2xl" @close="() => (testOpen = false)">
<template #title> Testno pošiljanje </template>
<template #content>
<div class="space-y-4">
<div class="grid gap-4 grid-cols-2">
<div>
<label class="label">Prejemnik (E.164)</label>
<input
v-model="testForm.to"
type="text"
class="input"
placeholder="+386..."
/>
</div>
<div>
<label class="label">Državna koda</label>
<input
v-model="testForm.country_code"
type="text"
class="input"
placeholder="SI"
/>
</div>
<div>
<label class="label">Profil</label>
<select v-model="testForm.profile_id" class="input">
<option :value="null">(privzeti)</option>
<option v-for="p in props.profiles" :key="p.id" :value="p.id">
{{ p.name }}
</option>
</select>
</div>
<div>
<label class="label">Sender</label>
<select
v-model="testForm.sender_id"
class="input"
:disabled="!testForm.profile_id"
>
<option :value="null">(privzeti)</option>
<option v-for="s in currentSendersForTest()" :key="s.id" :value="s.id">
{{ s.sname }}
</option>
</select>
</div>
<div>
<label class="label">Dostavna poročila</label>
<select v-model="testForm.delivery_report" class="input">
<option :value="true">Da</option>
<option :value="false">Ne</option>
</select>
</div>
</div>
<!-- Result details removed in favor of flash messages after redirect -->
</div>
</template>
<template #footer>
<button
type="button"
@click="() => (testOpen = false)"
class="px-4 py-2 text-sm rounded-md border bg-white hover:bg-gray-50"
>
Zapri
</button>
<button
type="button"
@click="submitTest"
:disabled="testForm.processing || !testTarget"
class="px-4 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50"
>
<FontAwesomeIcon :icon="faPaperPlane" class="w-3.5 h-3.5 mr-1" /> Pošlji test
</button>
</template>
</DialogModal>
</AdminLayout>
</template>
<style scoped>
.input {
width: 100%;
border-radius: 0.375rem;
border: 1px solid var(--tw-color-gray-300, #d1d5db);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
line-height: 1.25rem;
}
.input:focus {
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-color: #6366f1;
border-color: #6366f1;
box-shadow: 0 0 0 1px #6366f1;
}
.label {
display: block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 0.25rem;
}
</style>
+2
View File
@@ -274,6 +274,8 @@ const submitAttachSegment = () => {
:types="types"
tab-color="red-600"
:person="client_case.person"
:enable-sms="true"
:client-case-uuid="client_case.uuid"
/>
</div>
</div>
+58
View File
@@ -22,6 +22,7 @@ const props = defineProps({
fieldJobsAssignedToday: Array,
importsInProgress: Array,
activeTemplates: Array,
smsStats: Array,
});
const kpiDefs = [
@@ -318,6 +319,63 @@ function safeCaseHref(uuid, segment = null) {
<!-- Right side panels -->
<div class="lg:col-span-2 space-y-8">
<!-- SMS Overview -->
<div
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
>
<h3
class="text-sm font-semibold tracking-wide text-gray-700 dark:text-gray-200 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 dark:bg-gray-900/30 text-gray-600 dark:text-gray-300 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>
<th class="px-3 py-2 text-left">Danes (skupaj)</th>
<th class="px-3 py-2 text-left">Sent</th>
<th class="px-3 py-2 text-left">Delivered</th>
<th class="px-3 py-2 text-left">Failed</th>
</tr>
</thead>
<tbody>
<tr
v-for="p in props.smsStats"
:key="p.id"
class="border-t last:border-b dark:border-gray-700"
>
<td class="px-3 py-2">
<span class="font-medium text-gray-900 dark:text-gray-100">{{
p.name
}}</span>
<span
class="ml-2 text-[11px]"
:class="p.active ? 'text-emerald-600' : 'text-gray-400'"
>{{ p.active ? "Aktiven" : "Neaktiven" }}</span
>
</td>
<td class="px-3 py-2 text-gray-700 dark:text-gray-300">
{{ p.balance ?? "—" }}
</td>
<td class="px-3 py-2">{{ p.today?.total ?? 0 }}</td>
<td class="px-3 py-2 text-sky-700">{{ p.today?.sent ?? 0 }}</td>
<td class="px-3 py-2 text-emerald-700">
{{ p.today?.delivered ?? 0 }}
</td>
<td class="px-3 py-2 text-rose-700">{{ p.today?.failed ?? 0 }}</td>
</tr>
</tbody>
</table>
</div>
<div v-else class="text-sm text-gray-500 dark:text-gray-400">
Ni podatkov o SMS.
</div>
</div>
<!-- System Health -->
<div
class="bg-white dark:bg-gray-800 border dark:border-gray-700 rounded-xl shadow-sm p-6"
+5 -4
View File
@@ -44,8 +44,9 @@ const clientOptions = computed(() => {
? props.clients
: (Array.isArray(props.activities?.data) ? props.activities.data : [])
.map((row) => {
const cc = row.contract?.client_case || row.client_case;
return cc ? { value: cc.uuid, label: cc.person?.full_name || "(neznana stranka)" } : null;
const client = row.contract?.client_case?.client || row.client_case?.client;
if (!client?.uuid) return null;
return { value: client.uuid, label: client.person?.full_name || "(neznana stranka)" };
})
.filter(Boolean)
.reduce((acc, cur) => {
@@ -174,8 +175,8 @@ async function markRead(id) {
<template #cell-partner="{ row }">
<div class="truncate">
{{
(row.contract?.client_case?.person?.full_name) ||
(row.client_case?.person?.full_name) ||
(row.contract?.client_case?.client?.person?.full_name) ||
(row.client_case?.client?.person?.full_name) ||
'—'
}}
</div>