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
@@ -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>