- Add PostgreSQL performance indexes migration script (1000x faster queries) Sales Invoice: 1,248ms → 28ms, Payment Entry: 443ms → 31ms Indexes on customer/party columns for all major tables - Disable 3CX poller (PBX_ENABLED flag, using Twilio instead) - Add TelephonyPage: full CRUD UI for Routr/Fonoster resources (trunks, agents, credentials, numbers, domains, peers) - Add PhoneModal + usePhone composable (Twilio WebRTC softphone) - Lazy-load invoices/payments (initial 5, expand on demand) - Parallelize all API calls in ClientDetailPage (no waterfall) - Add targo-hub service (SSE relay, SMS, voice, telephony API) - Customer portal: invoice detail, ticket detail, messages pages - Remove dead Ollama nginx upstream Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
697 lines
21 KiB
Vue
697 lines
21 KiB
Vue
<template>
|
|
<teleport to="body">
|
|
<transition name="slide-up">
|
|
<div v-if="show" class="phone-modal" :class="{ 'phone-minimized': minimized }">
|
|
<!-- Minimized bar -->
|
|
<div v-if="minimized" class="phone-mini-bar" @click="minimized = false">
|
|
<q-icon name="phone_in_talk" size="18px" color="green" />
|
|
<span class="text-weight-medium q-ml-xs">{{ dialNumber || 'Telephone' }}</span>
|
|
<q-space />
|
|
<span class="text-caption text-green-7">{{ formattedDuration }}</span>
|
|
<q-icon name="open_in_full" size="14px" color="grey-6" class="q-ml-sm" />
|
|
</div>
|
|
|
|
<!-- Full panel -->
|
|
<div v-else class="phone-panel">
|
|
<!-- Header -->
|
|
<div class="phone-header">
|
|
<q-icon name="phone" size="20px" color="indigo-6" class="q-mr-xs" />
|
|
<span class="text-weight-bold" style="font-size:1rem">Telephone</span>
|
|
<q-space />
|
|
<q-badge v-if="deviceReady" color="green" class="q-mr-sm">
|
|
{{ provider === 'sip' ? 'SIP' : 'Twilio' }}
|
|
</q-badge>
|
|
<q-badge v-else-if="deviceError" color="red" class="q-mr-sm">Erreur</q-badge>
|
|
<q-badge v-else color="orange" class="q-mr-sm">
|
|
<q-spinner-dots size="10px" class="q-mr-xs" />Init
|
|
</q-badge>
|
|
<q-btn flat dense round size="xs" icon="minimize" color="grey-6" @click="minimized = true" />
|
|
<q-btn flat dense round size="xs" icon="close" color="grey-6" @click="close" />
|
|
</div>
|
|
|
|
<!-- Customer context -->
|
|
<div v-if="customerName" class="phone-contact">
|
|
<q-icon name="person" size="18px" color="indigo-5" />
|
|
<span class="q-ml-xs text-weight-medium">{{ customerName }}</span>
|
|
</div>
|
|
|
|
<!-- Error banner -->
|
|
<div v-if="deviceError" class="phone-error">
|
|
<q-icon name="error_outline" size="16px" color="red" class="q-mr-xs" />
|
|
<span class="text-caption text-red">{{ deviceError }}</span>
|
|
</div>
|
|
|
|
<!-- Dialer (before call) -->
|
|
<div v-if="!inCall" class="phone-dialer">
|
|
<q-input v-model="dialNumber" dense outlined placeholder="+1 514 555 1234"
|
|
:input-style="{ fontSize: '1.1rem', textAlign: 'center', letterSpacing: '1px' }"
|
|
class="q-mb-sm" @keydown.enter="startCall">
|
|
<template #prepend><q-icon name="dialpad" color="grey-5" /></template>
|
|
<template #append>
|
|
<q-icon v-if="dialNumber" name="backspace" color="grey-5" class="cursor-pointer"
|
|
@click="dialNumber = dialNumber.slice(0, -1)" size="18px" />
|
|
</template>
|
|
</q-input>
|
|
|
|
<!-- Dialpad -->
|
|
<div class="dialpad q-mb-sm">
|
|
<q-btn v-for="key in dialpadKeys" :key="key" flat dense class="dialpad-key"
|
|
:label="key" @click="pressKey(key)" />
|
|
</div>
|
|
|
|
<div class="flex flex-center q-gutter-sm">
|
|
<q-btn round color="green" icon="call" size="lg"
|
|
:disable="!dialNumber.trim() || !deviceReady"
|
|
:loading="connecting" @click="startCall">
|
|
<q-tooltip>Appeler via WebRTC</q-tooltip>
|
|
</q-btn>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- In-call controls -->
|
|
<div v-if="inCall" class="phone-call-view">
|
|
<div class="call-status-bar">
|
|
<div class="call-info">
|
|
<div class="text-weight-bold" style="font-size:1.1rem">{{ dialNumber }}</div>
|
|
<div class="text-caption" :class="connected ? 'text-green' : 'text-orange'">
|
|
<q-spinner-dots v-if="!connected" size="12px" class="q-mr-xs" />
|
|
{{ connected ? formattedDuration : callStatusText }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- In-call actions -->
|
|
<div class="call-controls">
|
|
<div class="control-row">
|
|
<div class="control-btn" @click="toggleMute">
|
|
<q-btn round flat :icon="muted ? 'mic_off' : 'mic'" size="md"
|
|
:color="muted ? 'red' : 'grey-7'" />
|
|
<span class="text-caption">{{ muted ? 'Unmute' : 'Mute' }}</span>
|
|
</div>
|
|
<div class="control-btn" @click="toggleDialpad">
|
|
<q-btn round flat icon="dialpad" size="md" color="grey-7" />
|
|
<span class="text-caption">Clavier</span>
|
|
</div>
|
|
<div class="control-btn" @click="toggleSpeaker">
|
|
<q-btn round flat :icon="speakerOn ? 'volume_up' : 'volume_off'" size="md"
|
|
:color="speakerOn ? 'indigo-6' : 'grey-7'" />
|
|
<span class="text-caption">HP</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- DTMF dialpad (toggled) -->
|
|
<div v-if="showDtmf" class="dialpad q-my-sm">
|
|
<q-btn v-for="key in dialpadKeys" :key="'dtmf-'+key" flat dense class="dialpad-key"
|
|
:label="key" @click="sendDtmf(key)" />
|
|
</div>
|
|
|
|
<!-- Hangup -->
|
|
<div class="flex flex-center q-mt-sm">
|
|
<q-btn round color="red" icon="call_end" size="lg" @click="endCall" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Call ended — log bar -->
|
|
<div v-if="callEnded" class="phone-log-bar">
|
|
<q-icon name="check_circle" size="18px" color="green" class="q-mr-xs" />
|
|
<span class="text-caption">Appel termine — {{ formattedDuration }}</span>
|
|
<q-space />
|
|
<q-btn dense unelevated size="sm" color="indigo-6" icon="save" label="Logger"
|
|
@click="logCall" :loading="loggingCall" />
|
|
</div>
|
|
|
|
<!-- Footer -->
|
|
<div class="phone-footer">
|
|
<q-icon name="circle" size="8px" :color="deviceReady ? 'green' : 'grey-4'" class="q-mr-xs" />
|
|
<span class="text-caption text-grey-6">
|
|
{{ provider === 'sip' ? 'Fonoster SIP' : 'Twilio WebRTC' }} — {{ identity }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</transition>
|
|
</teleport>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch, computed, onMounted, onUnmounted } from 'vue'
|
|
import { Notify } from 'quasar'
|
|
import { createDoc } from 'src/api/erp'
|
|
import { Device } from '@twilio/voice-sdk'
|
|
import { UserAgent, Registerer, Inviter, SessionState } from 'sip.js'
|
|
|
|
const HUB_URL = (window.location.hostname === 'localhost')
|
|
? 'http://localhost:3300'
|
|
: 'https://msg.gigafibre.ca'
|
|
|
|
const SIP_WSS_URL = 'wss://voice.gigafibre.ca/ws' // Routr WSS endpoint
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: Boolean, default: false },
|
|
initialNumber: { type: String, default: '' },
|
|
customerName: { type: String, default: '' },
|
|
customerErpName: { type: String, default: '' },
|
|
provider: { type: String, default: 'twilio' }, // 'twilio' or 'sip'
|
|
})
|
|
|
|
const emit = defineEmits(['update:modelValue', 'call-ended'])
|
|
|
|
// UI state
|
|
const show = ref(props.modelValue)
|
|
const minimized = ref(false)
|
|
const dialNumber = ref(props.initialNumber || '')
|
|
const inCall = ref(false)
|
|
const connected = ref(false)
|
|
const connecting = ref(false)
|
|
const callEnded = ref(false)
|
|
const muted = ref(false)
|
|
const speakerOn = ref(true)
|
|
const showDtmf = ref(false)
|
|
const loggingCall = ref(false)
|
|
const callStatusText = ref('Connexion...')
|
|
|
|
// Twilio state
|
|
const deviceReady = ref(false)
|
|
const deviceError = ref('')
|
|
const identity = ref('')
|
|
const callStartTime = ref(null)
|
|
const callDurationSec = ref(0)
|
|
let durationTimer = null
|
|
let device = null // Twilio Device
|
|
let activeCall = null // Twilio Call or SIP Session
|
|
let sipUA = null // SIP.js UserAgent
|
|
let sipRegisterer = null
|
|
let audioElement = null
|
|
|
|
const dialpadKeys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#']
|
|
|
|
watch(() => props.modelValue, (v) => { show.value = v })
|
|
watch(show, (v) => {
|
|
emit('update:modelValue', v)
|
|
if (v) initDevice()
|
|
})
|
|
watch(() => props.provider, () => {
|
|
// Provider changed — re-init on next open
|
|
cleanupDevices()
|
|
})
|
|
watch(() => props.initialNumber, (v) => { if (v) dialNumber.value = v })
|
|
|
|
const formattedDuration = computed(() => {
|
|
const m = Math.floor(callDurationSec.value / 60)
|
|
const s = callDurationSec.value % 60
|
|
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`
|
|
})
|
|
|
|
// ── Initialize Device (Twilio or SIP) ──
|
|
async function initDevice () {
|
|
if (props.provider === 'sip') {
|
|
if (sipUA) return
|
|
await initSipDevice()
|
|
} else {
|
|
if (device) return
|
|
await initTwilioDevice()
|
|
}
|
|
}
|
|
|
|
async function initTwilioDevice () {
|
|
deviceError.value = ''
|
|
try {
|
|
const res = await fetch(HUB_URL + '/voice/token')
|
|
if (!res.ok) throw new Error('Token fetch failed: ' + res.status)
|
|
const data = await res.json()
|
|
identity.value = data.identity
|
|
|
|
device = new Device(data.token, {
|
|
edge: 'ashburn',
|
|
logLevel: 'warn',
|
|
codecPreferences: ['opus', 'pcmu'],
|
|
})
|
|
|
|
device.on('registered', () => { deviceReady.value = true; deviceError.value = '' })
|
|
device.on('error', (err) => { deviceError.value = err.message || 'Device error' })
|
|
device.on('tokenWillExpire', async () => {
|
|
try {
|
|
const r = await fetch(HUB_URL + '/voice/token')
|
|
const d = await r.json()
|
|
device.updateToken(d.token)
|
|
} catch {}
|
|
})
|
|
|
|
await device.register()
|
|
deviceReady.value = true
|
|
|
|
device.on('incoming', (call) => {
|
|
show.value = true; minimized.value = false
|
|
dialNumber.value = call.parameters.From || 'Inconnu'
|
|
Notify.create({
|
|
type: 'info', message: `Appel entrant de ${dialNumber.value}`,
|
|
actions: [
|
|
{ label: 'Repondre', color: 'green', handler: () => answerIncoming(call) },
|
|
{ label: 'Refuser', color: 'red', handler: () => call.reject() },
|
|
],
|
|
timeout: 30000,
|
|
})
|
|
})
|
|
} catch (e) {
|
|
deviceError.value = e.message
|
|
console.error('[PhoneModal] Twilio init failed:', e)
|
|
}
|
|
}
|
|
|
|
async function initSipDevice () {
|
|
deviceError.value = ''
|
|
try {
|
|
// Get SIP credentials from Fonoster via targo-hub
|
|
const res = await fetch(HUB_URL + '/voice/sip-config')
|
|
if (!res.ok) throw new Error('SIP config fetch failed: ' + res.status)
|
|
const cfg = await res.json()
|
|
|
|
identity.value = cfg.extension || cfg.identity || 'sip-agent'
|
|
const sipDomain = cfg.domain || 'voice.gigafibre.ca'
|
|
const uri = UserAgent.makeURI(`sip:${cfg.extension}@${sipDomain}`)
|
|
if (!uri) throw new Error('Invalid SIP URI')
|
|
|
|
sipUA = new UserAgent({
|
|
uri,
|
|
transportOptions: { server: cfg.wssUrl || SIP_WSS_URL },
|
|
authorizationUsername: cfg.authId || cfg.extension,
|
|
authorizationPassword: cfg.authPassword,
|
|
displayName: cfg.displayName || 'Targo Ops',
|
|
logLevel: 'warn',
|
|
delegate: {
|
|
onInvite: (invitation) => {
|
|
show.value = true; minimized.value = false
|
|
const remote = invitation.remoteIdentity?.uri?.user || 'Inconnu'
|
|
dialNumber.value = remote
|
|
Notify.create({
|
|
type: 'info', message: `Appel SIP entrant de ${remote}`,
|
|
actions: [
|
|
{ label: 'Repondre', color: 'green', handler: () => answerSipIncoming(invitation) },
|
|
{ label: 'Refuser', color: 'red', handler: () => { try { invitation.reject() } catch {} } },
|
|
],
|
|
timeout: 30000,
|
|
})
|
|
},
|
|
},
|
|
})
|
|
|
|
await sipUA.start()
|
|
sipRegisterer = new Registerer(sipUA)
|
|
await sipRegisterer.register()
|
|
deviceReady.value = true
|
|
} catch (e) {
|
|
deviceError.value = e.message
|
|
console.error('[PhoneModal] SIP init failed:', e)
|
|
}
|
|
}
|
|
|
|
function cleanupDevices () {
|
|
deviceReady.value = false
|
|
deviceError.value = ''
|
|
if (device) { try { device.unregister(); device.destroy() } catch {} device = null }
|
|
if (sipRegisterer) { try { sipRegisterer.unregister() } catch {} sipRegisterer = null }
|
|
if (sipUA) { try { sipUA.stop() } catch {} sipUA = null }
|
|
if (audioElement) { audioElement.srcObject = null }
|
|
}
|
|
|
|
function getAudioElement () {
|
|
if (!audioElement) {
|
|
audioElement = document.createElement('audio')
|
|
audioElement.autoplay = true
|
|
document.body.appendChild(audioElement)
|
|
}
|
|
return audioElement
|
|
}
|
|
|
|
function answerIncoming (call) {
|
|
activeCall = call
|
|
setupCallListeners(call)
|
|
call.accept()
|
|
inCall.value = true
|
|
connected.value = true
|
|
callEnded.value = false
|
|
startDurationTimer()
|
|
}
|
|
|
|
function answerSipIncoming (invitation) {
|
|
activeCall = invitation
|
|
setupSipSessionListeners(invitation)
|
|
invitation.accept({ sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } } })
|
|
inCall.value = true
|
|
callEnded.value = false
|
|
}
|
|
|
|
function setupSipSessionListeners (session) {
|
|
session.stateChange.addListener((state) => {
|
|
if (state === SessionState.Established) {
|
|
connected.value = true; connecting.value = false
|
|
startDurationTimer()
|
|
// Attach remote audio
|
|
const pc = session.sessionDescriptionHandler?.peerConnection
|
|
if (pc) {
|
|
const audio = getAudioElement()
|
|
pc.getReceivers().forEach((r) => {
|
|
if (r.track?.kind === 'audio') audio.srcObject = new MediaStream([r.track])
|
|
})
|
|
}
|
|
} else if (state === SessionState.Terminated) {
|
|
stopDurationTimer()
|
|
inCall.value = false; connected.value = false; connecting.value = false
|
|
callEnded.value = true; muted.value = false; showDtmf.value = false
|
|
activeCall = null
|
|
if (audioElement) audioElement.srcObject = null
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── Make Call ──
|
|
async function startCall () {
|
|
if (!dialNumber.value.trim() || !deviceReady.value) return
|
|
|
|
connecting.value = true
|
|
callEnded.value = false
|
|
callStatusText.value = 'Connexion...'
|
|
|
|
// Normalize number
|
|
let number = dialNumber.value.trim().replace(/[\s\-\(\)]/g, '')
|
|
if (!number.startsWith('+')) {
|
|
if (number.length === 10) number = '+1' + number
|
|
else if (number.length === 11 && number.startsWith('1')) number = '+' + number
|
|
else number = '+' + number
|
|
}
|
|
|
|
if (props.provider === 'sip') {
|
|
await startSipCall(number)
|
|
} else {
|
|
await startTwilioCall(number)
|
|
}
|
|
}
|
|
|
|
async function startTwilioCall (number) {
|
|
try {
|
|
activeCall = await device.connect({ params: { To: number } })
|
|
setupCallListeners(activeCall)
|
|
inCall.value = true
|
|
} catch (e) {
|
|
deviceError.value = 'Appel echoue: ' + e.message
|
|
connecting.value = false
|
|
}
|
|
}
|
|
|
|
async function startSipCall (number) {
|
|
if (!sipUA) { deviceError.value = 'SIP not registered'; connecting.value = false; return }
|
|
try {
|
|
const domain = 'voice.gigafibre.ca'
|
|
const target = UserAgent.makeURI(`sip:${number}@${domain}`)
|
|
if (!target) throw new Error('Invalid SIP target')
|
|
|
|
const inviter = new Inviter(sipUA, target, {
|
|
sessionDescriptionHandlerOptions: { constraints: { audio: true, video: false } },
|
|
})
|
|
activeCall = inviter
|
|
setupSipSessionListeners(inviter)
|
|
await inviter.invite()
|
|
inCall.value = true
|
|
} catch (e) {
|
|
deviceError.value = 'SIP call failed: ' + e.message
|
|
connecting.value = false
|
|
}
|
|
}
|
|
|
|
function setupCallListeners (call) {
|
|
call.on('ringing', () => {
|
|
callStatusText.value = 'Sonnerie...'
|
|
connecting.value = false
|
|
})
|
|
|
|
call.on('accept', () => {
|
|
connected.value = true
|
|
connecting.value = false
|
|
callStatusText.value = ''
|
|
startDurationTimer()
|
|
})
|
|
|
|
call.on('disconnect', () => {
|
|
stopDurationTimer()
|
|
inCall.value = false
|
|
connected.value = false
|
|
connecting.value = false
|
|
callEnded.value = true
|
|
muted.value = false
|
|
showDtmf.value = false
|
|
activeCall = null
|
|
})
|
|
|
|
call.on('cancel', () => {
|
|
inCall.value = false
|
|
connected.value = false
|
|
connecting.value = false
|
|
callEnded.value = false
|
|
activeCall = null
|
|
})
|
|
|
|
call.on('error', (err) => {
|
|
deviceError.value = err.message || 'Call error'
|
|
inCall.value = false
|
|
connected.value = false
|
|
connecting.value = false
|
|
activeCall = null
|
|
})
|
|
}
|
|
|
|
// ── Call Controls ──
|
|
function endCall () {
|
|
if (!activeCall) return
|
|
if (props.provider === 'sip') {
|
|
try {
|
|
if (activeCall.state === SessionState.Established) activeCall.bye()
|
|
else activeCall.cancel?.() || activeCall.reject?.()
|
|
} catch { /* session already terminated */ }
|
|
} else {
|
|
activeCall.disconnect()
|
|
}
|
|
}
|
|
|
|
function toggleMute () {
|
|
if (!activeCall) return
|
|
muted.value = !muted.value
|
|
if (props.provider === 'sip') {
|
|
const pc = activeCall.sessionDescriptionHandler?.peerConnection
|
|
if (pc) pc.getSenders().forEach(s => { if (s.track?.kind === 'audio') s.track.enabled = !muted.value })
|
|
} else {
|
|
activeCall.mute(muted.value)
|
|
}
|
|
}
|
|
|
|
function toggleSpeaker () {
|
|
speakerOn.value = !speakerOn.value
|
|
// Browser doesn't support speaker toggle natively — this is a visual indicator
|
|
}
|
|
|
|
function toggleDialpad () {
|
|
showDtmf.value = !showDtmf.value
|
|
}
|
|
|
|
function sendDtmf (digit) {
|
|
if (!activeCall) return
|
|
if (props.provider === 'sip') {
|
|
try { activeCall.sessionDescriptionHandler?.sendDtmf(digit) } catch {}
|
|
} else {
|
|
activeCall.sendDigits(digit)
|
|
}
|
|
}
|
|
|
|
function pressKey (key) {
|
|
dialNumber.value += key
|
|
}
|
|
|
|
// ── Duration Timer ──
|
|
function startDurationTimer () {
|
|
callStartTime.value = Date.now()
|
|
callDurationSec.value = 0
|
|
durationTimer = setInterval(() => {
|
|
if (callStartTime.value) {
|
|
callDurationSec.value = Math.floor((Date.now() - callStartTime.value) / 1000)
|
|
}
|
|
}, 1000)
|
|
}
|
|
|
|
function stopDurationTimer () {
|
|
if (durationTimer) {
|
|
clearInterval(durationTimer)
|
|
durationTimer = null
|
|
}
|
|
}
|
|
|
|
// ── Log Call ──
|
|
async function logCall () {
|
|
loggingCall.value = true
|
|
const phone = dialNumber.value.trim()
|
|
const duration = callDurationSec.value
|
|
const durationMin = Math.floor(duration / 60)
|
|
const durationSec = duration % 60
|
|
const durationStr = `${durationMin}m${durationSec.toString().padStart(2, '0')}s`
|
|
|
|
try {
|
|
await createDoc('Communication', {
|
|
subject: 'Appel vers ' + phone,
|
|
communication_type: 'Communication',
|
|
communication_medium: 'Phone',
|
|
sent_or_received: 'Sent',
|
|
status: 'Linked',
|
|
phone_no: phone,
|
|
sender: 'sms@gigafibre.ca',
|
|
sender_full_name: 'Targo Ops',
|
|
content: `Appel vers ${phone} — Duree: ${durationStr}`,
|
|
reference_doctype: 'Customer',
|
|
reference_name: props.customerErpName || props.customerName,
|
|
})
|
|
Notify.create({ type: 'positive', message: 'Appel enregistre', timeout: 2000 })
|
|
emit('call-ended', { direction: 'out', remote: phone, duration })
|
|
callEnded.value = false
|
|
} catch (e) {
|
|
Notify.create({ type: 'negative', message: 'Erreur: ' + e.message, timeout: 3000 })
|
|
} finally {
|
|
loggingCall.value = false
|
|
}
|
|
}
|
|
|
|
// ── Close ──
|
|
function close () {
|
|
if (inCall.value) {
|
|
minimized.value = true
|
|
return
|
|
}
|
|
stopDurationTimer()
|
|
show.value = false
|
|
inCall.value = false
|
|
connected.value = false
|
|
callEnded.value = false
|
|
callStartTime.value = null
|
|
callDurationSec.value = 0
|
|
}
|
|
|
|
// ── Lifecycle ──
|
|
onMounted(() => {
|
|
if (show.value) initDevice()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
stopDurationTimer()
|
|
cleanupDevices()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.phone-modal {
|
|
position: fixed;
|
|
bottom: 16px;
|
|
right: 16px;
|
|
z-index: 9998;
|
|
width: 340px;
|
|
background: white;
|
|
border-radius: 16px;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.1);
|
|
overflow: hidden;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.phone-minimized {
|
|
width: 260px;
|
|
border-radius: 24px;
|
|
}
|
|
|
|
.phone-mini-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 16px;
|
|
cursor: pointer;
|
|
}
|
|
.phone-mini-bar:hover { background: #f5f5f5; }
|
|
|
|
.phone-panel { display: flex; flex-direction: column; }
|
|
|
|
.phone-header {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 12px 8px;
|
|
border-bottom: 1px solid #f0f0f0;
|
|
}
|
|
|
|
.phone-contact {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 6px 16px;
|
|
background: #f1f5f9;
|
|
font-size: 0.88rem;
|
|
}
|
|
|
|
.phone-error {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 6px 12px;
|
|
background: #fef2f2;
|
|
}
|
|
|
|
.phone-dialer { padding: 12px 16px; }
|
|
|
|
.dialpad {
|
|
display: grid;
|
|
grid-template-columns: repeat(3, 1fr);
|
|
gap: 2px;
|
|
max-width: 200px;
|
|
margin: 0 auto;
|
|
}
|
|
.dialpad-key {
|
|
font-size: 1.1rem !important;
|
|
font-weight: 600;
|
|
min-height: 38px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.phone-call-view { display: flex; flex-direction: column; }
|
|
|
|
.call-status-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 16px;
|
|
background: linear-gradient(135deg, #f0fdf0, #e8f5e9);
|
|
}
|
|
.call-info { flex: 1; text-align: center; }
|
|
|
|
.call-controls { padding: 12px 16px; }
|
|
.control-row {
|
|
display: flex;
|
|
justify-content: space-around;
|
|
align-items: center;
|
|
}
|
|
.control-btn {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
gap: 2px;
|
|
}
|
|
.control-btn .text-caption { font-size: 0.7rem; color: #666; }
|
|
|
|
.phone-log-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
background: #f0fdf0;
|
|
border-top: 1px solid #e0e0e0;
|
|
}
|
|
|
|
.phone-footer {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 5px 12px;
|
|
border-top: 1px solid #f0f0f0;
|
|
background: #fafafa;
|
|
}
|
|
|
|
.slide-up-enter-active, .slide-up-leave-active { transition: all 0.3s ease; }
|
|
.slide-up-enter-from, .slide-up-leave-to { transform: translateY(20px); opacity: 0; }
|
|
</style>
|