mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
feat(companies): improve requests and service line calls
This commit is contained in:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user