Teren-app/resources/js/Pages/Admin/Packages/Index.vue
2025-11-06 21:54:07 +01:00

419 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue'
import { Link, router, useForm } from '@inertiajs/vue3'
import { ref, computed } from 'vue'
const props = defineProps({
packages: { type: Object, required: true },
profiles: { type: Array, default: () => [] },
senders: { type: Array, default: () => [] },
templates: { type: Array, default: () => [] },
segments: { type: Array, default: () => [] },
clients: { type: Array, default: () => [] },
})
function goShow(id) {
router.visit(route('admin.packages.show', id))
}
const showCreate = ref(false)
const createMode = ref('numbers') // 'numbers' | 'contracts'
const form = useForm({
type: 'sms',
name: '',
description: '',
profile_id: null,
sender_id: null,
template_id: null,
delivery_report: false,
body: '',
numbers: '', // one per line
})
const filteredSenders = computed(() => {
if (!form.profile_id) return props.senders
return props.senders.filter(s => s.profile_id === form.profile_id)
})
function onTemplateChange() {
const template = props.templates.find(t => t.id === form.template_id)
if (template?.content) {
form.body = template.content
} else {
form.body = ''
}
}
function submitCreate() {
const lines = (form.numbers || '').split(/\r?\n/).map(s => s.trim()).filter(Boolean)
if (!lines.length) return
if (!form.profile_id && !form.template_id) {
// require profile if no template/default profile resolution available
alert('Izberi SMS profil ali predlogo.')
return
}
if (!form.template_id && !form.body) {
alert('Vnesi vsebino sporočila ali izberi predlogo.')
return
}
const payload = {
type: 'sms',
name: form.name || `SMS paket ${new Date().toLocaleString()}`,
description: form.description || '',
items: lines.map(number => ({
number,
payload: {
profile_id: form.profile_id,
sender_id: form.sender_id,
template_id: form.template_id,
delivery_report: !!form.delivery_report,
body: (form.body && form.body.trim()) ? form.body.trim() : null,
},
})),
}
router.post(route('admin.packages.store'), payload, {
onSuccess: () => {
form.reset()
showCreate.value = false
router.reload({ only: ['packages'] })
},
})
}
// Contracts mode state & actions
const contracts = ref({ data: [], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } })
const segmentId = ref(null)
const search = ref('')
const clientId = ref(null)
const startDateFrom = ref('')
const startDateTo = ref('')
const onlyMobile = ref(false)
const onlyValidated = ref(false)
const loadingContracts = ref(false)
const selectedContractIds = ref(new Set())
async function loadContracts(url = null) {
if (!segmentId.value) {
contracts.value = { data: [], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } }
return
}
loadingContracts.value = true
try {
const target = url || `${route('admin.packages.contracts')}?segment_id=${encodeURIComponent(segmentId.value)}${search.value ? `&q=${encodeURIComponent(search.value)}` : ''}${clientId.value ? `&client_id=${encodeURIComponent(clientId.value)}` : ''}${startDateFrom.value ? `&start_date_from=${encodeURIComponent(startDateFrom.value)}` : ''}${startDateTo.value ? `&start_date_to=${encodeURIComponent(startDateTo.value)}` : ''}${onlyMobile.value ? `&only_mobile=1` : ''}${onlyValidated.value ? `&only_validated=1` : ''}`
const res = await fetch(target, { headers: { 'X-Requested-With': 'XMLHttpRequest' } })
const json = await res.json()
contracts.value = { data: json.data || [], meta: json.meta || { current_page: 1, last_page: 1, per_page: 25, total: 0 } }
} finally {
loadingContracts.value = false
}
}
function toggleSelectContract(id) {
const s = selectedContractIds.value
if (s.has(id)) { s.delete(id) } else { s.add(id) }
// force reactivity
selectedContractIds.value = new Set(Array.from(s))
}
function clearSelection() {
selectedContractIds.value = new Set()
}
function toggleSelectAll() {
const currentPageIds = contracts.value.data.map(c => c.id)
const allSelected = currentPageIds.every(id => selectedContractIds.value.has(id))
if (allSelected) {
// Deselect all on current page
currentPageIds.forEach(id => selectedContractIds.value.delete(id))
} else {
// Select all on current page
currentPageIds.forEach(id => selectedContractIds.value.add(id))
}
// Force reactivity
selectedContractIds.value = new Set(Array.from(selectedContractIds.value))
}
const allCurrentPageSelected = computed(() => {
if (!contracts.value.data.length) return false
return contracts.value.data.every(c => selectedContractIds.value.has(c.id))
})
const someCurrentPageSelected = computed(() => {
if (!contracts.value.data.length) return false
return contracts.value.data.some(c => selectedContractIds.value.has(c.id)) && !allCurrentPageSelected.value
})
function goContractsPage(delta) {
const { current_page } = contracts.value.meta
const nextPage = current_page + delta
if (nextPage < 1 || nextPage > contracts.value.meta.last_page) return
const base = `${route('admin.packages.contracts')}?segment_id=${encodeURIComponent(segmentId.value)}${search.value ? `&q=${encodeURIComponent(search.value)}` : ''}${clientId.value ? `&client_id=${encodeURIComponent(clientId.value)}` : ''}${startDateFrom.value ? `&start_date_from=${encodeURIComponent(startDateFrom.value)}` : ''}${startDateTo.value ? `&start_date_to=${encodeURIComponent(startDateTo.value)}` : ''}${onlyMobile.value ? `&only_mobile=1` : ''}${onlyValidated.value ? `&only_validated=1` : ''}&page=${nextPage}`
loadContracts(base)
}
function submitCreateFromContracts() {
const ids = Array.from(selectedContractIds.value)
if (!ids.length) return
const payload = {
type: 'sms',
name: form.name || `SMS paket (segment) ${new Date().toLocaleString()}`,
description: form.description || '',
payload: {
profile_id: form.profile_id,
sender_id: form.sender_id,
template_id: form.template_id,
delivery_report: !!form.delivery_report,
body: (form.body && form.body.trim()) ? form.body.trim() : null,
},
contract_ids: ids,
}
router.post(route('admin.packages.store-from-contracts'), payload, {
onSuccess: () => {
clearSelection()
showCreate.value = false
router.reload({ only: ['packages'] })
},
})
}
</script>
<template>
<AdminLayout title="SMS paketi">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-800">Seznam paketov</h2>
<button @click="showCreate = !showCreate" class="px-3 py-1.5 rounded bg-indigo-600 text-white text-sm">{{ showCreate ? 'Zapri' : 'Nov paket' }}</button>
</div>
<div v-if="showCreate" class="mb-6 rounded border bg-white p-4">
<div class="mb-4 flex items-center gap-4 text-sm">
<label class="inline-flex items-center gap-2">
<input type="radio" value="numbers" v-model="createMode"> Vnos številk
</label>
<label class="inline-flex items-center gap-2">
<input type="radio" value="contracts" v-model="createMode"> Iz pogodb (segment)
</label>
</div>
<div class="grid sm:grid-cols-3 gap-4">
<div>
<label class="block text-xs text-gray-500 mb-1">Profil</label>
<select v-model.number="form.profile_id" class="w-full rounded border-gray-300 text-sm">
<option :value="null">—</option>
<option v-for="p in profiles" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Pošiljatelj</label>
<select v-model.number="form.sender_id" class="w-full rounded border-gray-300 text-sm">
<option :value="null">—</option>
<option v-for="s in filteredSenders" :key="s.id" :value="s.id">{{ s.sname }} <span v-if="s.phone_number">({{ s.phone_number }})</span></option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Predloga</label>
<select v-model.number="form.template_id" @change="onTemplateChange" class="w-full rounded border-gray-300 text-sm">
<option :value="null">—</option>
<option v-for="t in templates" :key="t.id" :value="t.id">{{ t.name }}</option>
</select>
</div>
<div class="sm:col-span-3">
<label class="block text-xs text-gray-500 mb-1">Vsebina (če ni predloge)</label>
<textarea v-model="form.body" rows="3" class="w-full rounded border-gray-300 text-sm" placeholder="Sporočilo..."></textarea>
<label class="inline-flex items-center gap-2 mt-2 text-sm text-gray-600">
<input type="checkbox" v-model="form.delivery_report" /> Zahtevaj delivery report
</label>
</div>
<!-- Numbers mode -->
<template v-if="createMode === 'numbers'">
<div class="sm:col-span-3">
<label class="block text-xs text-gray-500 mb-1">Telefonske številke (ena na vrstico)</label>
<textarea v-model="form.numbers" rows="4" class="w-full rounded border-gray-300 text-sm" placeholder="+38640123456
+38640123457"></textarea>
</div>
<div class="sm:col-span-3 flex items-center justify-end gap-2">
<button @click="submitCreate" class="px-3 py-1.5 rounded bg-emerald-600 text-white text-sm">Ustvari paket</button>
</div>
</template>
<!-- Contracts mode -->
<template v-else>
<div>
<label class="block text-xs text-gray-500 mb-1">Segment</label>
<select v-model.number="segmentId" @change="loadContracts()" class="w-full rounded border-gray-300 text-sm">
<option :value="null">—</option>
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Stranka</label>
<select v-model.number="clientId" @change="loadContracts()" class="w-full rounded border-gray-300 text-sm">
<option :value="null"></option>
<option v-for="c in clients" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Iskanje</label>
<input v-model="search" @keyup.enter="loadContracts()" type="text" class="w-full rounded border-gray-300 text-sm" placeholder="referenca...">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Datum začetka od</label>
<input v-model="startDateFrom" @change="loadContracts()" type="date" class="w-full rounded border-gray-300 text-sm">
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Datum začetka do</label>
<input v-model="startDateTo" @change="loadContracts()" type="date" class="w-full rounded border-gray-300 text-sm">
</div>
<div class="flex items-end">
<button @click="loadContracts()" class="px-3 py-1.5 rounded border text-sm h-fit">Išči</button>
</div>
<div class="sm:col-span-3 flex items-center gap-6 text-sm text-gray-700">
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="onlyMobile" @change="loadContracts()"> Samo s mobilno številko
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="onlyValidated" @change="loadContracts()"> Telefonska številka mora biti potrjena
</label>
</div>
<div class="sm:col-span-3">
<div class="overflow-hidden rounded border bg-white">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr class="text-xs text-gray-500">
<th class="px-3 py-2">
<input
type="checkbox"
:checked="allCurrentPageSelected"
:indeterminate="someCurrentPageSelected"
@change="toggleSelectAll"
:disabled="!contracts.data.length"
class="rounded"
title="Izberi vse na tej strani"
>
</th>
<th class="px-3 py-2 text-left">Pogodba</th>
<th class="px-3 py-2 text-left">Primer</th>
<th class="px-3 py-2 text-left">Stranka</th>
<th class="px-3 py-2 text-left">Datum začetka</th>
<th class="px-3 py-2 text-left">Izbrana številka</th>
<th class="px-3 py-2 text-left">Opomba</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200" v-if="!loadingContracts">
<tr v-for="c in contracts.data" :key="c.id" class="text-sm">
<td class="px-3 py-2">
<input type="checkbox" :checked="selectedContractIds.has(c.id)" @change="toggleSelectContract(c.id)">
</td>
<td class="px-3 py-2">
<div class="font-mono text-xs text-gray-600">{{ c.uuid }}</div>
<div class="text-xs text-gray-800">{{ c.reference }}</div>
</td>
<td class="px-3 py-2">
<div class="text-xs text-gray-800">{{ c.person?.full_name || '—' }}</div>
</td>
<td class="px-3 py-2">
<div class="text-xs text-gray-800">{{ c.client?.name || '—' }}</div>
</td>
<td class="px-3 py-2">
<div class="text-xs text-gray-700">{{ c.start_date ? new Date(c.start_date).toLocaleDateString('sl-SI') : '—' }}</div>
</td>
<td class="px-3 py-2">
<div v-if="c.selected_phone" class="text-xs">
{{ c.selected_phone.number }}
<span class="ml-2 inline-flex items-center px-1.5 py-0.5 rounded bg-gray-100 text-gray-600 text-[10px]">{{ c.selected_phone.validated ? 'validated' : 'unverified' }} / {{ c.selected_phone.type || 'unknown' }}</span>
</div>
<div v-else class="text-xs text-gray-500">—</div>
</td>
<td class="px-3 py-2 text-xs text-gray-500">{{ c.no_phone_reason || '—' }}</td>
</tr>
<tr v-if="!contracts.data?.length">
<td colspan="7" class="px-3 py-8 text-center text-sm text-gray-500">Ni rezultatov.</td>
</tr>
</tbody>
<tbody v-else>
<tr><td colspan="7" class="px-3 py-6 text-center text-sm text-gray-500">Nalaganje...</td></tr>
</tbody>
</table>
</div>
<div class="mt-3 flex items-center justify-between text-sm">
<div class="text-gray-600">
Prikazano stran {{ contracts.meta.current_page }} od {{ contracts.meta.last_page }} (skupaj {{ contracts.meta.total }})
</div>
<div class="space-x-2">
<button @click="goContractsPage(-1)" :disabled="contracts.meta.current_page <= 1" class="px-3 py-1.5 rounded border text-sm disabled:opacity-50">Nazaj</button>
<button @click="goContractsPage(1)" :disabled="contracts.meta.current_page >= contracts.meta.last_page" class="px-3 py-1.5 rounded border text-sm disabled:opacity-50">Naprej</button>
</div>
</div>
</div>
<div class="sm:col-span-3 flex items-center justify-end gap-2">
<div class="text-sm text-gray-600 mr-auto">Izbrano: {{ selectedContractIds.size }}</div>
<button @click="submitCreateFromContracts" :disabled="selectedContractIds.size === 0" class="px-3 py-1.5 rounded bg-emerald-600 text-white text-sm disabled:opacity-50">Ustvari paket</button>
</div>
</template>
</div>
</div>
<div class="overflow-hidden rounded-md border bg-white">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr class="text-xs text-gray-500">
<th class="px-3 py-2 text-left font-medium">ID</th>
<th class="px-3 py-2 text-left font-medium">UUID</th>
<th class="px-3 py-2 text-left font-medium">Ime</th>
<th class="px-3 py-2 text-left font-medium">Tip</th>
<th class="px-3 py-2 text-left font-medium">Status</th>
<th class="px-3 py-2 text-left font-medium">Skupaj</th>
<th class="px-3 py-2 text-left font-medium">Poslano</th>
<th class="px-3 py-2 text-left font-medium">Neuspešno</th>
<th class="px-3 py-2 text-left font-medium">Zaključeno</th>
<th class="px-3 py-2"></th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="p in packages.data" :key="p.id" class="text-sm">
<td class="px-3 py-2">{{ p.id }}</td>
<td class="px-3 py-2 font-mono text-xs text-gray-500">{{ p.uuid }}</td>
<td class="px-3 py-2">{{ p.name ?? '—' }}</td>
<td class="px-3 py-2 uppercase text-xs text-gray-600">{{ p.type }}</td>
<td class="px-3 py-2">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs"
:class="{
'bg-yellow-50 text-yellow-700': ['queued','running'].includes(p.status),
'bg-emerald-50 text-emerald-700': p.status === 'completed',
'bg-rose-50 text-rose-700': p.status === 'failed',
'bg-gray-100 text-gray-600': p.status === 'draft',
}"
>{{ p.status }}</span>
</td>
<td class="px-3 py-2">{{ p.total_items }}</td>
<td class="px-3 py-2">{{ p.sent_count }}</td>
<td class="px-3 py-2">{{ p.failed_count }}</td>
<td class="px-3 py-2 text-xs text-gray-500">{{ p.finished_at ?? '—' }}</td>
<td class="px-3 py-2 text-right">
<button @click="goShow(p.id)" class="text-indigo-600 hover:underline text-sm">Odpri</button>
</td>
</tr>
<tr v-if="!packages.data?.length">
<td colspan="10" class="px-3 py-8 text-center text-sm text-gray-500">Ni paketov za prikaz.</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-4 flex items-center justify-between text-sm">
<div class="text-gray-600">
Prikazano {{ packages.from || 0 }}{{ packages.to || 0 }} od {{ packages.total || 0 }}
</div>
<div class="space-x-2">
<Link :href="packages.prev_page_url || '#'" :class="['px-3 py-1.5 rounded border', packages.prev_page_url ? 'text-gray-700 hover:bg-gray-50' : 'text-gray-400 cursor-not-allowed']">Nazaj</Link>
<Link :href="packages.next_page_url || '#'" :class="['px-3 py-1.5 rounded border', packages.next_page_url ? 'text-gray-700 hover:bg-gray-50' : 'text-gray-400 cursor-not-allowed']">Naprej</Link>
</div>
</div>
</AdminLayout>
</template>