{% extends "console/layout.html" %}
{% set has_model = model is defined and model is not none %}
{% set page_mode = mode | default('create') %}
{% set model_data = model if has_model else {} %}
{% block title %}{{ model_data.name | default("queue.new_queue" | t) }} ยท {{ site_name | default('RustPBX') }}{%
endblock %}
{% block content %}
{% set base_url = base_path | safe %}
<div class="p-6" x-data='queueDetailPage({
basePath: {{ base_url | tojson }},
apiPrefix: {{ api_prefix | default('/api') | tojson }},
mode: {{ page_mode | tojson }},
model: {{ model_data | tojson }},
createUrl: {{ create_url | tojson }},
updateUrl: {{ update_url | tojson }},
listUrl: {{ list_url | default(create_url) | tojson }},
t: window._queueTranslations
})' x-init="init()">
<div class="mx-auto max-w-4xl space-y-6">
<header class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div class="space-y-3">
<a :href="listUrl"
class="inline-flex items-center gap-2 text-sm font-medium text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5l-4 5 4 5" />
</svg>
<span x-text="tt('queue.detail_back')"></span>
</a>
<div>
<h1 class="text-2xl font-semibold text-slate-900" x-text="pageTitle"></h1>
<p class="mt-2 text-sm text-slate-500"
x-text="mode === 'create' ? tt('queue.detail_subtitle_create') : tt('queue.detail_subtitle_edit')">
</p>
</div>
</div>
<div class="rounded-xl bg-white p-4 text-sm text-slate-600 shadow-sm ring-1 ring-black/5"
x-show="mode === 'edit'" x-cloak>
<div class="flex items-center justify-between gap-4">
<div class="font-semibold text-slate-900" x-text="tt('queue.detail_summary_title')"></div>
<span class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-semibold"
:class="form.is_active ? 'bg-emerald-50 text-emerald-600 ring-1 ring-emerald-100' : 'bg-amber-50 text-amber-600 ring-1 ring-amber-100'">
<span class="h-1.5 w-1.5 rounded-full"
:class="form.is_active ? 'bg-emerald-400' : 'bg-amber-400'"></span>
<span x-text="form.is_active ? tt('queue.status_active') : tt('queue.status_paused')"></span>
</span>
</div>
<dl class="mt-4 space-y-2 text-xs text-slate-500">
<div class="flex items-center justify-between gap-6">
<dt class="uppercase tracking-wide" x-text="tt('queue.detail_name_label')"></dt>
<dd class="text-sm text-slate-700" x-text="form.name || '-'"></dd>
</div>
<div class="flex items-center justify-between gap-6">
<dt class="uppercase tracking-wide" x-text="tt('queue.detail_updated_label')"></dt>
<dd class="text-sm text-slate-700" x-text="formatDate(model?.updated_at)"></dd>
</div>
</dl>
</div>
</header>
<template x-if="success">
<div class="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700"
x-text="success"></div>
</template>
<template x-if="error">
<div class="rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" x-text="error">
</div>
</template>
<form class="space-y-6" @submit.prevent="submit">
<div class="rounded-xl bg-white p-2 shadow-sm ring-1 ring-black/5">
<div class="flex flex-wrap gap-2">
<button type="button" @click="activeTab = 'overview'" :class="tabButtonClasses('overview')"
x-text="tt('queue.tab_overview')">
</button>
<button type="button" @click="activeTab = 'targets'" :class="tabButtonClasses('targets')"
x-text="tt('queue.tab_targets')">
</button>
<button type="button" @click="activeTab = 'fallback'" :class="tabButtonClasses('fallback')"
x-text="tt('queue.tab_fallback')">
</button>
<button type="button" @click="activeTab = 'voice_prompts'" :class="tabButtonClasses('voice_prompts')"
x-text="tt('queue.tab_voice_prompts')">
</button>
</div>
</div>
<section class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5" x-show="activeTab === 'overview'"
x-cloak>
<h2 class="text-sm font-semibold text-slate-900" x-text="tt('queue.section_basics')"></h2>
<div class="mt-4 grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.field_name')"></span>
<input type="text" x-model.trim="form.name" maxlength="160" required
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.field_name_placeholder')">
<span class="text-xs font-normal text-slate-400" x-text="tt('queue.field_name_hint')"></span>
</label>
<div class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.field_status')"></span>
<button type="button" @click="toggleActive()"
class="inline-flex items-center gap-2 rounded-lg px-3 py-1 text-xs font-semibold ring-2 transition"
:class="form.is_active ? 'bg-emerald-50 text-emerald-600 ring-emerald-200' : 'bg-slate-100 text-slate-500 ring-slate-200'">
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6">
<path stroke-linecap="round" stroke-linejoin="round" d="M5 10l4 4 6-8"
x-show="form.is_active"></path>
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6l8 8M6 14l8-8"
x-show="!form.is_active"></path>
</svg>
<span
x-text="form.is_active ? tt('queue.status_active') : tt('queue.status_paused')"></span>
</button>
<span class="text-xs font-normal text-slate-400" x-text="tt('queue.field_status_hint')"></span>
</div>
<label class="md:col-span-2 flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.field_description')"></span>
<textarea rows="3" x-model.trim="form.description"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.field_description_placeholder')"></textarea>
</label>
<div class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.field_accept_immediately')"></span>
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-400"
x-model="form.spec.accept_immediately">
<span x-text="tt('queue.field_accept_immediately_label')"></span>
</label>
</div>
<div class="flex flex-col gap-2 text-sm font-medium text-slate-700"
x-show="form.spec.accept_immediately" x-cloak>
<span x-text="tt('queue.field_passthrough_ringback')"></span>
<label class="inline-flex items-start gap-2 text-sm font-medium text-slate-700">
<input type="checkbox"
class="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-400"
x-model="form.spec.passthrough_ringback">
<span class="text-sm font-normal text-slate-600"
x-text="tt('queue.field_passthrough_ringback_desc')"></span>
</label>
</div>
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.field_tags')"></span>
<input type="text" x-model.trim="form.tags_input"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.field_tags_placeholder')">
</label>
</div>
</section>
<section class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5" x-show="activeTab === 'overview'"
x-cloak>
<div class="flex flex-col gap-1">
<h2 class="text-sm font-semibold text-slate-900" x-text="tt('queue.section_hold')"></h2>
<p class="text-sm text-slate-500" x-text="tt('queue.section_hold_desc')"></p>
</div>
<div class="mt-4 space-y-4">
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox" class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-400"
x-model="holdEnabled">
<span x-text="tt('queue.hold_enable_label')"></span>
</label>
<div class="grid gap-4 md:grid-cols-2" x-show="holdEnabled" x-cloak>
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.hold_audio_file')"></span>
<input type="text" x-model.trim="form.spec.hold.audio_file"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.hold_audio_file_placeholder')">
</label>
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="checkbox"
class="h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-400"
x-model="form.spec.hold.loop_playback">
<span x-text="tt('queue.hold_loop')"></span>
</label>
</div>
</div>
</section>
<section class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5" x-show="activeTab === 'targets'"
x-cloak>
<div class="flex flex-col gap-1">
<h2 class="text-sm font-semibold text-slate-900" x-text="tt('queue.section_targets')"></h2>
<p class="text-sm text-slate-500" x-text="tt('queue.section_targets_desc')"></p>
</div>
<div class="mt-4 space-y-5">
<div class="grid gap-4 md:grid-cols-2">
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.field_dial_mode')"></span>
<select x-model="form.spec.strategy.mode"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="sequential" x-text="tt('queue.dial_sequential')"></option>
<option value="parallel" x-text="tt('queue.dial_parallel')"></option>
</select>
</label>
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.field_ring_timeout')"></span>
<input type="number" min="0" x-model.number="form.spec.strategy.wait_timeout_secs"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.field_ring_timeout_placeholder')">
<span class="text-xs font-normal text-slate-400"
x-text="tt('queue.field_ring_timeout_hint')"></span>
</label>
<label class="md:col-span-2 flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.field_acd_policy')"></span>
<template x-if="acdPolicies.length">
<select x-model="form.spec.acd_policy"
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="" x-text="tt('queue.field_acd_policy_placeholder')"></option>
<template x-for="policy in acdPolicies" :key="policy.name">
<option :value="policy.name" x-text="policy.name"></option>
</template>
</select>
</template>
<template x-if="!acdPolicies.length">
<input type="text" x-model.trim="form.spec.acd_policy"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.field_acd_policy_placeholder')">
</template>
<span class="text-xs font-normal text-slate-400" x-text="tt('queue.field_acd_policy_hint')"></span>
</label>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-800" x-text="tt('queue.dial_targets_title')">
</h3>
<div class="flex items-center gap-2">
<button type="button" @click="addStrategyTarget('sip')"
class="inline-flex items-center gap-2 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50"
x-text="tt('queue.add_target')">
</button>
<button type="button" @click="addStrategyTarget('skill_group')"
x-show="skillGroups.length"
class="inline-flex items-center gap-2 rounded-lg border border-indigo-200 px-3 py-1.5 text-xs font-semibold text-indigo-600 hover:bg-indigo-50"
x-text="tt('queue.add_skill_group_target')">
</button>
</div>
</div>
<template x-if="!form.spec.strategy.targets.length">
<p class="text-xs text-slate-400" x-text="tt('queue.no_targets')"></p>
</template>
<div class="space-y-3">
<template x-for="(target, index) in form.spec.strategy.targets" :key="index">
<div
class="grid gap-3 rounded-lg border border-slate-200 p-3"
:class="target.uri && target.uri.startsWith('skill-group:') ? 'md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]' : 'md:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_auto]'">
<template x-if="!target.uri || !target.uri.startsWith('skill-group:')">
<label
class="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
<span x-text="tt('queue.target_uri')"></span>
<input type="text" x-model.trim="target.uri"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm font-normal text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.target_uri_placeholder')">
</label>
</template>
<template x-if="target.uri && target.uri.startsWith('skill-group:')">
<label class="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
<span x-text="tt('queue.target_skill_group')"></span>
<select x-model="target.skill_group_id"
x-effect="target.skill_group_id && skillGroups.length ? $el.value = target.skill_group_id : ''"
@change="target.uri = 'skill-group:' + target.skill_group_id"
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="" x-text="tt('queue.skill_group_select_placeholder')"></option>
<template x-for="group in skillGroups" :key="group.skill_group_id">
<option :value="group.skill_group_id" x-text="group.display_name || group.skill_group_id"></option>
</template>
</select>
</label>
</template>
<label
class="flex flex-col gap-1 text-xs font-semibold uppercase tracking-wide text-slate-500">
<span x-text="tt('queue.target_label')"></span>
<input type="text" x-model.trim="target.label"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm font-normal text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.target_label_placeholder')">
</label>
<div class="flex items-end justify-end">
<button type="button" @click="removeStrategyTarget(index)"
class="rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-500 hover:bg-slate-50"
x-text="tt('queue.remove_target')">
</button>
</div>
</div>
</template>
</div>
</div>
</div>
</section>
<section class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5" x-show="activeTab === 'fallback'"
x-cloak>
<div class="flex flex-col gap-1">
<h2 class="text-sm font-semibold text-slate-900" x-text="tt('queue.section_fallback')"></h2>
<p class="text-sm text-slate-500" x-text="tt('queue.section_fallback_desc')"></p>
</div>
<div class="mt-4 space-y-4">
<div class="flex flex-wrap gap-3">
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="radio" name="queue-fallback" value="none" x-model="fallbackMode"
class="h-4 w-4 text-sky-600 focus:ring-sky-400">
<span x-text="tt('queue.fallback_none')"></span>
</label>
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="radio" name="queue-fallback" value="redirect" x-model="fallbackMode"
class="h-4 w-4 text-sky-600 focus:ring-sky-400">
<span x-text="tt('queue.fallback_redirect_opt')"></span>
</label>
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="radio" name="queue-fallback" value="queue" x-model="fallbackMode"
class="h-4 w-4 text-sky-600 focus:ring-sky-400">
<span x-text="tt('queue.fallback_queue_opt')"></span>
</label>
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700" x-show="skillGroups.length" x-cloak>
<input type="radio" name="queue-fallback" value="skill_group" x-model="fallbackMode"
class="h-4 w-4 text-sky-600 focus:ring-sky-400">
<span x-text="tt('queue.fallback_skill_group_opt')"></span>
</label>
<label class="inline-flex items-center gap-2 text-sm font-medium text-slate-700">
<input type="radio" name="queue-fallback" value="failure" x-model="fallbackMode"
class="h-4 w-4 text-sky-600 focus:ring-sky-400">
<span x-text="tt('queue.fallback_failure_opt')"></span>
</label>
</div>
<div class="grid gap-4 md:grid-cols-2" x-show="fallbackMode === 'redirect'" x-cloak>
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.fallback_redirect_label')"></span>
<input type="text" x-model.trim="form.spec.fallback.redirect"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.fallback_redirect_placeholder')">
</label>
</div>
<div class="grid gap-4 md:grid-cols-2" x-show="fallbackMode === 'failure'" x-cloak>
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.fallback_code_label')"></span>
<input type="number" min="100" max="699" x-model.number="form.spec.fallback.failure_code"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.fallback_code_placeholder')">
</label>
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.fallback_reason_label')"></span>
<input type="text" x-model.trim="form.spec.fallback.failure_reason"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.fallback_reason_placeholder')">
</label>
<label class="md:col-span-2 flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.fallback_prompt_label')"></span>
<input type="text" x-model.trim="form.spec.fallback.failure_prompt"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.fallback_prompt_placeholder')">
<span class="text-xs font-normal text-slate-400"
x-text="tt('queue.fallback_prompt_hint')"></span>
</label>
</div>
<div class="grid gap-4 md:grid-cols-2" x-show="fallbackMode === 'queue'" x-cloak>
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.fallback_queue_label')"></span>
<input type="text" x-model.trim="form.spec.fallback.queue_ref"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.fallback_queue_placeholder')">
<span class="text-xs font-normal text-slate-400"
x-text="tt('queue.fallback_queue_hint')"></span>
</label>
</div>
<div class="grid gap-4 md:grid-cols-2" x-show="fallbackMode === 'skill_group'" x-cloak>
<label class="flex flex-col gap-2 text-sm font-medium text-slate-700">
<span x-text="tt('queue.fallback_skill_group_label')"></span>
<template x-if="skillGroups.length">
<select x-model="form.spec.fallback.skill_group_ref"
x-effect="form.spec.fallback.skill_group_ref && skillGroups.length ? $el.value = form.spec.fallback.skill_group_ref : ''"
class="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="" x-text="tt('queue.fallback_skill_group_placeholder')"></option>
<template x-for="group in skillGroups" :key="group.skill_group_id">
<option :value="group.skill_group_id" x-text="group.display_name || group.skill_group_id"></option>
</template>
</select>
</template>
<template x-if="!skillGroups.length">
<input type="text" x-model.trim="form.spec.fallback.skill_group_ref"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.fallback_skill_group_placeholder')">
</template>
<span class="text-xs font-normal text-slate-400" x-text="tt('queue.fallback_skill_group_hint')"></span>
</label>
</div>
</div>
</section>
<section class="rounded-xl bg-white p-6 shadow-sm ring-1 ring-black/5" x-show="activeTab === 'voice_prompts'" x-cloak>
<div class="flex flex-col gap-1">
<h2 class="text-sm font-semibold text-slate-900" x-text="tt('queue.section_voice_prompts')"></h2>
<p class="text-sm text-slate-500" x-text="tt('queue.section_voice_prompts_desc')"></p>
</div>
<div class="mt-6 space-y-6">
<div class="rounded-lg border border-slate-200 p-4">
<h3 class="text-sm font-semibold text-slate-800" x-text="tt('queue.vp_transfer')"></h3>
<p class="mt-1 text-xs text-slate-500" x-text="tt('queue.vp_transfer_desc')"></p>
<div class="mt-3 grid gap-3 md:grid-cols-3">
<select x-model="vpTransferType"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="none" x-text="tt('queue.vp_none')"></option>
<option value="zh" x-text="tt('queue.vp_chinese')"></option>
<option value="en" x-text="tt('queue.vp_english')"></option>
<option value="custom" x-text="tt('queue.vp_custom')"></option>
</select>
<div class="flex items-center gap-2 md:col-span-2">
<input type="text" x-show="vpTransferType === 'custom'" x-model.trim="vpTransferCustom"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.vp_url_placeholder')">
<button type="button" x-show="vpTransferType === 'custom' && vpTransferCustom.startsWith('http')"
@click="downloadAudio(vpTransferCustom, 'transfer')"
:disabled="downloadingTransfer"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 hover:border-sky-300 hover:text-sky-700"
x-text="downloadingTransfer ? tt('queue.vp_downloading') : tt('queue.vp_download')">
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-2">
<button type="button" @click="previewAudio('transfer')"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:border-indigo-300 hover:text-indigo-700"
x-text="tt('queue.vp_preview')">
</button>
<span class="text-xs text-slate-400" x-text="vpTransferPath || ''"></span>
</div>
</div>
<div class="rounded-lg border border-slate-200 p-4">
<h3 class="text-sm font-semibold text-slate-800" x-text="tt('queue.vp_busy')"></h3>
<p class="mt-1 text-xs text-slate-500" x-text="tt('queue.vp_busy_desc')"></p>
<div class="mt-3 grid gap-3 md:grid-cols-3">
<select x-model="vpBusyType"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="none" x-text="tt('queue.vp_none')"></option>
<option value="zh" x-text="tt('queue.vp_chinese')"></option>
<option value="en" x-text="tt('queue.vp_english')"></option>
<option value="custom" x-text="tt('queue.vp_custom')"></option>
</select>
<div class="flex items-center gap-2 md:col-span-2">
<input type="text" x-show="vpBusyType === 'custom'" x-model.trim="vpBusyCustom"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.vp_url_placeholder')">
<button type="button" x-show="vpBusyType === 'custom' && vpBusyCustom.startsWith('http')"
@click="downloadAudio(vpBusyCustom, 'busy')"
:disabled="downloadingBusy"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 hover:border-sky-300 hover:text-sky-700"
x-text="downloadingBusy ? tt('queue.vp_downloading') : tt('queue.vp_download')">
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-2">
<button type="button" @click="previewAudio('busy')"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:border-indigo-300 hover:text-indigo-700"
x-text="tt('queue.vp_preview')">
</button>
<span class="text-xs text-slate-400" x-text="vpBusyPath || ''"></span>
</div>
</div>
<div class="rounded-lg border border-slate-200 p-4">
<h3 class="text-sm font-semibold text-slate-800" x-text="tt('queue.vp_off_hours')"></h3>
<p class="mt-1 text-xs text-slate-500" x-text="tt('queue.vp_off_hours_desc')"></p>
<div class="mt-3 grid gap-3 md:grid-cols-3">
<select x-model="vpOffHoursType"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="none" x-text="tt('queue.vp_none')"></option>
<option value="zh" x-text="tt('queue.vp_chinese')"></option>
<option value="en" x-text="tt('queue.vp_english')"></option>
<option value="custom" x-text="tt('queue.vp_custom')"></option>
</select>
<div class="flex items-center gap-2 md:col-span-2">
<input type="text" x-show="vpOffHoursType === 'custom'" x-model.trim="vpOffHoursCustom"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.vp_url_placeholder')">
<button type="button" x-show="vpOffHoursType === 'custom' && vpOffHoursCustom.startsWith('http')"
@click="downloadAudio(vpOffHoursCustom, 'offhours')"
:disabled="downloadingOffHours"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 hover:border-sky-300 hover:text-sky-700"
x-text="downloadingOffHours ? tt('queue.vp_downloading') : tt('queue.vp_download')">
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-2">
<button type="button" @click="previewAudio('offhours')"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:border-indigo-300 hover:text-indigo-700"
x-text="tt('queue.vp_preview')">
</button>
<span class="text-xs text-slate-400" x-text="vpOffHoursPath || ''"></span>
</div>
</div>
<div class="rounded-lg border border-slate-200 p-4">
<h3 class="text-sm font-semibold text-slate-800" x-text="tt('queue.vp_no_answer')"></h3>
<p class="mt-1 text-xs text-slate-500" x-text="tt('queue.vp_no_answer_desc')"></p>
<div class="mt-3 grid gap-3 md:grid-cols-3">
<select x-model="vpNoAnswerType"
class="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200">
<option value="none" x-text="tt('queue.vp_none')"></option>
<option value="zh" x-text="tt('queue.vp_chinese')"></option>
<option value="en" x-text="tt('queue.vp_english')"></option>
<option value="custom" x-text="tt('queue.vp_custom')"></option>
</select>
<div class="flex items-center gap-2 md:col-span-2">
<input type="text" x-show="vpNoAnswerType === 'custom'" x-model.trim="vpNoAnswerCustom"
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700 focus:border-sky-300 focus:outline-none focus:ring-2 focus:ring-sky-200"
:placeholder="tt('queue.vp_url_placeholder')">
<button type="button" x-show="vpNoAnswerType === 'custom' && vpNoAnswerCustom.startsWith('http')"
@click="downloadAudio(vpNoAnswerCustom, 'noanswer')"
:disabled="downloadingNoAnswer"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-2 text-xs font-semibold text-slate-600 hover:border-sky-300 hover:text-sky-700"
x-text="downloadingNoAnswer ? tt('queue.vp_downloading') : tt('queue.vp_download')">
</button>
</div>
</div>
<div class="mt-2 flex items-center gap-2">
<button type="button" @click="previewAudio('noanswer')"
class="inline-flex items-center gap-1 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:border-indigo-300 hover:text-indigo-700"
x-text="tt('queue.vp_preview')">
</button>
<span class="text-xs text-slate-400" x-text="vpNoAnswerPath || ''"></span>
</div>
</div>
<audio x-ref="audioPreview" controls class="hidden"></audio>
</div>
</section>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-slate-400" x-text="tt('queue.save_hint')"></p>
<div class="flex items-center gap-3">
<a :href="listUrl"
class="inline-flex items-center justify-center rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 transition hover:bg-slate-100"
x-text="tt('queue.btn_cancel')">
</a>
<button type="submit"
class="inline-flex items-center justify-center gap-2 rounded-lg bg-sky-600 px-5 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-400 focus:ring-offset-2"
:disabled="saving">
<svg class="h-4 w-4 animate-spin" viewBox="0 0 20 20" fill="none" stroke="currentColor"
stroke-width="1.6" x-show="saving">
<path stroke-linecap="round" stroke-linejoin="round"
d="M4 10a6 6 0 0 1 6-6m0-3v3m6 6a6 6 0 0 1-6 6m0 3v-3m9-6h-3M1 10h3m10.95 4.95-2.12-2.12M4.05 5.05l2.12 2.12m0 5.66-2.12 2.12m9.9-9.9 2.12-2.12" />
</svg>
<span x-text="mode === 'edit' ? tt('queue.btn_save') : tt('queue.btn_create')"></span>
</button>
</div>
</div>
</form>
</div>
</div>
<script>
window.__consoleApiPrefix = {{ api_prefix | default('/api') | tojson }};
window._queueTranslations = {{ t | json | safe }};
document.addEventListener('alpine:init', () => {
Alpine.data('queueDetailPage', (options) => ({
basePath: options.basePath || '/console',
apiPrefix: options.apiPrefix || '/api',
mode: options.mode || 'create',
model: options.model || null,
createUrl: options.createUrl || `${options.basePath || '/console'}/queues`,
updateUrl: options.updateUrl || null,
listUrl: options.listUrl || `${options.basePath || '/console'}/queues`,
t: window._queueTranslations,
pageTitle: options.mode === 'edit' ? (window._queueTranslations?.queue?.detail_subtitle_edit || 'Edit queue') : (window._queueTranslations?.queue?.new_queue || 'New queue'),
activeTab: 'overview',
skillGroups: [],
acdPolicies: [],
pendingSkillGroup: '',
form: {
name: '',
description: '',
is_active: true,
tags_input: '',
spec: {
accept_immediately: false,
passthrough_ringback: false,
hold: {
audio_file: '',
loop_playback: true,
},
fallback: {
redirect: '',
failure_code: null,
failure_reason: '',
failure_prompt: '',
queue_ref: '',
skill_group_ref: '',
},
strategy: {
mode: 'sequential',
wait_timeout_secs: null,
targets: [],
skill_groups: [],
},
acd_policy: '',
},
},
holdEnabled: false,
fallbackMode: 'none',
vpTransferType: 'none',
vpBusyType: 'none',
vpOffHoursType: 'none',
vpNoAnswerType: 'none',
vpTransferCustom: '',
vpBusyCustom: '',
vpOffHoursCustom: '',
vpNoAnswerCustom: '',
vpTransferPath: '',
vpBusyPath: '',
vpOffHoursPath: '',
vpNoAnswerPath: '',
downloadingTransfer: false,
downloadingBusy: false,
downloadingOffHours: false,
downloadingNoAnswer: false,
saving: false,
success: null,
error: null,
normalizeApiPrefix(value) {
const raw = String(value || '/api').trim();
if (!raw) {
return '/api';
}
const normalized = raw.replace(/\/+$/, '');
if (/\/api(?:\/|$)/.test(normalized)) {
return normalized;
}
return `${normalized}/api`;
},
init() {
this.apiPrefix = this.normalizeApiPrefix(this.apiPrefix || window.__consoleApiPrefix || '/api');
this.applyModel(this.model || {});
this.loadSkillGroups();
this.loadAcdPolicies();
},
extractSkillGroupId(value) {
const raw = String(value || '').trim();
if (!raw) {
return '';
}
const match = raw.match(/^skill(?:-|_)?group:(.+)$/i);
if (match && match[1]) {
return match[1].trim();
}
return raw;
},
toSkillGroupUri(value) {
const id = this.extractSkillGroupId(value);
return id ? `skill-group:${id}` : '';
},
reconcileSkillGroupTargets() {
const targets = this.form?.spec?.strategy?.targets;
if (!Array.isArray(targets)) {
return;
}
targets.forEach((target) => {
if (!target || typeof target !== 'object') {
return;
}
const groupId = this.extractSkillGroupId(target.skill_group_id || target.uri);
if (!groupId) {
return;
}
target.skill_group_id = groupId;
target.uri = this.toSkillGroupUri(groupId);
});
const fallbackRef = this.extractSkillGroupId(this.form?.spec?.fallback?.skill_group_ref);
if (fallbackRef && this.form?.spec?.fallback) {
this.form.spec.fallback.skill_group_ref = fallbackRef;
}
},
hasSkillGroupOption(value) {
const target = this.extractSkillGroupId(value);
if (!target) {
return false;
}
return (this.skillGroups || []).some((group) => {
const id = this.extractSkillGroupId(group?.skill_group_id);
return id === target;
});
},
loadSkillGroups() {
const loader = window.__ccSkillGroupsLoader;
if (typeof loader === 'function') {
loader()
.then((groups) => {
this.skillGroups = Array.isArray(groups) ? groups : [];
this.reconcileSkillGroupTargets();
})
.catch(() => {
this.skillGroups = [];
});
} else {
fetch(this.apiPrefix + '/cc/skill-groups', { credentials: 'same-origin' })
.then(res => res.ok ? res.json() : null)
.then(payload => {
const arr = Array.isArray(payload) ? payload : (payload && Array.isArray(payload.data) ? payload.data : []);
this.skillGroups = arr;
this.reconcileSkillGroupTargets();
})
.catch(() => {
this.skillGroups = [];
});
}
},
loadAcdPolicies() {
const loader = window.__ccAcdPoliciesLoader;
if (typeof loader === 'function') {
loader()
.then((policies) => {
this.acdPolicies = Array.isArray(policies) ? policies : [];
})
.catch(() => {
this.acdPolicies = [];
});
return;
}
fetch(this.apiPrefix + '/cc/acd/policies', { credentials: 'same-origin' })
.then(res => res.ok ? res.json() : { data: [] })
.then(payload => {
this.acdPolicies = Array.isArray(payload?.data) ? payload.data : [];
})
.catch(() => {
this.acdPolicies = [];
});
},
tt(key) {
const parts = key.split('.');
let val = this.t;
for (const part of parts) {
if (val == null) return key;
val = val[part];
}
return val != null ? String(val) : key;
},
VP_PATHS: {
transfer: { zh: 'config/sounds/queue-transfer-zh.wav', en: 'config/sounds/queue-transfer-en.wav' },
busy: { zh: 'config/sounds/queue-busy-zh.wav', en: 'config/sounds/queue-busy-en.wav' },
offhours: { zh: 'config/sounds/queue-off-hours-zh.wav', en: 'config/sounds/queue-off-hours-en.wav' },
noanswer: { zh: 'config/sounds/queue-no-answer-zh.wav', en: 'config/sounds/queue-no-answer-en.wav' },
},
vpTypeFor(path) {
if (!path) return 'none';
for (const [type, langs] of Object.entries(this.VP_PATHS)) {
for (const [lang, p] of Object.entries(langs)) {
if (path === p) return lang;
}
}
return 'custom';
},
loadVoicePrompts(vp) {
if (!vp || typeof vp !== 'object') return;
this.vpTransferType = this.vpTypeFor(vp.transfer_prompt);
this.vpBusyType = this.vpTypeFor(vp.busy_prompt);
this.vpOffHoursType = this.vpTypeFor(vp.off_hours_prompt);
this.vpNoAnswerType = this.vpTypeFor(vp.no_answer_prompt);
this.vpTransferPath = vp.transfer_prompt || '';
this.vpBusyPath = vp.busy_prompt || '';
this.vpOffHoursPath = vp.off_hours_prompt || '';
this.vpNoAnswerPath = vp.no_answer_prompt || '';
if (this.vpTransferType === 'custom') this.vpTransferCustom = vp.transfer_prompt;
if (this.vpBusyType === 'custom') this.vpBusyCustom = vp.busy_prompt;
if (this.vpOffHoursType === 'custom') this.vpOffHoursCustom = vp.off_hours_prompt;
if (this.vpNoAnswerType === 'custom') this.vpNoAnswerCustom = vp.no_answer_prompt;
},
buildVoicePrompts() {
const result = {};
const transfer = this.resolvePrompt('transfer', this.vpTransferType, this.vpTransferCustom);
const busy = this.resolvePrompt('busy', this.vpBusyType, this.vpBusyCustom);
const offhours = this.resolvePrompt('offhours', this.vpOffHoursType, this.vpOffHoursCustom);
const noanswer = this.resolvePrompt('noanswer', this.vpNoAnswerType, this.vpNoAnswerCustom);
if (transfer) result.transfer_prompt = transfer;
if (busy) result.busy_prompt = busy;
if (offhours) result.off_hours_prompt = offhours;
if (noanswer) result.no_answer_prompt = noanswer;
return Object.keys(result).length ? result : null;
},
resolvePrompt(type, lang, custom) {
if (lang === 'none' || !lang) return null;
if (lang === 'custom') return custom || null;
return this.VP_PATHS[type]?.[lang] || null;
},
async downloadAudio(url, type) {
const loadingKey = type === 'transfer' ? 'downloadingTransfer'
: type === 'busy' ? 'downloadingBusy'
: type === 'noanswer' ? 'downloadingNoAnswer' : 'downloadingOffHours';
this[loadingKey] = true;
try {
const resp = await fetch(`${this.basePath}/queues/download-audio`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ url }),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data?.message || 'Download failed');
const path = data.path;
if (type === 'transfer') { this.vpTransferCustom = path; this.vpTransferPath = path; }
else if (type === 'busy') { this.vpBusyCustom = path; this.vpBusyPath = path; }
else if (type === 'noanswer') { this.vpNoAnswerCustom = path; this.vpNoAnswerPath = path; }
else { this.vpOffHoursCustom = path; this.vpOffHoursPath = path; }
} catch (err) {
console.error(err);
alert(err.message || 'Download failed');
} finally {
this[loadingKey] = false;
}
},
previewAudio(type) {
let path = type === 'transfer' ? this.vpTransferPath
: type === 'busy' ? this.vpBusyPath
: type === 'noanswer' ? this.vpNoAnswerPath : this.vpOffHoursPath;
if (!path) {
const lang = type === 'transfer' ? this.vpTransferType
: type === 'busy' ? this.vpBusyType
: type === 'noanswer' ? this.vpNoAnswerType : this.vpOffHoursType;
if (lang && lang !== 'none' && lang !== 'custom') {
path = this.VP_PATHS[type]?.[lang];
}
}
if (!path) return;
const url = `${this.basePath}/queues/sound/${path.replace(/^sounds\//, '')}`;
const audio = this.$refs.audioPreview;
if (audio) {
audio.src = url;
audio.classList.remove('hidden');
audio.play().catch(() => {});
}
},
tabButtonClasses(tab) {
return this.activeTab === tab
? 'rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white shadow-sm'
: 'rounded-lg px-4 py-2 text-sm font-semibold text-slate-600 hover:text-slate-900';
},
applyModel(model) {
if (!model || typeof model !== 'object') {
return;
}
if (model.name) {
this.form.name = model.name;
}
this.form.description = model.description || '';
this.form.is_active = model.is_active !== false;
const tags = Array.isArray(model.tags) ? model.tags : [];
this.form.tags_input = tags.join(', ');
const spec = this.normalizeSpec(model.spec || {});
this.form.spec = spec;
this.holdEnabled = Boolean(spec.hold && (spec.hold.audio_file || spec.hold.loop_playback === false));
this.fallbackMode = this.detectFallbackMode(spec.fallback);
this.loadVoicePrompts(spec.voice_prompts);
this.pageTitle = this.mode === 'edit'
? (model.name ? `${this.tt('queue.detail_subtitle_edit')} - ${model.name}` : this.tt('queue.detail_subtitle_edit'))
: this.tt('queue.new_queue');
},
normalizeSpec(raw) {
const spec = {
accept_immediately: Boolean(raw.accept_immediately),
passthrough_ringback: Boolean(raw.passthrough_ringback),
hold: {
audio_file: raw?.hold?.audio_file || '',
loop_playback: raw?.hold?.loop_playback !== false,
},
fallback: {
redirect: raw?.fallback?.redirect || '',
failure_code: raw?.fallback?.failure_code || null,
failure_reason: raw?.fallback?.failure_reason || '',
failure_prompt: raw?.fallback?.failure_prompt || '',
queue_ref: raw?.fallback?.queue_ref || '',
skill_group_ref: this.extractSkillGroupId(raw?.fallback?.skill_group_ref || ''),
},
strategy: {
mode: raw?.strategy?.mode === 'parallel' ? 'parallel' : 'sequential',
wait_timeout_secs: Number.isFinite(Number(raw?.strategy?.wait_timeout_secs))
? Number(raw.strategy.wait_timeout_secs)
: null,
targets: (() => {
const list = [];
if (Array.isArray(raw?.strategy?.targets)) {
raw.strategy.targets.forEach((target) => {
const uriText = (target?.uri || '').trim();
const legacySkillId = this.extractSkillGroupId(target?.skill_group_id || uriText);
if (legacySkillId && (/^skill(?:-|_)?group:/i.test(uriText) || target?.skill_group_id)) {
list.push({
uri: this.toSkillGroupUri(legacySkillId),
label: (target?.label || '').trim(),
skill_group_id: legacySkillId,
});
return;
}
if (uriText) {
list.push({
uri: uriText,
label: (target?.label || '').trim(),
});
}
});
}
if (Array.isArray(raw?.strategy?.skill_groups)) {
raw.strategy.skill_groups.forEach((entry) => {
const skillId = this.extractSkillGroupId(entry);
if (!skillId) {
return;
}
list.push({
uri: this.toSkillGroupUri(skillId),
label: '',
skill_group_id: skillId,
});
});
}
const seen = new Set();
return list.filter((target) => {
const key = `${target.uri}|${target.label || ''}`;
if (!target.uri || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
})(),
},
acd_policy: raw?.acd_policy || '',
voice_prompts: raw?.voice_prompts || null,
};
return spec;
},
detectFallbackMode(fallback) {
if ((fallback?.skill_group_ref || '').trim()) {
return 'skill_group';
}
if ((fallback?.queue_ref || '').trim().toLowerCase().startsWith('skill-group:')) {
const ref = this.extractSkillGroupId(fallback.queue_ref);
if (this.form?.spec?.fallback) {
this.form.spec.fallback.skill_group_ref = ref;
this.form.spec.fallback.queue_ref = '';
}
return 'skill_group';
}
if ((fallback?.queue_ref || '').trim()) {
return 'queue';
}
if (fallback?.redirect) {
return 'redirect';
}
if (fallback && (fallback.failure_code || fallback.failure_prompt || fallback.failure_reason)) {
return 'failure';
}
return 'none';
},
toggleActive() {
this.form.is_active = !this.form.is_active;
},
tagList() {
const parts = (this.form.tags_input || '')
.split(',')
.map((part) => part.trim())
.filter(Boolean);
const unique = [];
parts.forEach((value) => {
if (!unique.some((existing) => existing.toLowerCase() === value.toLowerCase())) {
unique.push(value);
}
});
return unique;
},
addStrategyTarget(type) {
if (!Array.isArray(this.form.spec.strategy.targets)) {
this.form.spec.strategy.targets = [];
}
if (type === 'skill_group') {
this.form.spec.strategy.targets.push({
uri: 'skill-group:',
label: '',
skill_group_id: ''
});
} else {
this.form.spec.strategy.targets.push({ uri: '', label: '' });
}
},
removeStrategyTarget(index) {
if (!Array.isArray(this.form.spec.strategy.targets)) {
return;
}
this.form.spec.strategy.targets.splice(index, 1);
},
prepareSpec() {
const spec = {
accept_immediately: Boolean(this.form.spec.accept_immediately),
passthrough_ringback: Boolean(this.form.spec.passthrough_ringback && this.form.spec.accept_immediately),
};
if (this.holdEnabled) {
const audio = (this.form.spec.hold?.audio_file || '').trim();
if (audio) {
spec.hold = {
audio_file: audio,
loop_playback: this.form.spec.hold?.loop_playback !== false,
};
}
}
if (this.fallbackMode === 'redirect') {
const target = (this.form.spec.fallback?.redirect || '').trim();
if (target) {
spec.fallback = { redirect: target };
}
} else if (this.fallbackMode === 'queue') {
const queueRef = (this.form.spec.fallback?.queue_ref || '').trim();
if (queueRef) {
spec.fallback = { queue_ref: queueRef };
}
} else if (this.fallbackMode === 'skill_group') {
const skillGroupRef = (this.form.spec.fallback?.skill_group_ref || '').trim();
if (skillGroupRef) {
spec.fallback = { skill_group_ref: skillGroupRef };
}
} else if (this.fallbackMode === 'failure') {
const code = Number(this.form.spec.fallback?.failure_code);
const payload = {
failure_code: Number.isFinite(code) ? code : null,
failure_reason: (this.form.spec.fallback?.failure_reason || '').trim() || null,
failure_prompt: (this.form.spec.fallback?.failure_prompt || '').trim() || null,
};
if (payload.failure_code || payload.failure_reason || payload.failure_prompt) {
spec.fallback = payload;
}
}
const strategyMode = this.form.spec.strategy?.mode === 'parallel' ? 'parallel' : 'sequential';
const targets = (this.form.spec.strategy?.targets || [])
.map((target) => {
const uri = (target.uri || '').trim();
if (/^skill(?:-|_)?group:/i.test(uri) || target?.skill_group_id) {
const skillGroupId = this.extractSkillGroupId(target.skill_group_id || uri);
if (skillGroupId) {
return {
uri: this.toSkillGroupUri(skillGroupId),
label: (target.label || '').trim() || null,
};
}
return null;
}
return {
uri: uri,
label: (target.label || '').trim() || null,
};
})
.filter(Boolean)
.filter((target) => target.uri);
const waitTimeoutRaw = Number(this.form.spec.strategy?.wait_timeout_secs);
const waitTimeout = Number.isFinite(waitTimeoutRaw) && waitTimeoutRaw > 0
? Math.trunc(waitTimeoutRaw)
: null;
if (targets.length || waitTimeout !== null || strategyMode === 'parallel') {
spec.strategy = {
mode: strategyMode,
wait_timeout_secs: waitTimeout,
targets,
};
}
const acdPolicy = (this.form.spec.acd_policy || '').trim();
if (acdPolicy) {
spec.acd_policy = acdPolicy;
}
spec.voice_prompts = this.buildVoicePrompts();
return spec;
},
buildPayload() {
var payload = {
name: this.form.name?.trim(),
description: this.form.description?.trim() || null,
is_active: Boolean(this.form.is_active),
tags: this.tagList(),
spec: this.prepareSpec(),
};
return payload;
},
async submit() {
this.saving = true;
this.success = null;
this.error = null;
try {
const payload = this.buildPayload();
const isEdit = this.mode === 'edit';
const endpoint = isEdit && this.updateUrl ? this.updateUrl : this.createUrl;
const method = isEdit ? 'PATCH' : 'PUT';
const response = await fetch(endpoint, {
method,
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data?.message || this.tt('queue.save_failed'));
}
this.success = isEdit ? this.tt('queue.queue_updated') : this.tt('queue.queue_created');
if (!isEdit && data?.id) {
window.location.href = `${this.basePath}/queues/${data.id}`;
return;
}
} catch (err) {
console.error(err);
this.error = err?.message || this.tt('queue.save_failed');
} finally {
this.saving = false;
}
},
formatDate(value) {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString();
},
}));
});
</script>
{% endblock %}