feat(companies): improve requests and service line calls

This commit is contained in:
Dominik
2026-08-17 00:34:08 +02:00
parent 335b9a98b5
commit ae8134bf98
14 changed files with 772 additions and 133 deletions
@@ -0,0 +1,87 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const client = readFileSync(
new URL('../../sky_phone/source/client/main.lua', import.meta.url),
'utf8',
)
const companiesServer = readFileSync(
new URL('../../sky_phone/source/server/companies.lua', import.meta.url),
'utf8',
)
const callsServer = readFileSync(
new URL('../../sky_phone/source/server/calls.lua', import.meta.url),
'utf8',
)
const companiesStore = readFileSync(
new URL('./stores/companies.ts', import.meta.url),
'utf8',
)
const apostrophe = String.fromCharCode(39)
const quote = String.fromCharCode(34)
function sourceBlock(source: string, startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('Companies outbound service-line call contract', () => {
it('exposes the dedicated callback through the NUI client bridge', () => {
expect(client).toContain(`${quote}companies:dial-service-line${quote}`)
})
it('accepts only a target number and derives the company from the live server member', () => {
const callback = sourceBlock(
companiesServer,
`Bridge.Callbacks.Register(${quote}sky_phone:companies:dial-service-line${quote}`,
'\n\nlocal function company_mutation_payload',
)
const storeAction = sourceBlock(
companiesStore,
'async dialServiceLine(',
'\n async mutateRequest(',
)
expect(callback).toContain(
`local phone_number = type(data) == ${quote}table${quote} and data.phoneNumber or nil`,
)
expect(callback).toContain('local member = call_member(source)')
expect(callback).toContain(
'SkyPhoneCalls.StartCompanyCall(source, member.company_id, phone_number)',
)
expect(callback).not.toContain('data.companyId')
expect(callback).not.toContain('data.callerNumber')
expect(storeAction).toContain(
`${apostrophe}companies:dial-service-line${apostrophe}`,
)
expect(storeAction).toContain('{\n phoneNumber,\n }')
expect(storeAction).not.toContain('companyId')
expect(storeAction).not.toContain('callerNumber')
})
it('revalidates call permission and presents the configured service number as caller ID', () => {
const startCompanyCall = sourceBlock(
callsServer,
'function SkyPhoneCalls.StartCompanyCall(',
`Bridge.Callbacks.Register(${quote}sky_phone:calls:dial${quote}`,
)
expect(startCompanyCall).toContain(
'SkyPhoneCompanies.CanPlaceCompanyCall(source, company_id)',
)
expect(startCompanyCall).toContain(
'SkyPhoneCompanies.GetServiceLineForCompany(company_id)',
)
expect(startCompanyCall).toContain(
'create_terminal_call(scope, number, target, target_status, service_line.number)',
)
expect(startCompanyCall).toContain('caller_number = service_line.number')
expect(startCompanyCall).not.toContain('data.companyId')
expect(startCompanyCall).not.toContain('data.callerNumber')
})
})
+24
View File
@@ -539,4 +539,28 @@ describe('companies store', () => {
subject: 'Help needed',
})
})
it('dials through the service line with only the target number and returns the server call state', async () => {
const call = {
direction: 'outgoing' as const,
id: 'company-call-1',
otherNumber: '5551110001',
speakerEnabled: false,
speakerSupported: true,
startedAt: 1_776_000_000,
state: 'ringing' as const,
}
mockNuiCall.mockResolvedValueOnce({ data: call, success: true })
const store = useCompaniesStore()
const response = await store.dialServiceLine(call.otherNumber)
expect(mockNuiCall).toHaveBeenCalledOnce()
expect(mockNuiCall).toHaveBeenCalledWith('companies:dial-service-line', {
phoneNumber: call.otherNumber,
})
expect(response).toEqual({ data: call, success: true })
expect(store.mutating).toBe(false)
expect(store.mutationError).toBe('')
})
})
+13
View File
@@ -524,6 +524,19 @@ export const useCompaniesStore = defineStore('companies', {
: (response.error ?? 'request_failed')
return response
},
async dialServiceLine(
phoneNumber: string,
): Promise<NuiResponse<PhoneCall>> {
this.mutating = true
const response = await nuiCall<PhoneCall>('companies:dial-service-line', {
phoneNumber,
})
this.mutating = false
this.mutationError = response.success
? ''
: (response.error ?? 'request_failed')
return response
},
async mutateRequest(
endpoint: string,
payload: Record<string, unknown>,
+7
View File
@@ -217,6 +217,13 @@ const companiesFallbackLocales = {
publicAvailability: 'Public Availability',
takeCalls: 'Take company calls',
takeCallsBody: 'Route new service-line calls to this active SIM.',
dialServiceLine: 'Call from service line',
dialServiceLineBody: 'Make an outgoing call that displays {number}.',
dialServiceLineHint:
'The recipient will see {number} as the incoming caller.',
targetNumber: 'Phone number',
targetNumberHint: 'Enter a phone number',
callNow: 'Call Now',
overview: 'Today at a Glance',
metrics: {
new: 'New',
@@ -0,0 +1,86 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./CompaniesApp.vue', import.meta.url),
'utf8',
)
const scriptSource = source.slice(0, source.indexOf('</script>'))
const requestBranchStart = source.indexOf(
'<template v-else-if="screen === \'request\'">',
)
const requestBranchEnd = source.indexOf(
"<SkyScrollArea\n v-else-if=\"screen === 'manager'",
requestBranchStart,
)
const requestBranchSource = source.slice(requestBranchStart, requestBranchEnd)
const requestScrollStart = requestBranchSource.indexOf('<SkyScrollArea')
const requestScrollEnd =
requestBranchSource.indexOf('</SkyScrollArea>', requestScrollStart) +
'</SkyScrollArea>'.length
const requestScrollSource = requestBranchSource.slice(
requestScrollStart,
requestScrollEnd,
)
const requestDockStart = requestBranchSource.indexOf(
'<footer',
requestScrollEnd,
)
const requestDockEnd =
requestBranchSource.indexOf('</footer>', requestDockStart) +
'</footer>'.length
const requestDockSource = requestBranchSource.slice(
requestDockStart,
requestDockEnd,
)
const styleSource = source.slice(source.indexOf('<style scoped>'))
describe('CompaniesApp request conversation contract', () => {
it('keeps one registered scroll owner and a separate fixed conversation dock', () => {
expect(requestBranchStart).toBeGreaterThan(-1)
expect(requestBranchEnd).toBeGreaterThan(requestBranchStart)
expect(requestBranchSource.match(/<SkyScrollArea\b/g)).toHaveLength(1)
expect(requestScrollSource).toContain(
'class="companies-content request-thread"',
)
expect(requestDockStart).toBeGreaterThan(requestScrollEnd)
expect(requestScrollSource).not.toContain('request-thread-actions')
expect(requestScrollSource).not.toContain('<SkyMessagebar')
expect(requestDockSource).toContain('class="request-thread-dock"')
expect(requestDockSource).toContain('class="request-thread-actions"')
expect(requestDockSource).toContain('<SkyMessagebar')
expect(requestDockSource.indexOf('request-thread-actions')).toBeLessThan(
requestDockSource.indexOf('<SkyMessagebar'),
)
})
it('does not introduce a nested vertical request scroller', () => {
expect(requestBranchSource).not.toContain('request-thread-scroll')
expect(styleSource).not.toMatch(
/\.request-thread(?:-content|-scroll)?\s*\{[^}]*\boverflow(?:-y)?\s*:\s*(?:auto|scroll)/s,
)
})
it('scrolls the sentinel after initial and newly received messages while respecting reduced motion', () => {
expect(scriptSource).toContain(
'const requestThreadBottom = ref<HTMLElement | null>(null)',
)
expect(requestScrollSource).toContain('ref="requestThreadBottom"')
expect(requestScrollSource).toContain('class="request-thread-bottom"')
expect(scriptSource).toContain(
'requestThreadBottom.value?.scrollIntoView({',
)
expect(scriptSource).toContain(
"window.matchMedia(\n '(prefers-reduced-motion: reduce)',",
)
expect(scriptSource).toContain(
"behavior: animate && !reduceMotion ? 'smooth' : 'auto'",
)
expect(scriptSource).toMatch(
/watch\(\s*\[\s*\(\) => companies\.request\?\.id \?\? ''[\s\S]*?companies\.request\?\.messages\.length \?\? 0[\s\S]*?messages\[messages\.length - 1\]!\.id[\s\S]*?scrollRequestThreadToBottom\(requestId === previousRequestId\)[\s\S]*?\{ flush: 'post' \},\s*\)/,
)
})
})
@@ -17,6 +17,10 @@ const requestFilterSource = source.slice(
source.indexOf('<div class="companies-segment-wrap">'),
) + '</div>'.length,
)
const availabilitySources =
source.match(
/<SkySegmented\s+class=\x22availability-segmented\x22[\s\S]*?<\/SkySegmented>/g,
) ?? []
describe('CompaniesApp Sky pill navigation contract', () => {
it('uses the full-width sliding glass navigation on the root screen', () => {
@@ -53,6 +57,7 @@ describe('CompaniesApp Sky pill navigation contract', () => {
expect(requestFilterSource).toContain('<SkySegmented')
expect(requestFilterSource).toContain('compact')
expect(requestFilterSource).toContain('navigation')
expect(requestFilterSource).toContain('strong')
expect(requestFilterSource).toContain(':item-count="2"')
expect(requestFilterSource).toContain(
':active-index="requestList === \'open\' ? 0 : 1"',
@@ -64,6 +69,12 @@ describe('CompaniesApp Sky pill navigation contract', () => {
})
it('uses compact sliding Glass for both availability controls', () => {
expect(availabilitySources).toHaveLength(2)
for (const availabilitySource of availabilitySources) {
expect(availabilitySource).toContain('compact')
expect(availabilitySource).toContain('navigation')
expect(availabilitySource).toContain('strong')
}
expect(source.match(/class="availability-segmented"/g)).toHaveLength(2)
expect(
source.match(
@@ -0,0 +1,59 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./CompaniesApp.vue', import.meta.url),
'utf8',
)
const testserverSource = readFileSync(
new URL('../../../testserver/index.cjs', import.meta.url),
'utf8',
)
const requestListClassIndex = source.indexOf('company-request-list')
const requestListStart = source.lastIndexOf(
'<SkyListCard',
requestListClassIndex,
)
const requestListSource = source.slice(
requestListStart,
source.indexOf('</SkyListCard>', requestListStart) + '</SkyListCard>'.length,
)
describe('CompaniesApp request card contract', () => {
it('keeps the screenshot hierarchy and request state visible', () => {
expect(requestListStart).toBeGreaterThan(-1)
expect(requestListSource).toMatch(/:header=\x22item\.companyName\x22/)
expect(requestListSource).toMatch(/:title=\x22item\.subject\x22/)
expect(requestListSource).toMatch(
/:subtitle=\x22requestSubtitle\(item\)\x22/,
)
expect(requestListSource).toMatch(/<ClipboardList :size=\x2219\x22/)
expect(requestListSource).toMatch(/v-if=\x22item\.unreadCount\x22/)
expect(requestListSource).toContain('item.unreadCount')
expect(requestListSource).toMatch(
/:class=\x22statusClass\(item\.status\)\x22/,
)
expect(requestListSource).toContain(
'Apps.companies.requestStatuses.${item.status}',
)
expect(requestListSource).toMatch(/openRequest\(item\.id, 'customer'\)/)
})
it('uses separate token-based request surfaces instead of hardcoded colors', () => {
expect(source).toMatch(
/\.company-request-list \{[\s\S]*?display: grid;[\s\S]*?gap: var\(--sky-space-2\);[\s\S]*?background: transparent;[\s\S]*?\}/,
)
expect(source).toMatch(
/\.company-request-list :deep\(\.sky-list-item\) \{[\s\S]*?border: 1px solid var\(--company-border\);[\s\S]*?border-radius: var\(--sky-radius-card\);[\s\S]*?background: var\(--company-surface\);[\s\S]*?\}/,
)
})
it('retains the in-progress reference request in the browser mock', () => {
expect(testserverSource).toMatch(/name: [\x22']Benny's Motor Works[\x22']/)
expect(testserverSource).toMatch(/subject: 'Vehicle will not start'/)
expect(testserverSource).toMatch(/serviceName: 'Roadside Assistance'/)
expect(testserverSource).toMatch(/status: 'in_progress'/)
expect(testserverSource).toContain('unreadCount: 1')
})
})
@@ -0,0 +1,76 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const source = readFileSync(
new URL('./CompaniesApp.vue', import.meta.url),
'utf8',
)
const apostrophe = String.fromCharCode(39)
const quote = String.fromCharCode(34)
function sourceBlock(startMarker: string, endMarker: string) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker, start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
return source.slice(start, end)
}
describe('CompaniesApp outbound service-line dialer contract', () => {
it('gates the work action and its opener by service-line permission', () => {
const openDialer = sourceBlock(
'function openServiceLineDialer()',
'\n\nfunction closeServiceLineDialer()',
)
const workAction = sourceBlock(
`phone.t(${apostrophe}Apps.companies.work.takeCalls${apostrophe})`,
`phone.t(${apostrophe}Apps.companies.manager.title${apostrophe})`,
)
expect(openDialer).toContain(
'if (!companies.workContext?.permissions.canTakeCalls) return',
)
expect(workAction).toContain(
`v-if=${quote}companies.workContext.permissions.canTakeCalls${quote}`,
)
expect(workAction).toContain(`@click=${quote}openServiceLineDialer${quote}`)
expect(workAction).toContain('Apps.companies.work.dialServiceLine')
})
it('uses a Sky sheet and telephone field for the target number', () => {
const sheet = sourceBlock(
`<div class=${quote}companies-sheet companies-service-line-sheet${quote}>`,
`<div class=${quote}companies-sheet companies-assignment-sheet${quote}>`,
)
expect(sheet).toContain('<SkySheet')
expect(sheet).toContain(`:opened=${quote}serviceLineSheetOpened${quote}`)
expect(sheet).toContain(
`<SkyList inset strong class=${quote}service-line-sheet__form${quote}>`,
)
expect(sheet).toContain('<SkyField')
expect(sheet).not.toMatch(/<SkyField\s+outline/)
expect(sheet).toContain(`type=${quote}tel${quote}`)
expect(sheet).toContain(`:value=${quote}serviceLineTarget${quote}`)
expect(sheet).toContain(`:disabled=${quote}!canDialServiceLine${quote}`)
expect(sheet).toContain(`@click=${quote}dialServiceLine${quote}`)
})
it('applies the authoritative call state before opening the Phone app', () => {
const dial = sourceBlock(
'async function dialServiceLine()',
'\n\nfunction syncManagerDraft(',
)
const applyIndex = dial.indexOf('calls.applyCallState(response.data)')
const routeIndex = dial.indexOf(
`await router.push(${apostrophe}/apps/phone${apostrophe})`,
)
expect(dial).toContain('companies.dialServiceLine(')
expect(dial).toContain('serviceLineTarget.value.trim()')
expect(applyIndex).toBeGreaterThanOrEqual(0)
expect(routeIndex).toBeGreaterThan(applyIndex)
})
})
+334 -133
View File
@@ -163,6 +163,8 @@ const threadDraft = ref('')
const workActionsOpened = ref(false)
const assignmentSheetOpened = ref(false)
const selectedMemberId = ref('')
const serviceLineSheetOpened = ref(false)
const serviceLineTarget = ref('')
const cancelDialogOpened = ref(false)
const conflictDialogOpened = ref(false)
const toastOpened = ref(false)
@@ -180,6 +182,7 @@ const announcementDraft = reactive({ body: '', expiresAt: '' })
const profileCoords = ref<CompanyCoordinates | null>(null)
const selectedLogoMedia = ref<PhoneMedia | null>(null)
const selectedCoverMedia = ref<PhoneMedia | null>(null)
const requestThreadBottom = ref<HTMLElement | null>(null)
let searchTimer: ReturnType<typeof setTimeout> | undefined
let toastTimer: ReturnType<typeof setTimeout> | undefined
@@ -224,6 +227,15 @@ const canSendThreadMessage = computed(
Boolean(threadDraft.value.trim()) &&
!companies.mutating,
)
const canDialServiceLine = computed(
() => Boolean(serviceLineTarget.value.trim()) && !companies.mutating,
)
const requestDockVisible = computed(() => {
const actions = companies.request?.actions
return Boolean(
actions && (actions.canCall || actions.canCancel || actions.canReply),
)
})
const navbarTitle = computed(() => {
if (screen.value === 'company') return activeCompany.value?.name ?? ''
if (screen.value === 'request') {
@@ -736,6 +748,30 @@ async function toggleCallAvailability(): Promise<void> {
)
}
function openServiceLineDialer(): void {
if (!companies.workContext?.permissions.canTakeCalls) return
serviceLineTarget.value = ''
serviceLineSheetOpened.value = true
}
function closeServiceLineDialer(): void {
serviceLineSheetOpened.value = false
}
async function dialServiceLine(): Promise<void> {
if (!canDialServiceLine.value) return
const response = await companies.dialServiceLine(
serviceLineTarget.value.trim(),
)
if (!response.success || !response.data) {
showToast(errorText(response.error))
return
}
calls.applyCallState(response.data)
closeServiceLineDialer()
await router.push('/apps/phone')
}
function syncManagerDraft(company: Company): void {
profileDraft.acceptsRequests = company.acceptsRequests
profileDraft.address = company.location?.address ?? ''
@@ -934,6 +970,21 @@ async function reloadConflict(): Promise<void> {
if (companies.request) await companies.loadRequest(companies.request.id)
}
async function scrollRequestThreadToBottom(animate: boolean): Promise<void> {
await nextTick()
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
if (screen.value !== 'request') return
const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)',
).matches
requestThreadBottom.value?.scrollIntoView({
behavior: animate && !reduceMotion ? 'smooth' : 'auto',
block: 'end',
})
}
watch([search, selectedCategory], ([, category], [, previousCategory]) => {
if (category === previousCategory) {
queueDirectoryLoad()
@@ -954,6 +1005,21 @@ watch(requestSheetOpened, async (opened) => {
await nextTick()
requestSheetContent.value?.scrollTo({ top: 0 })
})
watch(
[
() => companies.request?.id ?? '',
() => companies.request?.messages.length ?? 0,
() => {
const messages = companies.request?.messages ?? []
return messages.length ? messages[messages.length - 1]!.id : ''
},
],
([requestId], [previousRequestId]) => {
if (!requestId || screen.value !== 'request') return
void scrollRequestThreadToBottom(requestId === previousRequestId)
},
{ flush: 'post' },
)
watch(
() => route.query.requestId,
@@ -1206,6 +1272,7 @@ onBeforeUnmount(() => {
compact
navigation
rounded
strong
>
<SkySegmentedButton
:active="requestList === 'open'"
@@ -1371,6 +1438,7 @@ onBeforeUnmount(() => {
compact
navigation
rounded
strong
>
<SkySegmentedButton
v-for="availability in availabilityValues"
@@ -1402,6 +1470,23 @@ onBeforeUnmount(() => {
/>
</template>
</SkyListItem>
<SkyListItem
v-if="companies.workContext.permissions.canTakeCalls"
link
link-component="button"
:link-props="{ type: 'button' }"
:title="phone.t('Apps.companies.work.dialServiceLine')"
:subtitle="
phone.t('Apps.companies.work.dialServiceLineBody', {
number:
workCompany.phoneNumber ??
phone.t('Apps.companies.manager.noPhoneNumber'),
})
"
@click="openServiceLineDialer"
>
<template #media><Phone :size="20" /></template>
</SkyListItem>
<SkyListItem
v-if="companies.workContext.role === 'manager'"
link
@@ -1628,119 +1713,136 @@ onBeforeUnmount(() => {
</template>
</SkyScrollArea>
<SkyScrollArea
v-else-if="screen === 'request'"
padded
class="companies-content request-thread"
>
<SkyEmptyState
v-if="companies.requestLoading"
:title="phone.t('Apps.companies.loading.request')"
>
<template #icon><SkySpinner /></template>
</SkyEmptyState>
<SkyEmptyState
v-else-if="companies.requestError || !companies.request"
tone="danger"
:title="phone.t('Apps.companies.states.requestError')"
:body="errorText(companies.requestError)"
>
<template #icon><CircleAlert :size="34" /></template>
<template #actions>
<SkyButton rounded @click="goBack">
{{ phone.t('Apps.companies.back') }}
</SkyButton>
</template>
</SkyEmptyState>
<template v-else>
<div class="request-thread-scroll">
<SkyCard class="request-summary-card">
<span>
<small>{{ companies.request.companyName }}</small>
<strong>{{ companies.request.subject }}</strong>
</span>
<SkyBadge :class="statusClass(companies.request.status)">
{{
phone.t(
`Apps.companies.requestStatuses.${companies.request.status}`,
)
}}
</SkyBadge>
<p>{{ companies.request.description }}</p>
<span class="request-summary-card__meta">
{{
companies.request.serviceName ??
phone.t('Apps.companies.requests.generalService')
}}
· {{ formatDate(companies.request.createdAt) }}
</span>
</SkyCard>
<template v-else-if="screen === 'request'">
<SkyScrollArea padded class="companies-content request-thread">
<SkyEmptyState
v-if="companies.requestLoading"
:title="phone.t('Apps.companies.loading.request')"
>
<template #icon><SkySpinner /></template>
</SkyEmptyState>
<SkyEmptyState
v-else-if="companies.requestError || !companies.request"
tone="danger"
:title="phone.t('Apps.companies.states.requestError')"
:body="errorText(companies.requestError)"
>
<template #icon><CircleAlert :size="34" /></template>
<template #actions>
<SkyButton rounded @click="goBack">
{{ phone.t('Apps.companies.back') }}
</SkyButton>
</template>
</SkyEmptyState>
<template v-else>
<div class="request-thread-content">
<SkyCard class="request-summary-card">
<span>
<small>{{ companies.request.companyName }}</small>
<strong>{{ companies.request.subject }}</strong>
</span>
<SkyBadge :class="statusClass(companies.request.status)">
{{
phone.t(
`Apps.companies.requestStatuses.${companies.request.status}`,
)
}}
</SkyBadge>
<p>{{ companies.request.description }}</p>
<span class="request-summary-card__meta">
{{
companies.request.serviceName ??
phone.t('Apps.companies.requests.generalService')
}}
· {{ formatDate(companies.request.createdAt) }}
</span>
</SkyCard>
<div
v-if="companies.request.media.length"
class="request-media-strip"
:aria-label="phone.t('Apps.companies.requests.attachments')"
>
<img
v-for="media in companies.request.media"
:key="media.id"
:src="media.url"
:alt="phone.t('Apps.companies.requests.attachedPhoto')"
draggable="false"
loading="lazy"
referrerpolicy="no-referrer"
/>
</div>
<SkyBlockTitle>{{
phone.t('Apps.companies.requests.timeline')
}}</SkyBlockTitle>
<div class="request-timeline">
<div v-for="event in companies.request.events" :key="event.id">
<span><Check :size="12" /></span>
<p>
<strong>{{ eventLabel(event) }}</strong>
<small>{{ formatDate(event.createdAt) }}</small>
</p>
<div
v-if="companies.request.media.length"
class="request-media-strip"
:aria-label="phone.t('Apps.companies.requests.attachments')"
>
<img
v-for="media in companies.request.media"
:key="media.id"
:src="media.url"
:alt="phone.t('Apps.companies.requests.attachedPhoto')"
draggable="false"
loading="lazy"
referrerpolicy="no-referrer"
/>
</div>
</div>
<SkyBlockTitle>{{
phone.t('Apps.companies.requests.conversation')
}}</SkyBlockTitle>
<SkyMessages class="company-messages">
<SkyMessagesTitle v-if="!companies.request.messages.length">
{{ phone.t('Apps.companies.requests.noMessages') }}
</SkyMessagesTitle>
<SkyMessage
v-for="message in companies.request.messages"
:key="message.id"
:type="message.isMine ? 'sent' : 'received'"
:name="messageAuthorLabel(message.authorLabel)"
:text="message.body"
:text-footer="formatDate(message.createdAt)"
<SkyBlockTitle>{{
phone.t('Apps.companies.requests.timeline')
}}</SkyBlockTitle>
<div class="request-timeline">
<div v-for="event in companies.request.events" :key="event.id">
<span><Check :size="12" /></span>
<p>
<strong>{{ eventLabel(event) }}</strong>
<small>{{ formatDate(event.createdAt) }}</small>
</p>
</div>
</div>
<SkyBlockTitle>{{
phone.t('Apps.companies.requests.conversation')
}}</SkyBlockTitle>
<SkyMessages class="company-messages">
<SkyMessagesTitle v-if="!companies.request.messages.length">
{{ phone.t('Apps.companies.requests.noMessages') }}
</SkyMessagesTitle>
<SkyMessage
v-for="message in companies.request.messages"
:key="message.id"
:type="message.isMine ? 'sent' : 'received'"
:name="messageAuthorLabel(message.authorLabel)"
:text="message.body"
:text-footer="formatDate(message.createdAt)"
/>
</SkyMessages>
<span
ref="requestThreadBottom"
class="request-thread-bottom"
aria-hidden="true"
/>
</SkyMessages>
<div class="request-thread-actions">
<SkyButton
v-if="companies.request.actions.canCall"
outline
rounded
@click="callRequestParty"
>
<Phone :size="16" />{{ phone.t('Apps.companies.requests.call') }}
</SkyButton>
<SkyButton
v-if="companies.request.actions.canCancel"
outline
rounded
class="is-danger"
@click="cancelDialogOpened = true"
>
<X :size="16" />{{ phone.t('Apps.companies.requests.cancel') }}
</SkyButton>
</div>
</template>
</SkyScrollArea>
<footer
v-if="requestDockVisible && companies.request"
class="request-thread-dock"
:class="{
'request-thread-dock--actions-only':
!companies.request.actions.canReply,
}"
>
<div
v-if="
companies.request.actions.canCall ||
companies.request.actions.canCancel
"
class="request-thread-actions"
>
<SkyButton
v-if="companies.request.actions.canCall"
outline
rounded
@click="callRequestParty"
>
<Phone :size="16" />{{ phone.t('Apps.companies.requests.call') }}
</SkyButton>
<SkyButton
v-if="companies.request.actions.canCancel"
outline
rounded
class="is-danger"
@click="cancelDialogOpened = true"
>
<X :size="16" />{{ phone.t('Apps.companies.requests.cancel') }}
</SkyButton>
</div>
<SkyMessagebar
v-if="companies.request.actions.canReply"
@@ -1766,8 +1868,8 @@ onBeforeUnmount(() => {
</SkyToolbarPane>
</template>
</SkyMessagebar>
</template>
</SkyScrollArea>
</footer>
</template>
<SkyScrollArea
v-else-if="screen === 'manager' && workCompany"
@@ -1797,6 +1899,7 @@ onBeforeUnmount(() => {
compact
navigation
rounded
strong
>
<SkySegmentedButton
v-for="availability in availabilityValues"
@@ -2340,6 +2443,64 @@ onBeforeUnmount(() => {
</SkyActionGroup>
</SkyActionSheet>
<div class="companies-sheet companies-service-line-sheet">
<SkySheet
:opened="serviceLineSheetOpened"
:aria-label="phone.t('Apps.companies.work.dialServiceLine')"
@backdropclick="closeServiceLineDialer"
@escape="closeServiceLineDialer"
>
<section
v-if="serviceLineSheetOpened && workCompany"
class="companies-sheet__content service-line-sheet__content"
>
<header>
<span><PhoneCall :size="23" /></span>
<div>
<small>{{ workCompany.name }}</small>
<h2>{{ phone.t('Apps.companies.work.dialServiceLine') }}</h2>
</div>
<SkyLink
component="button"
icon-only
:aria-label="phone.t('Apps.companies.close')"
type="button"
@click="closeServiceLineDialer"
>
<X :size="19" />
</SkyLink>
</header>
<p class="service-line-sheet__body">
{{
phone.t('Apps.companies.work.dialServiceLineHint', {
number:
workCompany.phoneNumber ??
phone.t('Apps.companies.manager.noPhoneNumber'),
})
}}
</p>
<SkyList inset strong class="service-line-sheet__form">
<SkyField
type="tel"
:label="phone.t('Apps.companies.work.targetNumber')"
:placeholder="phone.t('Apps.companies.work.targetNumberHint')"
:value="serviceLineTarget"
@input="serviceLineTarget = eventValue($event)"
@keydown.enter.exact="handleEnterAction($event, dialServiceLine)"
/>
</SkyList>
<SkyButton
large
rounded
:disabled="!canDialServiceLine"
@click="dialServiceLine"
>
<Phone :size="18" />{{ phone.t('Apps.companies.work.callNow') }}
</SkyButton>
</section>
</SkySheet>
</div>
<div class="companies-sheet companies-assignment-sheet">
<SkySheet
:opened="assignmentSheetOpened"
@@ -2499,9 +2660,24 @@ onBeforeUnmount(() => {
box-shadow: none;
}
.company-list,
.company-list {
margin: 0 !important;
}
.company-request-list {
margin: 0 !important;
display: grid;
gap: var(--sky-space-2);
overflow: visible;
border-radius: 0;
background: transparent;
}
.company-request-list :deep(.sky-list-item) {
overflow: hidden;
border: 1px solid var(--company-border);
border-radius: var(--sky-radius-card);
background: var(--company-surface);
}
.company-list :deep(.sky-list-item__row),
@@ -2836,16 +3012,8 @@ onBeforeUnmount(() => {
border-color: rgba(255, 255, 255, 0.08);
}
.request-thread {
padding: 8px 0 0;
overflow: hidden;
}
.request-thread-scroll {
height: 100%;
padding: 4px 10px 88px;
overflow-y: auto;
scrollbar-width: none;
.request-thread-content {
min-height: 100%;
}
.request-summary-card :deep(> *) {
@@ -2952,11 +3120,30 @@ onBeforeUnmount(() => {
min-height: 100px;
}
.request-thread-bottom {
width: 100%;
height: 1px;
display: block;
}
.request-thread-dock {
position: relative;
z-index: 4;
flex: 0 0 auto;
background: var(--sky-bg);
}
.request-thread-actions {
margin: 14px 5px;
margin: var(--sky-space-2)
calc(var(--sky-page-gutter) + var(--sky-safe-area-right)) var(--sky-space-1)
calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-2);
}
.request-thread-actions > :only-child {
grid-column: 1 / -1;
}
.request-thread-actions .is-danger,
@@ -2964,17 +3151,16 @@ onBeforeUnmount(() => {
color: var(--company-red);
}
.company-messagebar {
position: absolute;
z-index: 4;
right: 0;
bottom: 0;
left: 0;
background: transparent;
.request-thread-dock--actions-only {
padding-bottom: calc(var(--sky-safe-area-bottom) + var(--sky-space-2));
}
.companies-app.sky-app-page--dark .company-messagebar {
background: transparent;
.company-messagebar {
position: relative;
z-index: auto;
right: auto;
bottom: auto;
left: auto;
}
.manager-screen {
@@ -3132,6 +3318,21 @@ onBeforeUnmount(() => {
font-size: 9px;
}
.service-line-sheet__body {
margin: 0 0 var(--sky-space-4);
color: var(--company-muted);
font-size: var(--sky-font-body);
line-height: 1.45;
}
.service-line-sheet__form {
margin: 0 0 var(--sky-space-4) !important;
}
.service-line-sheet__content > :deep(.sky-button) {
width: 100%;
}
.composer-service-title {
margin-top: 18px;
margin-bottom: 8px;
+42
View File
@@ -5527,6 +5527,48 @@ app.post('/api/:endpoint', (request, response) => {
)
return
}
if (endpoint === 'companies:dial-service-line') {
const context = companyWorkContext(testScenario)
if (!context.authorized || !context.permissions.canTakeCalls) {
response.json({ success: false, error: 'not_authorized' })
return
}
const phoneNumber = String(request.body.phoneNumber ?? '').replace(
/\D/g,
'',
)
if (
phoneNumber.length !== 10 ||
companyProfiles.some((company) => company.phoneNumber === phoneNumber)
) {
response.json({ success: false, error: 'invalid_number' })
return
}
const id = `company-call-${Date.now()}`
const startedAt = Date.now()
recentCalls.unshift({
call_id: id,
created_at: new Date(startedAt).toISOString(),
direction: 'outgoing',
duration_seconds: 0,
id: recentCalls.length + 1,
other_number: phoneNumber,
status: 'ringing',
})
response.json({
success: true,
data: {
direction: 'outgoing',
id,
otherNumber: phoneNumber,
speakerEnabled: false,
speakerSupported: true,
startedAt,
state: 'ringing',
},
})
return
}
if (endpoint === 'crewlink:bootstrap') {
response.json({ success: true, data: crewLinkBootstrap(testScenario) })
return
+10
View File
@@ -189,6 +189,16 @@ function verifyBrowserTestData(dataByEndpoint) {
}
async function verifyStatefulActions(baseUrl) {
const companyCall = await expectSuccess(
baseUrl,
'companies:dial-service-line',
{ phoneNumber: '5551110001' },
true,
)
assert.equal(companyCall.direction, 'outgoing')
assert.equal(companyCall.otherNumber, '5551110001')
assert.equal(companyCall.state, 'ringing')
let gallery = await expectSuccess(baseUrl, 'gallery:list', {}, true)
assert(gallery.length >= 10, 'gallery:list did not include enough test media')
assert(
+6
View File
@@ -825,6 +825,12 @@ Locales["en"] = {
publicAvailability = "Public Availability",
takeCalls = "Take company calls",
takeCallsBody = "Route new service-line calls to this active SIM.",
dialServiceLine = "Call from service line",
dialServiceLineBody = "Make an outgoing call that displays {number}.",
dialServiceLineHint = "The recipient will see {number} as the incoming caller.",
targetNumber = "Phone number",
targetNumberHint = "Enter a phone number",
callNow = "Call Now",
overview = "Today at a Glance",
metrics = {
new = "New",
+1
View File
@@ -223,6 +223,7 @@ local server_callbacks = {
"companies:publish-announcement",
"companies:set-call-availability",
"companies:call-customer",
"companies:dial-service-line",
"sim:insert",
"sim:eject",
"contacts:list",
+16
View File
@@ -2479,6 +2479,22 @@ Bridge.Callbacks.Register("sky_phone:companies:call-customer", function(source,
return result
end)
Bridge.Callbacks.Register("sky_phone:companies:dial-service-line", function(source, data)
local allowed, rate_error = allow_mutation(source, "dial_service_line", "RequestAction")
if not allowed then
return rate_error
end
local phone_number = type(data) == "table" and data.phoneNumber or nil
if type(phone_number) ~= "string" or phone_number == "" then
return { success = false, error = "invalid_number" }
end
local member = call_member(source)
if not member or member.definition.ServiceLine.CanCall ~= true then
return { success = false, error = "not_authorized" }
end
return SkyPhoneCalls.StartCompanyCall(source, member.company_id, phone_number)
end)
local function company_mutation_payload(source, company_id)
local company = company_payload(company_id, true)
if not company then