删除communication自定义页面

This commit is contained in:
jingrow 2026-05-16 05:32:18 +08:00
parent d85b9539b6
commit 79903ac84c
4 changed files with 0 additions and 3477 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,274 +0,0 @@
<template>
<div class="toolbar">
<div class="filters">
<n-input
v-model:value="searchQueryModel"
:placeholder="t('Search')"
clearable
style="width: 200px"
/>
</div>
<div class="view-toggle">
<button
class="toggle-btn"
:class="{ active: viewModeModel === '2col' }"
@click="viewModeModel = '2col'"
:title="t_ctx('List')"
>
<Icon icon="tabler:layout-list" :size="16" />
</button>
<button
class="toggle-btn"
:class="{ active: viewModeModel === '3col' }"
@click="viewModeModel = '3col'"
:title="t_ctx('Preview')"
>
<Icon icon="tabler:layout-columns" :size="16" />
</button>
</div>
<button class="refresh-btn" @click="reloadHandler" :disabled="loadingModel">
<i :class="loadingModel ? 'fa fa-spinner fa-spin' : 'fa fa-refresh'"></i>
</button>
<button
v-if="selectedKeysModel.length === 0"
class="create-btn email-compose-btn"
@click="openComposeEmail"
:disabled="loadingModel"
>
<i class="fa fa-plus"></i>
{{ t('New Email') }}
</button>
<button
v-else
class="delete-btn"
@click="deleteSelectedHandler"
:disabled="loadingModel"
>
<i class="fa fa-trash"></i>
{{ t('Delete Selected') }} ({{ selectedKeysModel.length }})
</button>
<!-- Email Compose Drawer -->
<EmailComposeModal ref="composeRef" @sent="onEmailSent" />
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { NInput } from 'naive-ui'
import { Icon } from '@iconify/vue'
import { t } from '@/shared/i18n'
import EmailComposeModal from '@/shared/components/EmailComposeModal.vue'
interface Props {
context?: {
entity?: string
searchQuery?: string
viewMode?: '2col' | '3col'
selectedKeys?: string[]
loading?: boolean
canEdit?: boolean
reload?: () => void
createRecordHandler?: () => void
handleDeleteSelected?: () => void
router?: any
t?: any
}
// Also support direct props (same as DefaultListToolbar)
entity?: string
searchQuery?: string
viewMode?: '2col' | '3col'
selectedKeys?: string[]
loading?: boolean
canEdit?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'update:searchQuery', value: string): void
(e: 'update:viewMode', value: '2col' | '3col'): void
(e: 'reload'): void
(e: 'create'): void
(e: 'delete-selected'): void
}>()
const composeRef = ref<InstanceType<typeof EmailComposeModal> | null>(null)
// 使使
const t_ctx = computed(() => props.context?.t ?? t)
// Resolve values from context or direct props
const searchQueryModel = computed({
get: () => props.context?.searchQuery ?? props.searchQuery ?? '',
set: (value) => emit('update:searchQuery', value),
})
const viewModeModel = computed({
get: () => props.context?.viewMode ?? props.viewMode ?? '3col',
set: (value) => emit('update:viewMode', value),
})
const selectedKeysModel = computed(() => props.context?.selectedKeys ?? props.selectedKeys ?? [])
const loadingModel = computed(() => props.context?.loading ?? props.loading ?? false)
function reloadHandler() {
if (props.context?.reload) props.context.reload()
else emit('reload')
}
function deleteSelectedHandler() {
if (props.context?.handleDeleteSelected) props.context.handleDeleteSelected()
else emit('delete-selected')
}
function openComposeEmail() {
composeRef.value?.open()
}
function onEmailSent(_data: any) {
// Reload list to show the new communication
reloadHandler()
}
</script>
<style scoped>
.toolbar {
display: flex;
align-items: center;
gap: 12px;
}
.filters {
display: flex;
gap: 8px;
align-items: center;
}
.view-toggle {
display: flex;
background: #f8fafc;
border-radius: 8px;
padding: 2px;
border: 1px solid #e2e8f0;
}
.toggle-btn {
width: 32px;
height: 32px;
border: none;
background: transparent;
color: #6b7280;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
}
.toggle-btn:hover {
background: #f1f5f9;
color: #475569;
}
.toggle-btn.active {
background: #e2e8f0;
color: #1e293b;
}
.refresh-btn {
width: 36px;
height: 36px;
border: none;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
transition: all 0.2s;
}
.refresh-btn:hover {
background: #e2e8f0;
color: #475569;
}
.refresh-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 新建邮件按钮 - 与默认列表页创建按钮风格一致 */
.email-compose-btn {
height: 36px;
padding: 0 16px;
border: 1px solid #1fc76f;
border-radius: 8px;
background: #e6f8f0;
color: #0d684b;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 500;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
}
.email-compose-btn:hover {
background: #dcfce7;
border-color: #1fc76f;
color: #166534;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(31, 199, 111, 0.15);
}
.email-compose-btn:active {
background: #1fc76f;
border-color: #1fc76f;
color: white;
transform: translateY(0);
box-shadow: 0 1px 4px rgba(31, 199, 111, 0.2);
}
.email-compose-btn:disabled {
background: #f1f5f9;
border-color: #e2e8f0;
color: #94a3b8;
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.email-compose-btn i {
font-size: 12px;
}
.delete-btn {
background: #ef4444;
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.delete-btn:hover {
background: #dc2626;
}
.delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@ -1,833 +0,0 @@
<template>
<n-space align="center">
<!-- Previous / Next -->
<n-button
type="default"
size="medium"
@click="goToPrev"
:disabled="loading || fetching || !hasPrev"
:title="t('Previous')"
class="header-action-btn"
>
<template #icon>
<n-icon><Icon icon="tabler:chevron-left" /></n-icon>
</template>
</n-button>
<n-button
type="default"
size="medium"
@click="goToNext"
:disabled="loading || fetching || !hasNext"
:title="t('Next')"
class="header-action-btn"
>
<template #icon>
<n-icon><Icon icon="tabler:chevron-right" /></n-icon>
</template>
</n-button>
<!-- Refresh -->
<n-button
type="default"
size="medium"
@click="$emit('refresh')"
:disabled="loading || isNew"
:title="t('Refresh')"
class="header-action-btn"
>
<template #icon>
<n-icon><Icon icon="tabler:refresh" /></n-icon>
</template>
</n-button>
<!-- ===== Email-specific actions ===== -->
<!-- Reference doc link -->
<n-button
v-if="record?.reference_pagetype && record?.reference_name"
type="default"
size="medium"
@click="goToReference"
:title="record.reference_name"
class="header-action-btn"
>
<template #icon>
<n-icon><Icon icon="tabler:file-text" /></n-icon>
</template>
{{ record.reference_name }}
</n-button>
<!-- Delete -->
<n-button
type="default"
size="medium"
@click="$emit('delete')"
:disabled="loading || isNew"
:title="t('Delete')"
class="header-action-btn delete-btn"
>
<template #icon>
<n-icon><Icon icon="tabler:trash" /></n-icon>
</template>
</n-button>
<!-- Like -->
<n-button
type="default"
size="medium"
@click="handleToggleLike"
:disabled="loading || isNew"
:loading="likeLoading"
:title="isLiked ? t('Unlike') : t('Like')"
class="header-action-btn like-action-btn"
:class="{ 'is-liked': isLiked }"
>
<template #icon>
<n-icon><Icon :icon="isLiked ? 'tabler:heart-filled' : 'tabler:heart'" /></n-icon>
</template>
</n-button>
<!-- Back -->
<n-button type="default" size="medium" @click="$emit('go-back')" :disabled="loading">
<template #icon>
<n-icon><Icon icon="tabler:arrow-left" /></n-icon>
</template>
{{ t('Back') }}
</n-button>
<!-- Reply -->
<n-button
v-if="canReply"
type="default"
size="medium"
@click="reply"
:disabled="loading"
class="reply-btn"
>
<template #icon>
<n-icon><Icon icon="tabler:arrow-back-up" /></n-icon>
</template>
{{ t('Reply') }}
</n-button>
<!-- Forward -->
<n-button
v-if="canReply"
type="default"
size="medium"
@click="forward"
:disabled="loading"
class="header-action-btn"
>
<template #icon>
<n-icon><Icon icon="tabler:arrow-forward-up" /></n-icon>
</template>
{{ t('Forward') }}
</n-button>
<!-- More menu -->
<n-dropdown
:options="moreOptions"
trigger="click"
:render-icon="renderMenuIcon"
@select="handleMoreSelect"
>
<n-button type="default" size="medium" :title="t('More')" class="header-action-btn">
<template #icon>
<n-icon><Icon icon="tabler:dots" /></n-icon>
</template>
</n-button>
</n-dropdown>
<!-- Email Compose Drawer -->
<EmailComposeModal ref="composeRef" @sent="onEmailSent" />
<!-- Relink Dialog -->
<n-modal v-model:show="showRelinkDialog" preset="dialog" :title="t('Relink Communication')" style="width: 480px;" :auto-focus="false">
<n-form ref="relinkFormRef" :model="relinkForm" :rules="relinkRules" label-placement="top">
<n-form-item :label="t('Reference Pagetype')" path="reference_pagetype" label-placement="top">
<LinkField
v-model="relinkForm.reference_pagetype"
:options="'PageType'"
:enable-create="false"
:get-query="() => ({ filters: { istable: 0 } })"
/>
</n-form-item>
<n-form-item :label="t('Reference Name')" path="reference_name" label-placement="top">
<LinkField
v-model="relinkForm.reference_name"
:options="relinkForm.reference_pagetype || ''"
:enable-create="false"
:disabled="!relinkForm.reference_pagetype"
/>
</n-form-item>
</n-form>
<template #action>
<n-button @click="showRelinkDialog = false">{{ t('Cancel') }}</n-button>
<n-button type="primary" :loading="relinking" @click="confirmRelink">
{{ t('Relink') }}
</n-button>
</template>
</n-modal>
<!-- Move Dialog -->
<n-modal v-model:show="showMoveDialog" preset="dialog" :title="t('Move Email')" style="width: 400px;">
<n-form-item :label="t('Email Account')" path="email_account">
<n-select
v-model:value="moveTargetAccount"
:options="emailAccountOptions"
filterable
placeholder=""
:loading="loadingAccounts"
/>
</n-form-item>
<template #action>
<n-button @click="showMoveDialog = false">{{ t('Cancel') }}</n-button>
<n-button type="primary" :loading="moving" @click="confirmMove">
{{ t('Move') }}
</n-button>
</template>
</n-modal>
</n-space>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch, h } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
NButton, NSpace, NIcon, NDropdown, NModal, NForm, NFormItem, NSelect,
useMessage, type DropdownOption, type FormRules, type FormInst,
} from 'naive-ui'
import { Icon } from '@iconify/vue'
import { t } from '@/shared/i18n'
import { usePageTypeSlug, pageTypeToSlug } from '@/shared/utils/slug'
import { getRecords, renameRecord, duplicateRecord, api } from '@/shared/api/common'
import { toggleLike as toggleLikeApi } from '@/shared/api/timeline'
import { useAuthStore } from '@/shared/stores/auth'
import EmailComposeModal from '@/shared/components/EmailComposeModal.vue'
import LinkField from '@/shared/components/LinkField.vue'
interface Props {
entity: string
id: string
record?: any
canEdit: boolean
loading: boolean
isSubmittable?: boolean
pageStatus?: number
sidebarPosition?: 'left' | 'right'
pageMeta?: any
_inPanel?: boolean
}
interface Emits {
(e: 'toggle-sidebar-position'): void
(e: 'refresh'): void
(e: 'delete'): void
(e: 'go-back'): void
(e: 'save'): void
(e: 'submit'): void
(e: 'cancel-doc'): void
(e: 'rename', newName: string): void
(e: 'duplicate', newName: string): void
(e: 'prev', name: string): void
(e: 'next', name: string): void
}
const props = withDefaults(defineProps<Props>(), {
sidebarPosition: 'left',
record: () => ({}),
isSubmittable: false,
pageStatus: -1,
pageMeta: () => ({}),
})
const emit = defineEmits<Emits>()
const message = useMessage()
const authStore = useAuthStore()
// ===== Email Compose =====
const composeRef = ref<InstanceType<typeof EmailComposeModal> | null>(null)
// ===== Email context flags =====
const isEmail = computed(() =>
props.record?.communication_type === 'Communication' &&
props.record?.communication_medium === 'Email'
)
const isReceived = computed(() => props.record?.sent_or_received === 'Received')
const canReply = computed(() => isEmail.value && isReceived.value && !props.isNew)
const isOpen = computed(() => props.record?.status === 'Open')
const isLinked = computed(() => props.record?.status === 'Linked')
const isSpam = computed(() => props.record?.email_status === 'Spam')
const isTrash = computed(() => props.record?.email_status === 'Trash')
const isSeen = computed(() => props.record?.seen)
const sender = computed(() => props.record?.sender || '')
const senderFullName = computed(() => props.record?.sender_full_name || '')
const subject = computed(() => props.record?.subject || '')
const cc = computed(() => props.record?.cc || '')
const recipients = computed(() => props.record?.recipients || '')
const emailAccount = computed(() => props.record?.email_account || '')
const originalComment = computed(() => props.record?.content || '')
const attachments = computed(() => props.record?.attachments || [])
// ===== Like =====
const likeLoading = ref(false)
const currentUser = computed(() => authStore.user?.username || authStore.user?.name || '')
const likedBy = computed<string[]>(() => {
const raw = props.record?._liked_by
if (!raw) return []
if (typeof raw === 'string') { try { return JSON.parse(raw) } catch { return [] } }
return Array.isArray(raw) ? raw : []
})
const isLiked = computed(() => likedBy.value.includes(currentUser.value))
async function handleToggleLike() {
if (props.isNew || likeLoading.value) return
likeLoading.value = true
try {
await toggleLikeApi(props.entity, props.id, isLiked.value ? 'No' : 'Yes')
emit('refresh')
} catch (err: any) {
message.error(err?.message || t('Failed to toggle like'))
} finally {
likeLoading.value = false
}
}
// ===== Navigation =====
const route = useRoute()
const router = useRouter()
const { pagetypeSlug } = usePageTypeSlug(route)
const isNew = computed(() => props.id === 'new' || props.id.startsWith('new-'))
const listStateKey = computed(() => `list-state:${props.entity}`)
const rowNames = ref<string[]>([])
const fetching = ref(false)
const loadedStart = ref(0)
const hasMoreNext = ref(false)
const hasMorePrev = ref(false)
const pageSize = parseInt(localStorage.getItem('itemsPerPage') || '10')
const currentIndex = computed(() => {
if (!props.id || rowNames.value.length === 0) return -1
return rowNames.value.indexOf(props.id)
})
const hasPrev = computed(() => {
if (isNew.value) return false
return currentIndex.value > 0 || hasMorePrev.value
})
const hasNext = computed(() => {
if (isNew.value || currentIndex.value < 0) return false
return currentIndex.value < rowNames.value.length - 1 || hasMoreNext.value
})
function readListState(): any | null {
const saved = sessionStorage.getItem(listStateKey.value)
if (!saved) return null
try { return JSON.parse(saved) } catch { return null }
}
function loadRowNames() {
const state = readListState()
if (!state?.rowNames?.length) return
rowNames.value = state.rowNames
loadedStart.value = ((state.page || 1) - 1) * pageSize
hasMorePrev.value = loadedStart.value > 0
hasMoreNext.value = state.rowNames.length >= pageSize
}
function navigateTo(name: string) {
router.push({
name: 'PageTypeDetail',
params: { entity: pagetypeSlug.value, id: name },
state: { _listPage: history.state?._listPage || 1 }
})
}
function buildApiFilters(filters: Record<string, any>): any[] {
if (!filters) return []
return Object.entries(filters)
.filter(([, v]) => v !== null && v !== undefined && v !== '' && !(Array.isArray(v) && v.length === 0))
.map(([k, v]) => typeof v === 'string' ? [k, 'like', `%${v}%`] : Array.isArray(v) ? [k, 'in', v] : [k, '=', v])
}
async function fetchPage(direction: 'next' | 'prev'): Promise<string[]> {
const state = readListState()
if (!state || state.searchQuery) return []
const orderBy = 'communication_date desc'
const filters = buildApiFilters(state.filters)
const ps = pageSize
let limitStart: number, limitLength: number
if (direction === 'next') {
limitStart = loadedStart.value + rowNames.value.length
limitLength = ps
} else {
limitLength = Math.min(ps, loadedStart.value)
limitStart = loadedStart.value - limitLength
if (limitLength <= 0) return []
}
const res = await getRecords(props.entity, filters, ['name'], orderBy, limitStart, limitLength)
if (!res.success || !res.data?.length) {
if (direction === 'next') hasMoreNext.value = false
else hasMorePrev.value = false
return []
}
const newNames = res.data.map((r: any) => r.name)
if (direction === 'next') {
rowNames.value = [...rowNames.value, ...newNames]
hasMoreNext.value = newNames.length >= ps
} else {
rowNames.value = [...newNames, ...rowNames.value]
loadedStart.value -= newNames.length
hasMorePrev.value = loadedStart.value > 0
}
try {
state.rowNames = rowNames.value
sessionStorage.setItem(listStateKey.value, JSON.stringify(state))
} catch {}
return newNames
}
async function goToPrev() {
if (fetching.value) return
if (currentIndex.value > 0) {
const targetName = rowNames.value[currentIndex.value - 1]
emit('prev', targetName)
if (!props._inPanel) navigateTo(targetName)
} else if (hasMorePrev.value) {
fetching.value = true
try {
const newNames = await fetchPage('prev')
if (newNames.length) {
const idx = rowNames.value.indexOf(props.id)
if (idx > 0) {
const target = rowNames.value[idx - 1]
emit('prev', target)
if (!props._inPanel) navigateTo(target)
}
}
} finally { fetching.value = false }
}
}
async function goToNext() {
if (fetching.value) return
if (currentIndex.value >= 0 && currentIndex.value < rowNames.value.length - 1) {
const targetName = rowNames.value[currentIndex.value + 1]
emit('next', targetName)
if (!props._inPanel) navigateTo(targetName)
} else if (hasMoreNext.value) {
fetching.value = true
try {
const newNames = await fetchPage('next')
if (newNames.length) {
const idx = currentIndex.value + 1
if (idx < rowNames.value.length) {
const target = rowNames.value[idx]
emit('next', target)
if (!props._inPanel) navigateTo(target)
}
}
} finally { fetching.value = false }
}
}
// ===== Reference link =====
function goToReference() {
if (!props.record?.reference_pagetype || !props.record?.reference_name) return
router.push({
name: 'PageTypeDetail',
params: {
entity: props.record.reference_pagetype.replace(/ /g, '-').toLowerCase(),
id: props.record.reference_name,
}
})
}
// ===== Email actions =====
function getMailArgs() {
return {
pg: props.record,
lastEmail: props.record,
sender: getSenderEmailId(),
attachments: attachments.value,
referencePagetype: props.record?.reference_pagetype,
referenceName: props.record?.reference_name,
doc: props.record,
}
}
function getSenderEmailId(): string {
const bootAccounts = (window as any).jingrow?.boot?.email_accounts || []
for (const account of bootAccounts) {
if (account.email_account === emailAccount.value) {
return account.email_id || ''
}
}
return ''
}
function reply() {
composeRef.value?.open({
...getMailArgs(),
isReply: true,
replyAll: false,
defaultSubject: subject.value.startsWith('Re:') ? subject.value : `Re: ${subject.value}`,
defaultRecipients: sender.value,
defaultContent: originalComment.value,
})
}
function replyAll() {
const selfEmail = getSenderEmailId()
const allRecipients = splitEmailList(recipients.value)
const allCc = splitEmailList(cc.value)
const otherRecipients = [...allRecipients, ...allCc].filter(e => e !== selfEmail)
composeRef.value?.open({
...getMailArgs(),
isReply: true,
replyAll: true,
defaultSubject: subject.value.startsWith('Re:') ? subject.value : `Re: ${subject.value}`,
defaultRecipients: sender.value,
defaultCc: otherRecipients.join(', '),
defaultContent: originalComment.value,
})
}
function forward() {
composeRef.value?.open({
...getMailArgs(),
isForward: true,
defaultSubject: subject.value.startsWith('Fw:') ? subject.value : `Fw: ${subject.value}`,
defaultRecipients: '',
defaultContent: originalComment.value,
})
}
function splitEmailList(str: string): string[] {
if (!str) return []
return str.split(/[;,]/).map(s => s.trim()).filter(Boolean)
}
function onEmailSent() {
emit('refresh')
}
// ===== Mark as Read/Unread =====
async function markAsReadUnread() {
const action = isSeen.value ? 'Unread' : 'Read'
try {
await api.call('jingrow.email.inbox.create_email_flag_queue', {
names: JSON.stringify([props.id]),
action,
flag: '\\SEEN',
})
message.success(t(action === 'Read' ? 'Marked as Read' : 'Marked as Unread'))
emit('refresh')
} catch (err: any) {
message.error(err?.message || t('Failed to update'))
}
}
// ===== Mark as Closed/Open =====
async function markAsClosedOpen() {
const status = isOpen.value ? 'Closed' : 'Open'
try {
await api.call('jingrow.email.inbox.mark_as_closed_open', {
communication: props.id,
status,
})
emit('refresh')
} catch (err: any) {
message.error(err?.message || t('Failed to update'))
}
}
// ===== Mark as Spam =====
async function markAsSpam() {
try {
await api.call('jingrow.email.inbox.mark_as_spam', {
communication: props.id,
sender: sender.value,
})
message.success(t('Email has been marked as spam'))
emit('refresh')
} catch (err: any) {
message.error(err?.message || t('Failed to mark as spam'))
}
}
// ===== Move to Trash =====
async function moveToTrash() {
try {
await api.call('jingrow.email.inbox.mark_as_trash', {
communication: props.id,
})
message.success(t('Email has been moved to trash'))
emit('refresh')
} catch (err: any) {
message.error(err?.message || t('Failed to move to trash'))
}
}
// ===== Add to Contact =====
function addToContact() {
const fullname = senderFullName.value || ''
const names = fullname.split(' ')
const firstName = names[0] || ''
const lastName = names.length >= 2 ? names[names.length - 1] : ''
const email = sender.value.replace(/<.*>/, '').trim() || sender.value
const phone = props.record?.phone_no || ''
// Store default values in sessionStorage (same mechanism as Duplicate)
const storageKey = 'duplicate:Contact'
try {
sessionStorage.setItem(storageKey, JSON.stringify({
email_id: email,
first_name: firstName,
last_name: lastName,
mobile_no: phone,
}))
} catch {}
// Navigate to new Contact page using Vue Router
const slug = pageTypeToSlug('Contact')
const randomStr = Math.random().toString(36).substring(2, 12)
const tempName = `new-${slug}-${randomStr}`
router.push({
name: 'PageTypeDetail',
params: { entity: slug, id: tempName }
})
}
// ===== Relink =====
const showRelinkDialog = ref(false)
const relinkFormRef = ref<FormInst | null>(null)
const relinking = ref(false)
const relinkForm = ref({ reference_pagetype: '', reference_name: '' as string | null })
const relinkRules: FormRules = {
reference_pagetype: [{ required: true, message: t('Required'), trigger: 'change' }],
}
// Clear reference_name when pagetype changes
watch(() => relinkForm.value.reference_pagetype, () => {
relinkForm.value.reference_name = ''
})
async function confirmRelink() {
try {
await relinkFormRef.value?.validate()
} catch { return }
relinking.value = true
try {
await api.call('jingrow.email.relink', {
name: props.id,
reference_pagetype: relinkForm.value.reference_pagetype,
reference_name: relinkForm.value.reference_name,
})
message.success(t('Communication relinked'))
showRelinkDialog.value = false
emit('refresh')
} catch (err: any) {
message.error(err?.message || t('Failed to relink'))
} finally {
relinking.value = false
}
}
// ===== Move =====
const showMoveDialog = ref(false)
const moveTargetAccount = ref<string | null>(null)
const emailAccountOptions = ref<{ label: string; value: string }[]>([])
const loadingAccounts = ref(false)
const moving = ref(false)
async function openMoveDialog() {
showMoveDialog.value = true
moveTargetAccount.value = null
if (emailAccountOptions.value.length === 0) {
try {
loadingAccounts.value = true
const res = await api.call('jingrow.email.inbox.get_email_accounts')
const data = (res?.email_accounts ?? []) as any[]
emailAccountOptions.value = data
.filter((d: any) => {
const ea = d.email_account ?? ''
return ea && ea !== emailAccount.value && ea !== 'All Accounts'
})
.map((d: any) => ({
label: d.email_id,
value: d.email_account,
}))
} catch { /* ignore */ } finally {
loadingAccounts.value = false
}
}
}
async function confirmMove() {
if (!moveTargetAccount.value) {
message.warning(t('Select an email account'))
return
}
moving.value = true
try {
await api.call('jingrow.email.inbox.move_email', {
communication: props.id,
email_account: moveTargetAccount.value,
})
message.success(t('Email moved'))
showMoveDialog.value = false
emit('refresh')
} catch (err: any) {
message.error(err?.message || t('Failed to move'))
} finally {
moving.value = false
}
}
// ===== Dropdown helpers =====
function makeOption(key: string, label: string, icon: string | undefined, condition = true): DropdownOption | null {
if (!condition || !icon) return null
return { label, key, icon }
}
function renderMenuIcon(option: DropdownOption) {
const iconName = option.icon as string
if (!iconName) return null
return h(NIcon, null, { default: () => h(Icon, { icon: iconName }) })
}
const moreOptions = computed<DropdownOption[]>(() => {
const opts: DropdownOption[] = []
// Email action items (from former Actions dropdown)
if (canReply.value) {
const replyAll = makeOption('reply_all', t('Reply All'), 'tabler:arrow-back-up-double')
if (replyAll) opts.push(replyAll)
const readLabel = isSeen.value ? t('Mark as Unread') : t('Mark as Read')
const readIcon = isSeen.value ? 'tabler:mail-opened' : 'tabler:mail-check'
const toggleRead = makeOption('toggle_read', readLabel, readIcon)
if (toggleRead) opts.push(toggleRead)
const move = makeOption('move', t('Move'), 'tabler:arrows-transfer-down')
if (move) opts.push(move)
if (!isSpam.value) {
const spam = makeOption('spam', t('Mark as Spam'), 'tabler:alert-triangle')
if (spam) opts.push(spam)
}
if (!isTrash.value) {
const trash = makeOption('trash', t('Move To Trash'), 'tabler:trash')
if (trash) opts.push(trash)
}
// Close / Reopen
if (!isNew.value && !isLinked.value) {
const closeLabel = isOpen.value ? t('Close') : t('Reopen')
const closeIcon = isOpen.value ? 'tabler:x' : 'tabler:rotate-clockwise'
const closeOpt = makeOption('toggle_close', closeLabel, closeIcon)
if (closeOpt) opts.push(closeOpt)
}
opts.push({ type: 'divider', key: 'd-email-actions' })
}
// General more items
if (!isNew.value && props.canEdit && !props.pageMeta?.allow_copy) {
const dup = makeOption('duplicate', t('Duplicate'), 'tabler:copy')
if (dup) opts.push(dup)
}
const canRename = !isNew.value && props.canEdit && !!props.pageMeta?.allow_rename
if (canRename) {
const rename = makeOption('rename', t('Rename'), 'tabler:edit')
if (rename) opts.push(rename)
}
if (!isNew.value) {
const newEmail = makeOption('new_email', t('New Email'), 'tabler:mail')
if (newEmail) opts.push(newEmail)
}
// Add to Contact
if (canReply.value) {
const contact = makeOption('contact', t('Contact'), 'tabler:user-plus')
if (contact) opts.push(contact)
}
// Relink
if (!isNew.value) {
const relink = makeOption('relink', t('Relink'), 'tabler:link')
if (relink) opts.push(relink)
}
return opts
})
function handleMoreSelect(key: string) {
switch (key) {
case 'reply_all': replyAll(); break
case 'toggle_read': markAsReadUnread(); break
case 'move': openMoveDialog(); break
case 'spam': markAsSpam(); break
case 'trash': moveToTrash(); break
case 'toggle_close': markAsClosedOpen(); break
case 'contact': addToContact(); break
case 'relink': showRelinkDialog.value = true; break
case 'rename': emit('rename', '')
break
case 'duplicate': emit('duplicate', '')
break
case 'new_email':
composeRef.value?.open({
referencePagetype: props.entity,
referenceName: props.id,
})
break
}
}
onMounted(() => {
loadRowNames()
})
</script>
<style scoped>
.header-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.header-action-btn.delete-btn:hover:not(:disabled) {
background: #ef4444 !important;
border-color: #ef4444 !important;
color: white !important;
}
.header-action-btn.like-action-btn.is-liked {
background: #fef2f2 !important;
}
.header-action-btn.like-action-btn.is-liked :deep(.n-icon) {
color: #ef4444;
}
/* Reply button - same style as Forward */
/* Relink dialog - allow LinkField dropdown to overflow */
:deep(.n-dialog__content) {
overflow: visible !important;
}
</style>