Publish current project state

This commit is contained in:
2026-09-14 10:47:21 +08:00
parent 0dd188c46f
commit 4ad56ee9f8
186 changed files with 6289 additions and 1657 deletions
@@ -1,5 +1,5 @@
<script setup>
import { computed, watch, onMounted, ref } from 'vue';
import { computed, watch, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useMapGetter, useStore } from 'dashboard/composables/store';
@@ -25,9 +25,26 @@ const hoveredLabel = ref(null);
const allLabels = useMapGetter('labels/getLabels');
const contactLabels = useMapGetter('contactLabels/getContactLabels');
const hasContactLabels = useMapGetter('contactLabels/hasContactLabels');
const contactLabelsUIFlags = useMapGetter('contactLabels/getUIFlags');
const currentContactId = computed(
() => props.contactId || route.params.contactId
);
const labelsLoaded = computed(
() =>
Boolean(currentContactId.value) &&
hasContactLabels.value(currentContactId.value)
);
const isBusy = computed(
() =>
!labelsLoaded.value ||
contactLabelsUIFlags.value.isFetching ||
contactLabelsUIFlags.value.isUpdating
);
const savedLabels = computed(() => {
const availableContactLabels = contactLabels.value(props.contactId);
const availableContactLabels = contactLabels.value(currentContactId.value);
return allLabels.value.filter(({ title }) =>
availableContactLabels.includes(title)
);
@@ -55,6 +72,9 @@ const fetchLabels = async contactId => {
};
const handleLabelAction = async ({ value }) => {
if (isBusy.value) {
return;
}
try {
// Get current label titles
const currentLabels = savedLabels.value.map(label => label.title);
@@ -76,7 +96,7 @@ const handleLabelAction = async ({ value }) => {
}
await store.dispatch('contactLabels/update', {
contactId: props.contactId,
contactId: currentContactId.value,
labels: updatedLabels,
});
@@ -91,18 +111,14 @@ const handleRemoveLabel = label => {
};
watch(
() => props.contactId,
() => currentContactId.value,
(newVal, oldVal) => {
if (newVal !== oldVal) {
if (newVal && newVal !== oldVal) {
fetchLabels(newVal);
}
}
},
{ immediate: true }
);
onMounted(() => {
if (route.params.contactId) {
fetchLabels(route.params.contactId);
}
});
const handleMouseLeave = () => {
// Reset hover state when mouse leaves the container
@@ -6,6 +6,7 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
defineProps({
selectedContact: {
@@ -25,7 +26,7 @@ const openConfirmDeleteContactDialog = () => {
</script>
<template>
<Policy :permissions="['administrator']">
<Policy :permissions="[ROLE_ADMINISTRATOR]">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
@@ -1,5 +1,5 @@
<script setup>
import { reactive, computed } from 'vue';
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
@@ -16,13 +16,18 @@ const route = useRoute();
const state = reactive({
message: '',
editMessage: '',
editingNoteId: null,
});
let editorGeneration = 0;
const currentUser = useMapGetter('getCurrentUser');
const notesByContact = useMapGetter('contactNotes/getAllNotesByContactId');
const uiFlags = useMapGetter('contactNotes/getUIFlags');
const isFetchingNotes = computed(() => uiFlags.value.isFetching);
const isCreatingNote = computed(() => uiFlags.value.isCreating);
const isUpdatingNote = computed(() => uiFlags.value.isUpdating);
const isDeletingNote = computed(() => uiFlags.value.isDeleting);
const notes = computed(() => notesByContact.value(route.params.contactId));
const getWrittenBy = note => {
@@ -32,26 +37,80 @@ const getWrittenBy = note => {
: note?.user?.name || 'Bot';
};
const onAdd = content => {
if (!content) return;
const { contactId } = route.params;
store.dispatch('contactNotes/create', { content, contactId });
state.message = '';
const onAdd = async content => {
if (!content || isCreatingNote.value) return;
const targetContactId = route.params.contactId;
const generation = editorGeneration;
await store.dispatch('contactNotes/create', {
content,
contactId: targetContactId,
});
if (
generation === editorGeneration &&
route.params.contactId === targetContactId &&
state.message === content
) {
state.message = '';
}
};
const onEdit = note => {
if (!note?.id || isUpdatingNote.value) return;
editorGeneration += 1;
state.editingNoteId = note.id;
state.editMessage = note.content || '';
};
const closeEdit = () => {
editorGeneration += 1;
state.editingNoteId = null;
state.editMessage = '';
};
const onUpdate = async () => {
if (!state.editingNoteId || !state.editMessage || isUpdatingNote.value)
return;
const targetContactId = route.params.contactId;
const targetNoteId = state.editingNoteId;
const content = state.editMessage;
const generation = editorGeneration;
await store.dispatch('contactNotes/update', {
noteId: targetNoteId,
contactId: targetContactId,
content,
});
if (
generation === editorGeneration &&
route.params.contactId === targetContactId &&
state.editingNoteId === targetNoteId &&
state.editMessage === content
) {
closeEdit();
}
};
const onDelete = noteId => {
if (!noteId) return;
if (!noteId || isDeletingNote.value) return;
const { contactId } = route.params;
store.dispatch('contactNotes/delete', { noteId, contactId });
};
const keyboardEvents = {
'$mod+Enter': {
action: () => onAdd(state.message),
action: () => (state.editingNoteId ? onUpdate() : onAdd(state.message)),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
watch(
() => route.params.contactId,
() => {
editorGeneration += 1;
state.message = '';
closeEdit();
}
);
</script>
<template>
@@ -60,6 +119,8 @@ useKeyboardEvents(keyboardEvents);
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
focus-on-mount
:max-length="0"
:show-character-count="false"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4 px-6"
>
<template #actions>
@@ -91,11 +152,43 @@ useKeyboardEvents(keyboardEvents);
:note="note"
:written-by="getWrittenBy(note)"
allow-delete
allow-edit
@delete="onDelete"
@edit="onEdit"
/>
</div>
<p v-else class="px-6 py-6 text-sm leading-6 text-center text-n-slate-11">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EMPTY_STATE') }}
</p>
<woot-modal
:show="Boolean(state.editingNoteId)"
:on-close="closeEdit"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<div class="flex w-full flex-col gap-6 px-6 py-6">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EDIT_NOTE') }}
</h3>
<Editor
v-model="state.editMessage"
focus-on-mount
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
:max-length="0"
:show-character-count="false"
class="[&>div]:!border-transparent [&>div]:px-4 [&>div]:py-4"
/>
<div class="flex items-center justify-end gap-3">
<Button
solid
blue
:label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.SAVE')"
:is-loading="isUpdatingNote"
:disabled="!state.editMessage || isUpdatingNote"
@click="onUpdate"
/>
</div>
</div>
</woot-modal>
</div>
</template>
@@ -20,13 +20,17 @@ const props = defineProps({
type: Boolean,
default: false,
},
allowEdit: {
type: Boolean,
default: false,
},
collapsible: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['delete']);
const emit = defineEmits(['delete', 'edit']);
const noteContentRef = useTemplateRef('noteContentRef');
const needsCollapse = ref(false);
const [isExpanded, toggleExpanded] = useToggle();
@@ -37,6 +41,10 @@ const handleDelete = () => {
emit('delete', props.note.id);
};
const handleEdit = () => {
emit('edit', props.note);
};
onMounted(() => {
if (props.collapsible) {
// Check if content height exceeds approximately 4 lines
@@ -71,15 +79,28 @@ onMounted(() => {
</span>
</div>
</div>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
class="opacity-0 group-hover/note:opacity-100"
@click="handleDelete"
/>
<div
class="flex items-center gap-1 opacity-0 group-hover/note:opacity-100"
>
<Button
v-if="allowEdit"
variant="faded"
color="blue"
size="xs"
icon="i-lucide-pencil"
:aria-label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.EDIT_NOTE')"
@click="handleEdit"
/>
<Button
v-if="allowDelete"
variant="faded"
color="ruby"
size="xs"
icon="i-lucide-trash"
:aria-label="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.DELETE_NOTE')"
@click="handleDelete"
/>
</div>
</div>
<p
ref="noteContentRef"
@@ -1,9 +1,10 @@
<script setup>
import { computed, ref, onMounted } from 'vue';
import { computed, ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -47,6 +48,14 @@ onMounted(() => {
Object.assign(contactData.value, getInitialContactData());
});
watch(
() => props.selectedContact,
contact => {
contactData.value = contact ? { ...contact } : {};
},
{ deep: true }
);
const createdAt = computed(() => {
return contactData.value?.createdAt
? dynamicTime(contactData.value.createdAt)
@@ -63,6 +72,21 @@ const avatarSrc = computed(() => {
return avatarUrl.value ? avatarUrl.value : contactData.value?.thumbnail;
});
const shangwutongNameOperations = computed(() =>
(
contactData.value?.contactInboxes ||
contactData.value?.contact_inboxes ||
[]
)
.map(contactInbox => ({
contactInbox,
operation:
contactInbox.shangwutongContactNameOperation ||
contactInbox.shangwutong_contact_name_operation,
}))
.filter(({ operation }) => operation)
);
const handleFormUpdate = updatedData => {
Object.assign(contactData.value, updatedData);
};
@@ -136,6 +160,29 @@ const handleAvatarDelete = async () => {
{{ selectedContact?.name }}
</h3>
<div class="flex flex-col gap-1.5">
<span
v-for="item in shangwutongNameOperations"
:key="
item.operation.contactInboxId ||
item.operation.contact_inbox_id ||
item.contactInbox.id
"
class="text-xs text-n-slate-10"
>
{{
item.contactInbox.inbox?.name ||
item.contactInbox.inboxName ||
item.contactInbox.sourceId ||
item.contactInbox.source_id
}}:
{{ item.operation.status }}
<span
v-if="item.operation.errorMessage || item.operation.error_message"
>
—
{{ item.operation.errorMessage || item.operation.error_message }}
</span>
</span>
<span
v-if="selectedContact?.identifier"
class="inline-flex items-center gap-1 text-sm text-n-slate-11"
@@ -175,7 +222,7 @@ const handleAvatarDelete = async () => {
@click="updateContact"
/>
</div>
<Policy :permissions="['administrator']">
<Policy :permissions="[ROLE_ADMINISTRATOR]">
<div
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
>
@@ -4,6 +4,7 @@ import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -45,7 +46,7 @@ const menuItems = computed(() => {
},
];
if (checkPermissions(['administrator'])) {
if (checkPermissions([ROLE_ADMINISTRATOR])) {
allOptions.push(
{
label: t('CAPTAIN.ASSISTANTS.OPTIONS.EDIT_ASSISTANT'),
@@ -4,6 +4,7 @@ import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
import {
isSafeHttpLink,
formatDocumentLink,
@@ -97,7 +98,7 @@ const modelValue = computed({
const isPdf = computed(() => props.pdfDocument);
const hasSafeLink = computed(() => isSafeHttpLink(props.externalLink));
const canManage = computed(() => checkPermissions(['administrator']));
const canManage = computed(() => checkPermissions([ROLE_ADMINISTRATOR]));
const isAvailable = computed(() => props.status === 'available');
const canSync = computed(
() => canManage.value && !isPdf.value && isAvailable.value
@@ -7,6 +7,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
import { INBOX_TYPES, getInboxIconByType } from 'dashboard/helper/inbox';
const props = defineProps({
@@ -80,7 +81,7 @@ const handleAction = ({ action, value }) => {
<div class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
:permissions="[ROLE_ADMINISTRATOR]"
class="relative flex items-center group"
>
<Button
@@ -3,6 +3,7 @@ import { computed } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
@@ -142,7 +143,7 @@ const handleDocumentableClick = () => {
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
:permissions="[ROLE_ADMINISTRATOR]"
class="relative flex items-center group"
>
<Button
@@ -168,7 +169,7 @@ const handleDocumentableClick = () => {
v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
>
<Policy v-if="showActions" :permissions="['administrator']">
<Policy v-if="showActions" :permissions="[ROLE_ADMINISTRATOR]">
<div class="flex items-center gap-2 sm:gap-5 w-full">
<Button
v-if="status === 'pending'"
@@ -8,6 +8,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Policy from 'dashboard/components/policy.vue';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
const props = defineProps({
id: {
@@ -82,7 +83,7 @@ const authTypeLabel = computed(() => {
<div class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="['administrator']"
:permissions="[ROLE_ADMINISTRATOR]"
class="relative flex items-center group"
>
<Button
@@ -5,6 +5,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import AssistantCard from 'dashboard/components-next/captain/assistant/AssistantCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { assistantsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
@@ -27,7 +28,7 @@ const onClick = () => {
<EmptyStateLayout
:title="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.ASSISTANTS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
:action-perms="[ROLE_ADMINISTRATOR]"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
@@ -3,6 +3,7 @@ import { useAccount } from 'dashboard/composables/useAccount';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
@@ -25,7 +26,7 @@ const onClick = () => {
<EmptyStateLayout
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
:action-perms="[ROLE_ADMINISTRATOR]"
>
<template #empty-state-item>
<div class="min-h-[600px]" />
@@ -6,6 +6,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { documentsList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
const emit = defineEmits(['click']);
const { isOnChatwootCloud } = useAccount();
@@ -30,7 +31,7 @@ const onClick = () => {
<EmptyStateLayout
:title="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.DOCUMENTS.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
:action-perms="[ROLE_ADMINISTRATOR]"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
@@ -1,6 +1,7 @@
<script setup>
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
import InboxCard from 'dashboard/components-next/captain/assistant/InboxCard.vue';
import { inboxes } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
@@ -15,7 +16,7 @@ const onClick = () => {
<EmptyStateLayout
:title="$t('CAPTAIN.INBOXES.EMPTY_STATE.TITLE')"
:subtitle="$t('CAPTAIN.INBOXES.EMPTY_STATE.SUBTITLE')"
:action-perms="['administrator']"
:action-perms="[ROLE_ADMINISTRATOR]"
>
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
@@ -8,6 +8,7 @@ import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/Featur
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { computed } from 'vue';
import { ROLE_ADMINISTRATOR } from 'dashboard/constants/permissions.js';
const props = defineProps({
variant: {
@@ -56,7 +57,7 @@ const onClearFilters = () => {
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="['administrator']"
:action-perms="[ROLE_ADMINISTRATOR]"
:show-backdrop="isApproved"
>
<template v-if="isApproved" #empty-state-item>