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

523 lines
24 KiB
Vue
Raw Permalink 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 promiseDateFrom = ref('')
const promiseDateTo = ref('')
const onlyMobile = ref(false)
const onlyValidated = ref(false)
const loadingContracts = ref(false)
const selectedContractIds = ref(new Set())
const perPage = ref(25)
async function loadContracts(url = null) {
loadingContracts.value = true
try {
const params = new URLSearchParams()
if (segmentId.value) params.append('segment_id', segmentId.value)
if (search.value) params.append('q', search.value)
if (clientId.value) params.append('client_id', clientId.value)
if (startDateFrom.value) params.append('start_date_from', startDateFrom.value)
if (startDateTo.value) params.append('start_date_to', startDateTo.value)
if (promiseDateFrom.value) params.append('promise_date_from', promiseDateFrom.value)
if (promiseDateTo.value) params.append('promise_date_to', promiseDateTo.value)
if (onlyMobile.value) params.append('only_mobile', '1')
if (onlyValidated.value) params.append('only_validated', '1')
params.append('per_page', perPage.value)
const target = url || `${route('admin.packages.contracts')}?${params.toString()}`
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 params = new URLSearchParams()
if (segmentId.value) params.append('segment_id', segmentId.value)
if (search.value) params.append('q', search.value)
if (clientId.value) params.append('client_id', clientId.value)
if (startDateFrom.value) params.append('start_date_from', startDateFrom.value)
if (startDateTo.value) params.append('start_date_to', startDateTo.value)
if (promiseDateFrom.value) params.append('promise_date_from', promiseDateFrom.value)
if (promiseDateTo.value) params.append('promise_date_to', promiseDateTo.value)
if (onlyMobile.value) params.append('only_mobile', '1')
if (onlyValidated.value) params.append('only_validated', '1')
params.append('per_page', perPage.value)
params.append('page', nextPage)
const base = `${route('admin.packages.contracts')}?${params.toString()}`
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 class="sm:col-span-3 space-y-4">
<!-- Basic filters -->
<div class="grid sm:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Segment</label>
<select v-model.number="segmentId" @change="loadContracts()" class="w-full rounded border-gray-300 text-sm">
<option :value="null">Vsi segmenti</option>
<option v-for="s in segments" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Stranka</label>
<select v-model.number="clientId" @change="loadContracts()" class="w-full rounded border-gray-300 text-sm">
<option :value="null">Vse stranke</option>
<option v-for="c in clients" :key="c.id" :value="c.id">{{ c.name }}</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 mb-1">Iskanje po referenci</label>
<input v-model="search" @keyup.enter="loadContracts()" type="text" class="w-full rounded border-gray-300 text-sm" placeholder="Vnesi referenco...">
</div>
</div>
<!-- Date range filters -->
<div class="border-t pt-4">
<h4 class="text-xs font-semibold text-gray-700 mb-3">Datumski filtri</h4>
<div class="space-y-4">
<div>
<div class="text-xs font-medium text-gray-600 mb-2">Datum začetka pogodbe</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500 mb-1">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">Do</label>
<input v-model="startDateTo" @change="loadContracts()" type="date" class="w-full rounded border-gray-300 text-sm">
</div>
</div>
</div>
<div>
<div class="text-xs font-medium text-gray-600 mb-2">Datum obljube plačila</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-xs text-gray-500 mb-1">Od</label>
<input v-model="promiseDateFrom" @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">Do</label>
<input v-model="promiseDateTo" @change="loadContracts()" type="date" class="w-full rounded border-gray-300 text-sm">
</div>
</div>
</div>
</div>
</div>
<!-- Phone filters -->
<div class="border-t pt-4">
<h4 class="text-xs font-semibold text-gray-700 mb-3">Telefonski filtri</h4>
<div class="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()" class="rounded border-gray-300">
Samo mobilne številke
</label>
<label class="inline-flex items-center gap-2">
<input type="checkbox" v-model="onlyValidated" @change="loadContracts()" class="rounded border-gray-300">
Samo potrjene številke
</label>
</div>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2 pt-2">
<button @click="loadContracts()" class="px-4 py-2 rounded bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700">
Išči pogodbe
</button>
<button
@click="segmentId = null; clientId = null; search = ''; startDateFrom = ''; startDateTo = ''; promiseDateFrom = ''; promiseDateTo = ''; onlyMobile = false; onlyValidated = false; contracts.value = { data: [], meta: { current_page: 1, last_page: 1, per_page: 25, total: 0 } }"
class="px-4 py-2 rounded border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50"
>
Počisti filtre
</button>
</div>
</div>
<!-- Results table -->
<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">Zadnja obljuba</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 hover:bg-gray-50">
<td class="px-3 py-2">
<input type="checkbox" :checked="selectedContractIds.has(c.id)" @change="toggleSelectContract(c.id)" class="rounded">
</td>
<td class="px-3 py-2">
<div class="font-mono text-xs text-gray-600">{{ c.uuid }}</div>
<a
v-if="c.case?.uuid"
:href="route('clientCase.show', c.case.uuid)"
target="_blank"
rel="noopener noreferrer"
class="text-xs font-medium text-indigo-600 hover:text-indigo-800 hover:underline"
>
{{ c.reference }}
</a>
<div v-else class="text-xs font-medium 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 class="text-xs text-gray-700">{{ c.promise_date ? new Date(c.promise_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="8" class="px-3 py-8 text-center text-sm text-gray-500">
Ni rezultatov. Poskusite z drugimi filtri.
</td>
</tr>
</tbody>
<tbody v-else>
<tr><td colspan="8" 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 flex items-center gap-4">
<span v-if="contracts.data.length">
Prikazano stran {{ contracts.meta.current_page }} od {{ contracts.meta.last_page }} (skupaj {{ contracts.meta.total }})
</span>
<div class="flex items-center gap-2">
<label class="text-xs text-gray-500">Na stran:</label>
<select v-model.number="perPage" @change="loadContracts()" class="rounded border-gray-300 text-xs py-1">
<option :value="10">10</option>
<option :value="25">25</option>
<option :value="50">50</option>
<option :value="100">100</option>
<option :value="200">200</option>
</select>
</div>
</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 disabled:cursor-not-allowed">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 disabled:cursor-not-allowed">Naprej</button>
</div>
</div>
</div>
<div class="sm:col-span-3 flex items-center justify-between gap-2 pt-4 border-t">
<div class="text-sm text-gray-600">
<span class="font-medium">Izbrano: {{ selectedContractIds.size }}</span>
<span v-if="selectedContractIds.size > 0" class="ml-2 text-gray-500">({{ selectedContractIds.size === 1 ? '1 pogodba' : `${selectedContractIds.size} pogodb` }})</span>
</div>
<button @click="submitCreateFromContracts" :disabled="selectedContractIds.size === 0" class="px-4 py-2 rounded bg-emerald-600 text-white text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-emerald-700">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>