ENH - merge dev into Companies feature

This commit is contained in:
Leon.Schmidt
2026-08-11 16:31:27 +02:00
24 changed files with 5113 additions and 438 deletions
+12
View File
@@ -261,6 +261,9 @@ const notifications = useNotificationsStore()
const route = useRoute()
const router = useRouter()
const isAppRoute = computed(() => route.name === 'app')
const showActiveCallReturn = computed(
() => Boolean(calls.activeCall) && route.params.appId !== 'phone',
)
const appTransitionName = computed(() =>
route.query.transition === 'app-switch' ? 'app-switch' : 'app-window',
)
@@ -407,6 +410,7 @@ async function hydrateDevelopmentPhone(): Promise<void> {
sim: {
id: 'development-sim',
number: '5551234567',
removable: true,
registered: true,
type: 'registered',
},
@@ -974,6 +978,12 @@ function lockPhone(): void {
isLocked.value = true
}
function returnToActiveCall(): void {
if (!calls.activeCall) return
controlCenterOpened.value = false
void router.push('/apps/phone')
}
function unlockCamera(): void {
if (phone.security.enabled) {
pendingUnlockRoute.value = '/apps/camera'
@@ -1205,8 +1215,10 @@ onBeforeUnmount(() => {
>
<PhoneStatusBar
v-if="!isLocked"
:active-call-return="showActiveCallReturn"
:control-center-opened="controlCenterOpened"
lockable
@active-call="returnToActiveCall"
@control-center="toggleControlCenter"
@lock="lockPhone"
/>
+41
View File
@@ -467,6 +467,47 @@ button {
cursor: pointer;
pointer-events: auto;
}
.phone-status-bar__time-button--active-call {
isolation: isolate;
text-shadow: none;
}
.phone-status-bar__time-button--active-call::before {
position: absolute;
z-index: -1;
top: 19px;
left: calc(50% - 3px);
width: 90px;
height: 30px;
border-radius: 15px;
background: #34c759;
box-shadow: 0 2px 8px #0b6f2b66;
animation: phone-active-call-pill-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
content: '';
transform-origin: center;
transform: translateX(-50%);
transition: transform 140ms ease;
}
.phone-status-bar__time-button--active-call:active::before {
transform: translateX(-50%) scale(0.94);
}
.phone-status-bar__active-call-icon {
position: absolute;
top: 27px;
left: calc(50% - 39px);
width: 13px;
height: 13px;
stroke-width: 2.5;
}
@keyframes phone-active-call-pill-in {
from {
opacity: 0;
transform: translateX(-50%) scale(0.82);
}
to {
opacity: 1;
transform: translateX(-50%) scale(1);
}
}
.phone-status-bar__indicators {
position: absolute;
top: 0;
+31 -5
View File
@@ -1,17 +1,23 @@
<script setup lang="ts">
import { PhoneCall } from 'lucide-vue-next'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import PhoneStatusIndicators from '@/components/PhoneStatusIndicators.vue'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
withDefaults(
defineProps<{ controlCenterOpened?: boolean; lockable?: boolean }>(),
const props = withDefaults(
defineProps<{
activeCallReturn?: boolean
controlCenterOpened?: boolean
lockable?: boolean
}>(),
{
activeCallReturn: false,
controlCenterOpened: false,
lockable: false,
},
)
const emit = defineEmits<{ controlCenter: []; lock: [] }>()
const emit = defineEmits<{ activeCall: []; controlCenter: []; lock: [] }>()
const time = ref('')
let intervalId: number | undefined
@@ -23,6 +29,14 @@ function updateTime(): void {
}).format(new Date())
}
function handleTimeClick(): void {
if (props.activeCallReturn) {
emit('activeCall')
return
}
emit('lock')
}
onMounted(() => {
updateTime()
intervalId = window.setInterval(updateTime, 30_000)
@@ -38,10 +52,22 @@ onBeforeUnmount(() => {
<button
v-if="lockable"
class="phone-status-bar__time phone-status-bar__time-button"
:class="{
'phone-status-bar__time-button--active-call': activeCallReturn,
}"
type="button"
:aria-label="phone.t('LockScreen.label')"
@click.stop="emit('lock')"
:aria-label="
phone.t(
activeCallReturn ? 'Apps.phone.returnToCall' : 'LockScreen.label',
)
"
@click.stop="handleTimeClick"
>
<PhoneCall
v-if="activeCallReturn"
class="phone-status-bar__active-call-icon"
aria-hidden="true"
/>
<time>{{ time }}</time>
</button>
<time v-else class="phone-status-bar__time">{{ time }}</time>
+50
View File
@@ -68,4 +68,54 @@ describe('calls store', () => {
expect(calls.activeCall).toBeNull()
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
})
it('blocks the active caller and closes the call screen', async () => {
const calls = useCallsStore()
calls.applyCallState({
direction: 'incoming',
id: 'call-3',
otherNumber: '5551110025',
startedAt: 1,
state: 'ringing',
})
const response = await calls.blockNumber('5551110025')
expect(response.success).toBe(true)
expect(nuiCall).toHaveBeenCalledWith('calls:block', {
phoneNumber: '5551110025',
})
expect(calls.activeCall).toBeNull()
expect(nuiCall).toHaveBeenCalledWith('calls:recents')
})
it('updates a contact favorite and refreshes the contact list', async () => {
vi.mocked(nuiCall)
.mockResolvedValueOnce({
success: true,
data: { favorite: true, id: 'contact-alex' },
})
.mockResolvedValueOnce({
success: true,
data: [
{
favorite: true,
id: 'contact-alex',
name: 'Alex Rivera',
phone_number: '5551110001',
},
],
})
const calls = useCallsStore()
const response = await calls.setContactFavorite('contact-alex', true)
expect(response.success).toBe(true)
expect(nuiCall).toHaveBeenNthCalledWith(1, 'contacts:favorite', {
favorite: true,
id: 'contact-alex',
})
expect(nuiCall).toHaveBeenNthCalledWith(2, 'contacts:list')
expect(calls.contacts[0]?.favorite).toBe(true)
})
})
+47 -3
View File
@@ -35,8 +35,11 @@ export const useCallsStore = defineStore('calls', () => {
}
async function saveContact(contact: {
avatarMediaId?: number | null
id?: string
name: string
notes?: string
organization?: string
phoneNumber: string
}): Promise<NuiResponse<PhoneContact>> {
const response = await nuiCall<PhoneContact>('contacts:save', contact)
@@ -58,17 +61,56 @@ export const useCallsStore = defineStore('calls', () => {
async function answer(): Promise<NuiResponse> {
if (!activeCall.value) return { success: false, error: 'call_not_found' }
return nuiCall('calls:answer', { id: activeCall.value.id })
const response = await nuiCall('calls:answer', { id: activeCall.value.id })
if (response.success && activeCall.value) {
activeCall.value = {
...activeCall.value,
answeredAt: activeCall.value.answeredAt ?? Date.now(),
state: 'connected',
}
}
return response
}
async function decline(): Promise<boolean> {
if (!activeCall.value) return false
return (await nuiCall('calls:decline', { id: activeCall.value.id })).success
const response = await nuiCall('calls:decline', { id: activeCall.value.id })
if (response.success) activeCall.value = null
return response.success
}
async function setContactFavorite(
id: string,
favorite: boolean,
): Promise<NuiResponse<{ favorite: boolean; id: string }>> {
const response = await nuiCall<{ favorite: boolean; id: string }>(
'contacts:favorite',
{ favorite, id },
)
if (response.success) await loadContacts()
return response
}
async function hangup(): Promise<boolean> {
if (!activeCall.value) return false
return (await nuiCall('calls:hangup', { id: activeCall.value.id })).success
const response = await nuiCall('calls:hangup', { id: activeCall.value.id })
if (response.success) {
activeCall.value = null
await loadRecents()
}
return response.success
}
async function blockNumber(phoneNumber: string): Promise<NuiResponse> {
const response = await nuiCall('calls:block', { phoneNumber })
if (
response.success &&
activeCall.value?.otherNumber === phoneNumber
) {
activeCall.value = null
await loadRecents()
}
return response
}
function applyCallState(call: PhoneCall): void {
@@ -95,6 +137,7 @@ export const useCallsStore = defineStore('calls', () => {
answer,
applyCallState,
bootstrap,
blockNumber,
contacts,
decline,
deleteContact,
@@ -104,5 +147,6 @@ export const useCallsStore = defineStore('calls', () => {
loadRecents,
recents,
saveContact,
setContactFavorite,
}
})
+52
View File
@@ -1312,6 +1312,7 @@ const defaultLocales: LocaleTree = {
addContact: 'Add Contact',
contactSaved: 'Contact saved',
removeContact: 'Remove Contact',
returnToCall: 'Return to Call',
block: 'Block User',
unblock: 'Unblock User',
clearChat: 'Clear Chat',
@@ -1472,14 +1473,60 @@ const defaultLocales: LocaleTree = {
noSim: 'No SIM',
noSimBody: 'Insert a SIM card in Settings to make calls.',
noRecents: 'No Recent Calls',
allCalls: 'All',
missedCalls: 'Missed',
searchRecents: 'Search Calls',
contactDetails: 'Contact Details',
unknownCaller: 'Unknown Caller',
callHistory: 'Call History',
noCallHistory: 'No calls with this number yet.',
noContacts: 'No Contacts',
favorites: 'Favorites',
searchContacts: 'Search Contacts',
myCard: 'My Card',
myNumber: 'My Number',
device: 'Device',
contactIndex: 'Contact Index',
addContact: 'New Contact',
newContact: 'New',
editContact: 'Edit Contact',
contactName: 'Name',
firstName: 'First Name',
lastName: 'Last Name',
companyOrGroup: 'Company or Group',
phoneNumber: 'Phone Number',
officialContact: 'Official company contact',
choosePhoto: 'Choose Contact Photo',
chooseGallery: 'Gallery',
takePhoto: 'Camera',
removePhoto: 'Remove Photo',
call: 'Call',
message: 'Message',
video: 'Video',
mail: 'Mail',
defaultLabel: 'Default',
privateLabel: 'Private',
contactCard: 'Contact Card',
removeContact: 'Remove Contact',
mobile: 'Mobile',
messagesProfile: 'Messages',
notes: 'Notes',
sendMessage: 'Send Message',
shareContact: 'Share Contact',
addFavorite: 'Add to Favorites',
removeFavorite: 'Remove from Favorites',
addEmergency: 'Add to Emergency Contacts',
blockContact: 'Block Contact',
block: 'Block',
blockCaller: 'Block Caller',
blockCallerTitle: 'Block this caller?',
blockCallerBody: '{number} will no longer be able to call this SIM.',
faceTime: 'FaceTime',
hideKeypad: 'Hide Keypad',
more: 'More',
mute: 'Mute',
speaker: 'Speaker',
viewContact: 'View Contact',
calling: 'calling...',
incoming: 'Incoming Call',
incomingDirection: 'Incoming',
@@ -1508,9 +1555,14 @@ const defaultLocales: LocaleTree = {
invalid_contact: 'Enter a name and valid phone number.',
invalid_number: 'Enter a valid phone number.',
invalid_sim: 'The active SIM card is unavailable.',
message_unavailable: 'The conversation could not be opened.',
contact_remove_failed: 'The contact could not be removed.',
contact_favorite_failed: 'The favorite could not be updated.',
no_sim: 'This phone has no SIM card.',
airplane_mode: 'Turn off Airplane Mode to make calls.',
self_call: 'You cannot call your own number.',
blocked: 'This number is blocked.',
recipient_not_found: 'This number is not known.',
busy: 'The line is busy.',
company_unavailable: 'This company service line is unavailable.',
readonly_contact: 'Official company contacts cannot be changed.',
+6
View File
@@ -3,18 +3,24 @@ export type SimType = 'registered' | 'anonymous'
export type PhoneSim = {
id: string
number: string
removable: boolean
registered: boolean
type: SimType
}
export type PhoneContact = {
avatar_media_id?: number | null
avatar_url?: string | null
canCall?: boolean
canMessage?: boolean
companyId?: string
created_at?: string
favorite?: boolean | number
id: string
icon?: string
name: string
notes?: string | null
organization?: string | null
phone_number: string
readonly?: boolean
source?: 'personal' | 'company'
+14 -4
View File
@@ -288,6 +288,7 @@ onBeforeUnmount(() => {
</div>
<template v-else-if="billing.detail">
<kGlass
:highlight="false"
class="billing-detail__hero"
:class="{
'billing-detail__hero--paid': billing.detail.status === 'paid',
@@ -441,17 +442,26 @@ onBeforeUnmount(() => {
<template v-else-if="tab === 'overview' && billing.overview">
<div class="billing-summary">
<kGlass class="billing-summary__item billing-summary__item--open">
<kGlass
:highlight="false"
class="billing-summary__item billing-summary__item--open"
>
<ReceiptText :size="19" />
<span>{{ t('summary.open') }}</span>
<strong>{{ billing.overview.openCount }}</strong>
</kGlass>
<kGlass class="billing-summary__item billing-summary__item--due">
<kGlass
:highlight="false"
class="billing-summary__item billing-summary__item--due"
>
<CalendarDays :size="19" />
<span>{{ t('summary.due') }}</span>
<strong>{{ formatMoney(billing.overview.openTotal) }}</strong>
</kGlass>
<kGlass class="billing-summary__item billing-summary__item--overdue">
<kGlass
:highlight="false"
class="billing-summary__item billing-summary__item--overdue"
>
<AlertTriangle :size="19" />
<span>{{ t('summary.overdue') }}</span>
<strong>{{ billing.overview.overdueCount }}</strong>
@@ -743,7 +753,7 @@ onBeforeUnmount(() => {
/></span>
<h2>{{ t('payment.title') }}</h2>
<p>{{ t('payment.body', { issuer: billing.detail.issuerLabel }) }}</p>
<kGlass class="billing-payment-total">
<kGlass :highlight="false" class="billing-payment-total">
<span>{{ billing.detail.title }}</span>
<strong>{{
formatMoney(billing.detail.amount, billing.detail.currency)
+20 -36
View File
@@ -118,14 +118,24 @@ function queueCapture(id: string, mediaType: MediaType): void {
captures.value = captures.value.slice(0, 6)
}
function devMedia(id: string, mediaType: MediaType): PhoneMedia {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="900" height="1600"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#19354f"/><stop offset="1" stop-color="#d78357"/></linearGradient></defs><rect width="900" height="1600" fill="url(#g)"/><circle cx="680" cy="380" r="210" fill="#ffffff22"/><path d="M0 1200 260 840l180 220 170-170 290 310v400H0z" fill="#102331aa"/></svg>`
return {
createdAt: Date.now(),
id: Number(Date.now()),
mediaType,
url: `data:image/svg+xml,${encodeURIComponent(svg)}#${id}`,
}
async function completeDevelopmentCapture(
correlationId: string,
mediaType: MediaType,
): Promise<void> {
const response = await nuiCall<PhoneMedia>('media:devCapture', { mediaType })
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
correlationId,
error: response.error,
media: response.data,
success: response.success,
},
type: 'media:uploadResult',
},
}),
)
}
async function requestPhoto(): Promise<void> {
@@ -138,20 +148,7 @@ async function requestPhoto(): Promise<void> {
shutterActive.value = false
}, 280)
if (isDevelopment) {
window.setTimeout(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
correlationId: id,
media: devMedia(id, 'photo'),
success: true,
},
type: 'media:uploadResult',
},
}),
)
}, 700)
window.setTimeout(() => void completeDevelopmentCapture(id, 'photo'), 700)
return
}
await nuiCall('media:requestUpload', {
@@ -196,20 +193,7 @@ function stopRecording(): void {
savingVideo.value = true
if (recordingTimer !== undefined) window.clearInterval(recordingTimer)
recordingTimer = undefined
window.setTimeout(() => {
window.dispatchEvent(
new MessageEvent('message', {
data: {
data: {
correlationId: id,
media: devMedia(id, 'video'),
success: true,
},
type: 'media:uploadResult',
},
}),
)
}, 900)
window.setTimeout(() => void completeDevelopmentCapture(id, 'video'), 900)
return
}
window.postMessage(
+2
View File
@@ -407,6 +407,8 @@ async function saveContactDetails(): Promise<void> {
const response = await calls.saveContact({
id: activeContact.value?.id,
name: contactNameDraft.value.trim(),
notes: activeContact.value?.notes ?? '',
organization: activeContact.value?.organization ?? '',
phoneNumber: contactNumberDraft.value,
})
if (!response.success) {
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -1337,7 +1337,7 @@ onBeforeUnmount(() => {
"
/>
<k-list-item
v-if="phone.device?.sim"
v-if="phone.device?.sim?.removable"
:title="phone.t('Apps.settings.simType')"
:after="
phone.t(
@@ -1348,7 +1348,7 @@ onBeforeUnmount(() => {
"
/>
<k-list-button
v-if="phone.device?.sim"
v-if="phone.device?.sim?.removable"
@click="simEjectOpened = true"
>
{{ phone.t('Apps.settings.ejectSim') }}
@@ -1610,7 +1610,11 @@ onBeforeUnmount(() => {
</template>
</k-dialog>
<k-dialog :opened="simEjectOpened" @backdropclick="simEjectOpened = false">
<k-dialog
v-if="phone.device?.sim?.removable"
:opened="simEjectOpened"
@backdropclick="simEjectOpened = false"
>
<template #title>{{ phone.t('Apps.settings.ejectSim') }}</template>
<p>{{ phone.t('Apps.settings.ejectSimBody') }}</p>
<template #buttons>