Merge pull request #748 from shariquerik/google-calendar

This commit is contained in:
Shariq Ansari 2025-09-04 13:34:48 +05:30 committed by GitHub
commit 181439be1d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 3442 additions and 3284 deletions

@ -1 +1 @@
Subproject commit 136f2715c2bd22b7390a2a02f1849a147d16b191
Subproject commit 02fc126fd5c49f0ecf6cce117585f89c4ea585c3

286
frontend/components.d.ts vendored Normal file
View File

@ -0,0 +1,286 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
// biome-ignore lint: disable
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
AboutModal: typeof import('./src/components/Modals/AboutModal.vue')['default']
Activities: typeof import('./src/components/Activities/Activities.vue')['default']
ActivityHeader: typeof import('./src/components/Activities/ActivityHeader.vue')['default']
ActivityIcon: typeof import('./src/components/Icons/ActivityIcon.vue')['default']
AddChartModal: typeof import('./src/components/Dashboard/AddChartModal.vue')['default']
AddExistingUserModal: typeof import('./src/components/Modals/AddExistingUserModal.vue')['default']
AddressIcon: typeof import('./src/components/Icons/AddressIcon.vue')['default']
AddressModal: typeof import('./src/components/Modals/AddressModal.vue')['default']
AllModals: typeof import('./src/components/Activities/AllModals.vue')['default']
AppHeader: typeof import('./src/components/Layouts/AppHeader.vue')['default']
Apps: typeof import('./src/components/Apps.vue')['default']
AppsIcon: typeof import('./src/components/Icons/AppsIcon.vue')['default']
AppSidebar: typeof import('./src/components/Layouts/AppSidebar.vue')['default']
ArrowUpRightIcon: typeof import('./src/components/Icons/ArrowUpRightIcon.vue')['default']
AscendingIcon: typeof import('./src/components/Icons/AscendingIcon.vue')['default']
AssigneeRules: typeof import('./src/components/Settings/AssignmentRules/AssigneeRules.vue')['default']
AssigneeSearch: typeof import('./src/components/Settings/AssignmentRules/AssigneeSearch.vue')['default']
AssignmentModal: typeof import('./src/components/Modals/AssignmentModal.vue')['default']
AssignmentRuleListItem: typeof import('./src/components/Settings/AssignmentRules/AssignmentRuleListItem.vue')['default']
AssignmentRulePage: typeof import('./src/components/Settings/AssignmentRules/AssignmentRulePage.vue')['default']
AssignmentRules: typeof import('./src/components/Settings/AssignmentRules/AssignmentRules.vue')['default']
AssignmentRulesList: typeof import('./src/components/Settings/AssignmentRules/AssignmentRulesList.vue')['default']
AssignmentRulesSection: typeof import('./src/components/Settings/AssignmentRules/AssignmentRulesSection.vue')['default']
AssignmentRuleView: typeof import('./src/components/Settings/AssignmentRules/AssignmentRuleView.vue')['default']
AssignmentSchedule: typeof import('./src/components/Settings/AssignmentRules/AssignmentSchedule.vue')['default']
AssignmentScheduleItem: typeof import('./src/components/Settings/AssignmentRules/AssignmentScheduleItem.vue')['default']
AssignTo: typeof import('./src/components/AssignTo.vue')['default']
AssignToBody: typeof import('./src/components/AssignToBody.vue')['default']
AttachmentArea: typeof import('./src/components/Activities/AttachmentArea.vue')['default']
AttachmentIcon: typeof import('./src/components/Icons/AttachmentIcon.vue')['default']
AttachmentItem: typeof import('./src/components/AttachmentItem.vue')['default']
Attendee: typeof import('./src/components/Calendar/Attendee.vue')['default']
AudioPlayer: typeof import('./src/components/Activities/AudioPlayer.vue')['default']
Autocomplete: typeof import('./src/components/frappe-ui/Autocomplete.vue')['default']
AvatarIcon: typeof import('./src/components/Icons/AvatarIcon.vue')['default']
BrandLogo: typeof import('./src/components/BrandLogo.vue')['default']
BrandSettings: typeof import('./src/components/Settings/General/BrandSettings.vue')['default']
BulkDeleteLinkedDocModal: typeof import('./src/components/BulkDeleteLinkedDocModal.vue')['default']
CalendarEventPanel: typeof import('./src/components/Calendar/CalendarEventPanel.vue')['default']
CalendarIcon: typeof import('./src/components/Icons/CalendarIcon.vue')['default']
CallArea: typeof import('./src/components/Activities/CallArea.vue')['default']
CallLogDetailModal: typeof import('./src/components/Modals/CallLogDetailModal.vue')['default']
CallLogModal: typeof import('./src/components/Modals/CallLogModal.vue')['default']
CallLogsListView: typeof import('./src/components/ListViews/CallLogsListView.vue')['default']
CallUI: typeof import('./src/components/Telephony/CallUI.vue')['default']
CameraIcon: typeof import('./src/components/Icons/CameraIcon.vue')['default']
CertificateIcon: typeof import('./src/components/Icons/CertificateIcon.vue')['default']
CFCondition: typeof import('./src/components/ConditionsFilter/CFCondition.vue')['default']
CFConditions: typeof import('./src/components/ConditionsFilter/CFConditions.vue')['default']
ChangePasswordModal: typeof import('./src/components/Modals/ChangePasswordModal.vue')['default']
CheckCircleIcon: typeof import('./src/components/Icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/Icons/CheckIcon.vue')['default']
CollapseSidebar: typeof import('./src/components/Icons/CollapseSidebar.vue')['default']
Column: typeof import('./src/components/FieldLayout/Column.vue')['default']
ColumnSettings: typeof import('./src/components/ColumnSettings.vue')['default']
ColumnsIcon: typeof import('./src/components/Icons/ColumnsIcon.vue')['default']
CommentArea: typeof import('./src/components/Activities/CommentArea.vue')['default']
CommentBox: typeof import('./src/components/CommentBox.vue')['default']
CommentIcon: typeof import('./src/components/Icons/CommentIcon.vue')['default']
CommunicationArea: typeof import('./src/components/CommunicationArea.vue')['default']
ContactIcon: typeof import('./src/components/Icons/ContactIcon.vue')['default']
ContactModal: typeof import('./src/components/Modals/ContactModal.vue')['default']
ContactsIcon: typeof import('./src/components/Icons/ContactsIcon.vue')['default']
ContactsListView: typeof import('./src/components/ListViews/ContactsListView.vue')['default']
ConvertIcon: typeof import('./src/components/Icons/ConvertIcon.vue')['default']
ConvertToDealModal: typeof import('./src/components/Modals/ConvertToDealModal.vue')['default']
CountUpTimer: typeof import('./src/components/CountUpTimer.vue')['default']
CreateDocumentModal: typeof import('./src/components/Modals/CreateDocumentModal.vue')['default']
CRMLogo: typeof import('./src/components/Icons/CRMLogo.vue')['default']
CurrencySettings: typeof import('./src/components/Settings/General/CurrencySettings.vue')['default']
CustomActions: typeof import('./src/components/CustomActions.vue')['default']
DashboardGrid: typeof import('./src/components/Dashboard/DashboardGrid.vue')['default']
DashboardIcon: typeof import('./src/components/Icons/DashboardIcon.vue')['default']
DashboardItem: typeof import('./src/components/Dashboard/DashboardItem.vue')['default']
DataFields: typeof import('./src/components/Activities/DataFields.vue')['default']
DataFieldsModal: typeof import('./src/components/Modals/DataFieldsModal.vue')['default']
DealModal: typeof import('./src/components/Modals/DealModal.vue')['default']
DealsIcon: typeof import('./src/components/Icons/DealsIcon.vue')['default']
DealsListView: typeof import('./src/components/ListViews/DealsListView.vue')['default']
DeclinedCallIcon: typeof import('./src/components/Icons/DeclinedCallIcon.vue')['default']
DeleteLinkedDocModal: typeof import('./src/components/DeleteLinkedDocModal.vue')['default']
DescriptionIcon: typeof import('./src/components/Icons/DescriptionIcon.vue')['default']
DesendingIcon: typeof import('./src/components/Icons/DesendingIcon.vue')['default']
DesktopLayout: typeof import('./src/components/Layouts/DesktopLayout.vue')['default']
DetailsIcon: typeof import('./src/components/Icons/DetailsIcon.vue')['default']
DialpadIcon: typeof import('./src/components/Icons/DialpadIcon.vue')['default']
DocumentIcon: typeof import('./src/components/Icons/DocumentIcon.vue')['default']
DotIcon: typeof import('./src/components/Icons/DotIcon.vue')['default']
DoubleCheckIcon: typeof import('./src/components/Icons/DoubleCheckIcon.vue')['default']
DragIcon: typeof import('./src/components/Icons/DragIcon.vue')['default']
DragVerticalIcon: typeof import('./src/components/Icons/DragVerticalIcon.vue')['default']
DropdownItem: typeof import('./src/components/DropdownItem.vue')['default']
DuplicateIcon: typeof import('./src/components/Icons/DuplicateIcon.vue')['default']
DurationIcon: typeof import('./src/components/Icons/DurationIcon.vue')['default']
EditEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/EditEmailTemplate.vue')['default']
EditIcon: typeof import('./src/components/Icons/EditIcon.vue')['default']
EditValueModal: typeof import('./src/components/Modals/EditValueModal.vue')['default']
Email2Icon: typeof import('./src/components/Icons/Email2Icon.vue')['default']
EmailAccountCard: typeof import('./src/components/Settings/EmailAccountCard.vue')['default']
EmailAccountList: typeof import('./src/components/Settings/EmailAccountList.vue')['default']
EmailAdd: typeof import('./src/components/Settings/EmailAdd.vue')['default']
EmailArea: typeof import('./src/components/Activities/EmailArea.vue')['default']
EmailAtIcon: typeof import('./src/components/Icons/EmailAtIcon.vue')['default']
EmailConfig: typeof import('./src/components/Settings/EmailConfig.vue')['default']
EmailContent: typeof import('./src/components/Activities/EmailContent.vue')['default']
EmailEdit: typeof import('./src/components/Settings/EmailEdit.vue')['default']
EmailEditor: typeof import('./src/components/EmailEditor.vue')['default']
EmailIcon: typeof import('./src/components/Icons/EmailIcon.vue')['default']
EmailMultiSelect: typeof import('./src/components/Controls/EmailMultiSelect.vue')['default']
EmailProviderIcon: typeof import('./src/components/Settings/EmailProviderIcon.vue')['default']
EmailTemplateIcon: typeof import('./src/components/Icons/EmailTemplateIcon.vue')['default']
EmailTemplatePage: typeof import('./src/components/Settings/EmailTemplate/EmailTemplatePage.vue')['default']
EmailTemplates: typeof import('./src/components/Settings/EmailTemplate/EmailTemplates.vue')['default']
EmailTemplateSelectorModal: typeof import('./src/components/Modals/EmailTemplateSelectorModal.vue')['default']
ERPNextIcon: typeof import('./src/components/Icons/ERPNextIcon.vue')['default']
ERPNextSettings: typeof import('./src/components/Settings/ERPNextSettings.vue')['default']
ErrorPage: typeof import('./src/components/ErrorPage.vue')['default']
EventArea: typeof import('./src/components/Activities/EventArea.vue')['default']
EventIcon: typeof import('./src/components/Icons/EventIcon.vue')['default']
EventModal: typeof import('./src/components/Modals/EventModal.vue')['default']
ExotelCallUI: typeof import('./src/components/Telephony/ExotelCallUI.vue')['default']
ExportIcon: typeof import('./src/components/Icons/ExportIcon.vue')['default']
ExternalLinkIcon: typeof import('./src/components/Icons/ExternalLinkIcon.vue')['default']
FadedScrollableDiv: typeof import('./src/components/FadedScrollableDiv.vue')['default']
Field: typeof import('./src/components/FieldLayout/Field.vue')['default']
FieldLayout: typeof import('./src/components/FieldLayout/FieldLayout.vue')['default']
FieldLayoutEditor: typeof import('./src/components/FieldLayoutEditor.vue')['default']
FileAudioIcon: typeof import('./src/components/Icons/FileAudioIcon.vue')['default']
FileIcon: typeof import('./src/components/Icons/FileIcon.vue')['default']
FileImageIcon: typeof import('./src/components/Icons/FileImageIcon.vue')['default']
FileSpreadsheetIcon: typeof import('./src/components/Icons/FileSpreadsheetIcon.vue')['default']
FilesUploader: typeof import('./src/components/FilesUploader/FilesUploader.vue')['default']
FilesUploaderArea: typeof import('./src/components/FilesUploader/FilesUploaderArea.vue')['default']
FileTextIcon: typeof import('./src/components/Icons/FileTextIcon.vue')['default']
FileTypeIcon: typeof import('./src/components/Icons/FileTypeIcon.vue')['default']
FileVideoIcon: typeof import('./src/components/Icons/FileVideoIcon.vue')['default']
Filter: typeof import('./src/components/Filter.vue')['default']
FilterIcon: typeof import('./src/components/Icons/FilterIcon.vue')['default']
FormattedInput: typeof import('./src/components/Controls/FormattedInput.vue')['default']
FrappeCloudIcon: typeof import('./src/components/Icons/FrappeCloudIcon.vue')['default']
GenderIcon: typeof import('./src/components/Icons/GenderIcon.vue')['default']
GeneralSettings: typeof import('./src/components/Settings/General/GeneralSettings.vue')['default']
GeneralSettingsPage: typeof import('./src/components/Settings/General/GeneralSettingsPage.vue')['default']
GlobalModals: typeof import('./src/components/Modals/GlobalModals.vue')['default']
GoogleIcon: typeof import('./src/components/Icons/GoogleIcon.vue')['default']
Grid: typeof import('./src/components/Controls/Grid.vue')['default']
GridFieldsEditorModal: typeof import('./src/components/Controls/GridFieldsEditorModal.vue')['default']
GridRowFieldsModal: typeof import('./src/components/Controls/GridRowFieldsModal.vue')['default']
GridRowModal: typeof import('./src/components/Controls/GridRowModal.vue')['default']
GroupBy: typeof import('./src/components/GroupBy.vue')['default']
GroupByIcon: typeof import('./src/components/Icons/GroupByIcon.vue')['default']
HeartIcon: typeof import('./src/components/Icons/HeartIcon.vue')['default']
HelpdeskIcon: typeof import('./src/components/Icons/HelpdeskIcon.vue')['default']
HelpdeskSettings: typeof import('./src/components/Settings/HelpdeskSettings.vue')['default']
HelpIcon: typeof import('./src/components/Icons/HelpIcon.vue')['default']
HomeActions: typeof import('./src/components/Settings/General/HomeActions.vue')['default']
Icon: typeof import('./src/components/Icon.vue')['default']
IconPicker: typeof import('./src/components/IconPicker.vue')['default']
ImageUploader: typeof import('./src/components/Controls/ImageUploader.vue')['default']
InboundCallIcon: typeof import('./src/components/Icons/InboundCallIcon.vue')['default']
InboxIcon: typeof import('./src/components/Icons/InboxIcon.vue')['default']
IndicatorIcon: typeof import('./src/components/Icons/IndicatorIcon.vue')['default']
InviteIcon: typeof import('./src/components/Icons/InviteIcon.vue')['default']
InviteUserPage: typeof import('./src/components/Settings/InviteUserPage.vue')['default']
KanbanIcon: typeof import('./src/components/Icons/KanbanIcon.vue')['default']
KanbanSettings: typeof import('./src/components/Kanban/KanbanSettings.vue')['default']
KanbanView: typeof import('./src/components/Kanban/KanbanView.vue')['default']
KeyboardShortcut: typeof import('./src/components/KeyboardShortcut.vue')['default']
LayoutHeader: typeof import('./src/components/LayoutHeader.vue')['default']
LeadModal: typeof import('./src/components/Modals/LeadModal.vue')['default']
LeadsIcon: typeof import('./src/components/Icons/LeadsIcon.vue')['default']
LeadsListView: typeof import('./src/components/ListViews/LeadsListView.vue')['default']
LightningIcon: typeof import('./src/components/Icons/LightningIcon.vue')['default']
Link: typeof import('./src/components/Controls/Link.vue')['default']
LinkedDocsListView: typeof import('./src/components/ListViews/LinkedDocsListView.vue')['default']
LinkIcon: typeof import('./src/components/Icons/LinkIcon.vue')['default']
ListBulkActions: typeof import('./src/components/ListBulkActions.vue')['default']
ListIcon: typeof import('./src/components/Icons/ListIcon.vue')['default']
ListRows: typeof import('./src/components/ListViews/ListRows.vue')['default']
LoadingIndicator: typeof import('./src/components/Icons/LoadingIndicator.vue')['default']
LostReasonModal: typeof import('./src/components/Modals/LostReasonModal.vue')['default']
LucideCalendar: typeof import('~icons/lucide/calendar')['default']
LucideChevronLeft: typeof import('~icons/lucide/chevron-left')['default']
LucideCopy: typeof import('~icons/lucide/copy')['default']
LucideTrash2: typeof import('~icons/lucide/trash2')['default']
LucideX: typeof import('~icons/lucide/x')['default']
MarkAsDoneIcon: typeof import('./src/components/Icons/MarkAsDoneIcon.vue')['default']
MaximizeIcon: typeof import('./src/components/Icons/MaximizeIcon.vue')['default']
MenuIcon: typeof import('./src/components/Icons/MenuIcon.vue')['default']
MinimizeIcon: typeof import('./src/components/Icons/MinimizeIcon.vue')['default']
MissedCallIcon: typeof import('./src/components/Icons/MissedCallIcon.vue')['default']
MobileAppHeader: typeof import('./src/components/Mobile/MobileAppHeader.vue')['default']
MobileLayout: typeof import('./src/components/Layouts/MobileLayout.vue')['default']
MobileSidebar: typeof import('./src/components/Mobile/MobileSidebar.vue')['default']
MoneyIcon: typeof import('./src/components/Icons/MoneyIcon.vue')['default']
MultiActionButton: typeof import('./src/components/MultiActionButton.vue')['default']
MultipleAvatar: typeof import('./src/components/MultipleAvatar.vue')['default']
MuteIcon: typeof import('./src/components/Icons/MuteIcon.vue')['default']
NewEmailTemplate: typeof import('./src/components/Settings/EmailTemplate/NewEmailTemplate.vue')['default']
NoteArea: typeof import('./src/components/Activities/NoteArea.vue')['default']
NoteIcon: typeof import('./src/components/Icons/NoteIcon.vue')['default']
NoteModal: typeof import('./src/components/Modals/NoteModal.vue')['default']
Notifications: typeof import('./src/components/Notifications.vue')['default']
NotificationsIcon: typeof import('./src/components/Icons/NotificationsIcon.vue')['default']
OrganizationModal: typeof import('./src/components/Modals/OrganizationModal.vue')['default']
OrganizationsIcon: typeof import('./src/components/Icons/OrganizationsIcon.vue')['default']
OrganizationsListView: typeof import('./src/components/ListViews/OrganizationsListView.vue')['default']
OutboundCallIcon: typeof import('./src/components/Icons/OutboundCallIcon.vue')['default']
Password: typeof import('./src/components/Controls/Password.vue')['default']
PauseIcon: typeof import('./src/components/Icons/PauseIcon.vue')['default']
PeopleIcon: typeof import('./src/components/Icons/PeopleIcon.vue')['default']
PhoneIcon: typeof import('./src/components/Icons/PhoneIcon.vue')['default']
PinIcon: typeof import('./src/components/Icons/PinIcon.vue')['default']
PlaybackSpeedIcon: typeof import('./src/components/Icons/PlaybackSpeedIcon.vue')['default']
PlaybackSpeedOption: typeof import('./src/components/Activities/PlaybackSpeedOption.vue')['default']
PlayIcon: typeof import('./src/components/Icons/PlayIcon.vue')['default']
ProfileSettings: typeof import('./src/components/Settings/ProfileSettings.vue')['default']
QuickEntryModal: typeof import('./src/components/Modals/QuickEntryModal.vue')['default']
QuickFilterField: typeof import('./src/components/QuickFilterField.vue')['default']
QuickFilterIcon: typeof import('./src/components/Icons/QuickFilterIcon.vue')['default']
ReactIcon: typeof import('./src/components/Icons/ReactIcon.vue')['default']
RefreshIcon: typeof import('./src/components/Icons/RefreshIcon.vue')['default']
ReloadIcon: typeof import('./src/components/Icons/ReloadIcon.vue')['default']
ReplyAllIcon: typeof import('./src/components/Icons/ReplyAllIcon.vue')['default']
ReplyIcon: typeof import('./src/components/Icons/ReplyIcon.vue')['default']
Resizer: typeof import('./src/components/Resizer.vue')['default']
RightSideLayoutIcon: typeof import('./src/components/Icons/RightSideLayoutIcon.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Section: typeof import('./src/components/FieldLayout/Section.vue')['default']
SelectIcon: typeof import('./src/components/Icons/SelectIcon.vue')['default']
Settings: typeof import('./src/components/Settings/Settings.vue')['default']
SettingsIcon: typeof import('./src/components/Icons/SettingsIcon.vue')['default']
SettingsIcon2: typeof import('./src/components/Icons/SettingsIcon2.vue')['default']
SettingsPage: typeof import('./src/components/Settings/SettingsPage.vue')['default']
ShortcutTooltip: typeof import('./src/components/ShortcutTooltip.vue')['default']
SidebarLink: typeof import('./src/components/SidebarLink.vue')['default']
SidePanelLayout: typeof import('./src/components/SidePanelLayout.vue')['default']
SidePanelLayoutEditor: typeof import('./src/components/SidePanelLayoutEditor.vue')['default']
SidePanelModal: typeof import('./src/components/Modals/SidePanelModal.vue')['default']
SLASection: typeof import('./src/components/SLASection.vue')['default']
SmileIcon: typeof import('./src/components/Icons/SmileIcon.vue')['default']
SortBy: typeof import('./src/components/SortBy.vue')['default']
SortIcon: typeof import('./src/components/Icons/SortIcon.vue')['default']
SquareAsterisk: typeof import('./src/components/Icons/SquareAsterisk.vue')['default']
StepsIcon: typeof import('./src/components/Icons/StepsIcon.vue')['default']
SuccessIcon: typeof import('./src/components/Icons/SuccessIcon.vue')['default']
TableMultiselectInput: typeof import('./src/components/Controls/TableMultiselectInput.vue')['default']
TaskArea: typeof import('./src/components/Activities/TaskArea.vue')['default']
TaskIcon: typeof import('./src/components/Icons/TaskIcon.vue')['default']
TaskModal: typeof import('./src/components/Modals/TaskModal.vue')['default']
TaskPanel: typeof import('./src/components/Telephony/TaskPanel.vue')['default']
TaskPriorityIcon: typeof import('./src/components/Icons/TaskPriorityIcon.vue')['default']
TasksListView: typeof import('./src/components/ListViews/TasksListView.vue')['default']
TaskStatusIcon: typeof import('./src/components/Icons/TaskStatusIcon.vue')['default']
TelegramIcon: typeof import('./src/components/Icons/TelegramIcon.vue')['default']
TelephonySettings: typeof import('./src/components/Settings/TelephonySettings.vue')['default']
TerritoryIcon: typeof import('./src/components/Icons/TerritoryIcon.vue')['default']
TwilioCallUI: typeof import('./src/components/Telephony/TwilioCallUI.vue')['default']
UnpinIcon: typeof import('./src/components/Icons/UnpinIcon.vue')['default']
UserAvatar: typeof import('./src/components/UserAvatar.vue')['default']
UserDropdown: typeof import('./src/components/UserDropdown.vue')['default']
Users: typeof import('./src/components/Settings/Users.vue')['default']
ViewBreadcrumbs: typeof import('./src/components/ViewBreadcrumbs.vue')['default']
ViewControls: typeof import('./src/components/ViewControls.vue')['default']
ViewModal: typeof import('./src/components/Modals/ViewModal.vue')['default']
VolumnHighIcon: typeof import('./src/components/Icons/VolumnHighIcon.vue')['default']
VolumnLowIcon: typeof import('./src/components/Icons/VolumnLowIcon.vue')['default']
WebsiteIcon: typeof import('./src/components/Icons/WebsiteIcon.vue')['default']
WhatsAppArea: typeof import('./src/components/Activities/WhatsAppArea.vue')['default']
WhatsAppBox: typeof import('./src/components/Activities/WhatsAppBox.vue')['default']
WhatsAppIcon: typeof import('./src/components/Icons/WhatsAppIcon.vue')['default']
WhatsAppSettings: typeof import('./src/components/Settings/WhatsAppSettings.vue')['default']
WhatsappTemplateSelectorModal: typeof import('./src/components/Modals/WhatsappTemplateSelectorModal.vue')['default']
}
}

View File

@ -13,7 +13,7 @@
"@tiptap/extension-paragraph": "^2.12.0",
"@twilio/voice-sdk": "^2.10.2",
"@vueuse/integrations": "^10.3.0",
"frappe-ui": "^0.1.189",
"frappe-ui": "^0.1.197",
"gemoji": "^8.1.0",
"lodash": "^4.17.21",
"mime": "^4.0.1",

View File

@ -1,6 +1,6 @@
<template>
<FrappeUIProvider>
<Layout v-if="session().isLoggedIn">
<Layout class="isolate" v-if="session().isLoggedIn">
<router-view :key="$route.fullPath"/>
</Layout>
<Dialogs />
@ -10,9 +10,8 @@
<script setup>
import { Dialogs } from '@/utils/dialogs'
import { sessionStore as session } from '@/stores/session'
import { setTheme } from '@/stores/theme'
import { FrappeUIProvider, setConfig } from 'frappe-ui'
import { computed, defineAsyncComponent, onMounted } from 'vue'
import { computed, defineAsyncComponent } from 'vue'
const MobileLayout = defineAsyncComponent(
() => import('./components/Layouts/MobileLayout.vue'),
@ -28,8 +27,6 @@ const Layout = computed(() => {
}
})
onMounted(() => setTheme())
setConfig('systemTimezone', window.timezone?.system || null)
setConfig('localTimezone', window.timezone?.user || null)
</script>

View File

@ -21,6 +21,9 @@
<LoadingIndicator class="h-6 w-6" />
<span>{{ __('Loading...') }}</span>
</div>
<div v-else-if="title == 'Events'" class="h-full activity">
<EventArea :doctype="doctype" :docname="docname" />
</div>
<div
v-else-if="
activities?.length ||
@ -435,6 +438,7 @@
<AllModals
ref="modalRef"
v-model="all_activities"
v-model:events="events"
:doctype="doctype"
:doc="doc"
/>
@ -463,11 +467,13 @@ import UserAvatar from '@/components/UserAvatar.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import WhatsAppIcon from '@/components/Icons/WhatsAppIcon.vue'
import EventArea from '@/components/Activities/EventArea.vue'
import WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
@ -754,6 +760,9 @@ function timelineIcon(activity_type, is_lead) {
case 'comment':
icon = CommentIcon
break
case 'event':
icon = CalendarIcon
break
case 'incoming_call':
icon = InboundCallIcon
break
@ -783,7 +792,7 @@ watch([reload, reload_email], ([reload_value, reload_email_value]) => {
})
function scroll(hash) {
if (['tasks', 'notes'].includes(route.hash?.slice(1))) return
if (['tasks', 'notes', 'events'].includes(route.hash?.slice(1))) return
setTimeout(() => {
let el
if (!hash) {

View File

@ -25,6 +25,16 @@
variant="solid"
:options="callActions"
/>
<Button
v-else-if="title == 'Events'"
variant="solid"
@click="modalRef.showEvent()"
>
<template #prefix>
<EventIcon class="h-4 w-4" />
</template>
<span>{{ __('Schedule an event') }}</span>
</Button>
<Button
v-else-if="title == 'Notes'"
variant="solid"
@ -75,6 +85,7 @@
import MultiActionButton from '@/components/MultiActionButton.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import EventIcon from '@/components/Icons/EventIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
@ -112,6 +123,11 @@ const defaultActions = computed(() => {
label: __('New Comment'),
onClick: () => (props.emailBox.showComment = true),
},
{
icon: h(EventIcon, { class: 'h-4 w-4' }),
label: __('Schedule an event'),
onClick: () => props.modalRef.showEvent(),
},
{
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
label: __('Log a Call'),

View File

@ -22,21 +22,36 @@
:referenceDoc="referenceDoc"
:options="{ afterInsert: () => activities.reload() }"
/>
<EventModal
v-if="showEventModal"
v-model="showEventModal"
:event="activeEvent"
:doctype="doctype"
:docname="doc?.name"
/>
</template>
<script setup>
import TaskModal from '@/components/Modals/TaskModal.vue'
import NoteModal from '@/components/Modals/NoteModal.vue'
import CallLogModal from '@/components/Modals/CallLogModal.vue'
import EventModal from '@/components/Modals/EventModal.vue'
import { showEventModal, activeEvent } from '@/composables/event'
import { call } from 'frappe-ui'
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const props = defineProps({
doctype: String,
doc: Object,
})
const activities = defineModel()
const doc = defineModel('doc')
// Event
function showEvent(e) {
showEventModal.value = true
activeEvent.value = e
}
// Tasks
const showTaskModal = ref(false)
@ -115,6 +130,7 @@ function redirect(tabName) {
}
defineExpose({
showEvent,
showTask,
deleteTask,
updateTaskStatus,

View File

@ -89,7 +89,7 @@ import VolumnHighIcon from '@/components/Icons/VolumnHighIcon.vue'
import MuteIcon from '@/components/Icons/MuteIcon.vue'
import PlaybackSpeedIcon from '@/components/Icons/PlaybackSpeedIcon.vue'
import PlaybackSpeedOption from '@/components/Activities/PlaybackSpeedOption.vue'
import Dropdown from '@/components/frappe-ui/Dropdown.vue'
import { Dropdown } from 'frappe-ui'
import { computed, h, ref } from 'vue'
const props = defineProps({

View File

@ -0,0 +1,109 @@
<template>
<div v-if="events.length" v-for="(event, i) in events" :key="event.name">
<div
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-3 sm:px-10"
>
<div
class="z-0 relative flex justify-center before:absolute before:left-[50%] before:-z-[1] before:top-0 before:border-l before:border-outline-gray-modals"
:class="i != events.length - 1 ? 'before:h-full' : 'before:h-4'"
>
<div
class="flex h-8 w-7 items-center justify-center bg-surface-white text-ink-gray-8"
>
<CalendarIcon class="h-4 w-4" />
</div>
</div>
<div class="mb-5">
<div
class="mb-1 flex items-center justify-stretch gap-2 py-1 text-base"
>
<div class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5">
<Avatar
:image="event.owner.image"
:label="event.owner.label"
size="md"
/>
<span class="font-medium text-ink-gray-8 ml-1">
{{ event.owner.label }}
</span>
<span>{{ 'has created an event' }}</span>
</div>
<div class="ml-auto whitespace-nowrap">
<Tooltip :text="formatDate(event.creation)">
<div class="text-sm text-ink-gray-5">
{{ __(timeAgo(event.creation)) }}
</div>
</Tooltip>
</div>
</div>
<div
class="flex gap-2 border cursor-pointer border-outline-gray-modals rounded-lg bg-surface-cards px-2.5 py-2.5 text-ink-gray-9"
@click="showEvent(event)"
>
<div
class="flex w-[2px] rounded-lg"
:style="{ backgroundColor: event.color || '#30A66D' }"
/>
<div class="flex-1 flex flex-col gap-1 text-base">
<div
class="flex items-center justify-between gap-2 font-medium text-ink-gray-7"
>
<div>{{ event.subject }}</div>
<MultipleAvatar
v-if="event.participants?.length > 1"
:avatars="event.participants"
size="sm"
/>
</div>
<div
class="flex justify-between gap-2 items-center text-ink-gray-6"
>
<div>
{{
startEndTime(event.starts_on, event.ends_on, event.all_day)
}}
</div>
<div>{{ startDate(event.starts_on) }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div
v-else
class="flex h-full flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-4"
>
<CalendarIcon class="h-10 w-10" />
<span>{{ __('No Events Scheduled') }}</span>
<Button :label="__('Schedule an Event')" @click="showEvent()" />
</div>
</template>
<script setup>
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import { useEvent, showEventModal, activeEvent } from '@/composables/event'
import { formatDate, timeAgo } from '@/utils'
import { Tooltip, Avatar } from 'frappe-ui'
const props = defineProps({
doctype: {
type: String,
default: '',
},
docname: {
type: String,
default: '',
},
})
function showEvent(e = {}) {
showEventModal.value = true
activeEvent.value = e
}
const { events, startEndTime, startDate } = useEvent(
props.doctype,
props.docname,
)
</script>

View File

@ -0,0 +1,322 @@
<template>
<div>
<div
class="flex items-center justify-between text-ink-gray-7 [&>div]:w-full"
>
<Popover v-model:show="showOptions">
<template #target="{ togglePopover }">
<TextInput
ref="search"
type="text"
:size="size"
class="w-full"
variant="outline"
v-model="query"
:debounce="300"
:placeholder="placeholder"
@click="togglePopover"
@keydown="onKeydown"
>
<template #suffix>
<FeatherIcon
name="chevron-down"
class="h-4 text-ink-gray-5"
@click.stop="togglePopover()"
/>
</template>
</TextInput>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<ul
v-if="options.length"
role="listbox"
class="p-1.5 max-h-[12rem] overflow-y-auto"
>
<li
v-for="(option, idx) in options"
:key="option.value"
role="option"
:aria-selected="idx === highlightIndex"
@click="selectOption(option)"
@mouseenter="highlightIndex = idx"
class="flex cursor-pointer items-center rounded px-2 py-1 text-base"
:class="{ 'bg-surface-gray-3': idx === highlightIndex }"
>
<UserAvatar class="mr-2" :user="option.value" size="lg" />
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ option.label }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ul>
<div
v-else
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon v-if="fetchContacts" name="search" class="h-4" />
{{
fetchContacts
? __('No results found')
: __('Type an email address to add attendee')
}}
</div>
</div>
</div>
</template>
</Popover>
</div>
<div
v-if="values.length"
class="flex flex-col gap-2 mt-2 max-h-[165px] overflow-y-auto"
ref="optionsRef"
>
<Button
ref="emails"
v-for="att in values"
:key="att.email"
:label="att.email"
theme="gray"
class="rounded-full w-fit"
:tooltip="getTooltip(att.email)"
>
<template #prefix>
<UserAvatar :user="att.email" class="-ml-1 !size-5.5" />
</template>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(att.email)"
/>
</template>
</Button>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
</div>
</div>
</template>
<script setup>
import UserAvatar from '@/components/UserAvatar.vue'
import { createResource, TextInput, Popover } from 'frappe-ui'
import { ref, computed, nextTick, watch } from 'vue'
import { watchDebounced } from '@vueuse/core'
const props = defineProps({
validate: {
type: Function,
default: null,
},
variant: {
type: String,
default: 'subtle',
},
size: {
type: String,
default: 'sm',
},
placeholder: {
type: String,
default: 'Add attendee',
},
inputClass: {
type: String,
default: '',
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
fetchContacts: {
type: Boolean,
default: true,
},
existingEmails: {
type: Array,
default: () => [],
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const info = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const optionsRef = ref(null)
const highlightIndex = ref(-1)
const metaByEmail = computed(() => {
const out = {}
const source = values.value || []
for (const a of source) {
if (a?.email) out[a.email] = a
}
return out
})
function getTooltip(email) {
const m = metaByEmail.value[email]
if (!m) return email
const parts = []
if (m.reference_doctype) parts.push(m.reference_doctype)
if (m.reference_docname) parts.push(m.reference_docname)
return parts.length ? parts.join(': ') : email
}
watchDebounced(
query,
(val) => {
val = val || ''
if (text.value === val && options.value?.length) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true },
)
const filterOptions = createResource({
url: 'crm.api.contact.search_emails',
method: 'POST',
cache: [text.value, 'Contact'],
params: { txt: text.value },
transform: (data) => {
let allData = data.map((option) => {
let fullName = option[0]
let email = option[1]
let name = option[2]
return {
label: fullName || name || email,
name: name,
value: email,
}
})
// Filter out existing emails
if (props.existingEmails?.length) {
allData = allData.filter((option) => {
return !props.existingEmails.includes(option.value)
})
}
return allData
},
})
const options = computed(() => {
let searchedContacts = props.fetchContacts ? filterOptions.data : []
if (!searchedContacts?.length && query.value) {
searchedContacts.push({
name: 'new',
label: query.value,
value: query.value,
})
}
return searchedContacts || []
})
function reload(val) {
if (!props.fetchContacts) return
filterOptions.update({
params: { txt: val },
})
filterOptions.reload()
}
watch(
() => options.value,
() => {
highlightIndex.value = options.value.length ? 0 : -1
},
)
function selectOption(option) {
if (!option) return
addValue(option)
!error.value && (query.value = '')
showOptions.value = false
}
function onKeydown(e) {
if (e.key === 'Enter') {
if (highlightIndex.value >= 0 && options.value[highlightIndex.value]) {
selectOption(options.value[highlightIndex.value])
} else if (query.value) {
// Add entered email directly
selectOption({ name: 'new', label: query.value, value: query.value })
}
e.preventDefault()
} else if (e.key === 'Escape') {
showOptions.value = false
}
}
const addValue = (option) => {
// Safeguard for falsy option
if (!option || !option.value) return
error.value = null
info.value = null
const current = Array.isArray(values.value) ? values.value.slice() : []
const existing = new Set(current.map((a) => a.email))
const raw = option.value || ''
const parts = raw.split(',')
const hasMultiple = parts.length > 1
for (let p of parts) {
p = p.trim()
if (!p) continue
if (existing.has(p)) {
info.value = __('email already exists')
continue
}
if (props.validate && !props.validate(p)) {
error.value = props.errorMessage(p)
query.value = p
continue
}
existing.add(p)
const entry = { email: p }
if (option.name && !hasMultiple) {
entry.reference_docname = option.name
}
current.push(entry)
}
values.value = current
// Scroll to the bottom so the last added value is visible
nextTick(() => {
// use requestAnimationFrame to ensure DOM paint
requestAnimationFrame(() => {
const el = optionsRef.value
if (el) {
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' })
}
})
})
}
const removeValue = (email) => {
values.value = (values.value || []).filter((a) => a.email !== email)
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
</script>

View File

@ -0,0 +1,706 @@
<template>
<div v-if="show" class="flex flex-col w-[352px] text-base">
<!-- Event Header -->
<div
class="flex items-center justify-between p-4.5 text-ink-gray-7 text-lg font-medium"
>
<div
class="flex items-center gap-x-2"
:class="mode == 'edit' && 'cursor-pointer hover:text-ink-gray-8'"
@click="mode == 'edit' && details()"
>
<LucideChevronLeft v-if="mode == 'edit'" class="size-4" />
{{ __(title) }}
</div>
<div class="flex items-center gap-x-1">
<ShortcutTooltip
v-if="mode == 'details'"
:label="__('Edit event')"
combo="Enter"
>
<Button :icon="EditIcon" variant="ghost" @click="editDetails" />
</ShortcutTooltip>
<ShortcutTooltip
v-if="mode === 'edit' || mode === 'details'"
:label="__('Delete event')"
combo="Delete"
:alt-combos="['Backspace']"
>
<Button icon="trash-2" variant="ghost" @click="deleteEvent" />
</ShortcutTooltip>
<ShortcutTooltip
v-if="mode === 'edit' || mode === 'details'"
:label="__('Duplicate event')"
combo="Mod+D"
>
<Button icon="copy" variant="ghost" @click="duplicateEvent" />
</ShortcutTooltip>
<ShortcutTooltip :label="__('Close panel')" combo="Esc">
<Button icon="x" variant="ghost" @click="close" />
</ShortcutTooltip>
</div>
</div>
<!-- Event Details -->
<div v-if="mode == 'details'" class="flex flex-col overflow-y-auto">
<div class="flex items-start gap-2 px-4.5 py-3 pb-0">
<div
class="mx-0.5 my-[5px] size-2.5 rounded-full cursor-pointer"
:style="{
backgroundColor: _event.color || '#30A66D',
}"
/>
<div class="flex flex-col gap-[3px]">
<div class="text-ink-gray-8 font-semibold text-xl">
{{ _event.title || __('(No title)') }}
</div>
<div class="text-ink-gray-6 text-p-base">{{ formattedDateTime }}</div>
</div>
</div>
<div
v-if="_event.referenceDocname"
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
/>
<div
v-if="_event.referenceDocname"
class="flex items-center px-4.5 py-1 text-ink-gray-7"
>
<component
:is="_event.referenceDoctype == 'CRM Lead' ? LeadsIcon : DealsIcon"
class="size-4"
/>
<Link
class="[&_button]:bg-surface-white [&_button]:select-text [&_button]:text-ink-gray-7 [&_button]:cursor-text"
v-model="_event.referenceDocname"
:doctype="_event.referenceDoctype"
:disabled="true"
/>
<Button variant="ghost" @click="redirect">
<template #icon>
<ArrowUpRightIcon class="size-4 text-ink-gray-7" />
</template>
</Button>
</div>
<div
v-if="peoples.length"
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
/>
<div v-if="peoples.length" class="px-4.5 py-2">
<div class="flex gap-3 text-ink-gray-7 mb-3">
<PeopleIcon class="size-4" />
<div>{{ __('{0} Attendees', [peoples.length + 1]) }}</div>
</div>
<div class="flex flex-col gap-2 -ml-1">
<Button
:key="_event.owner"
variant="ghost"
theme="gray"
class="rounded-full w-fit !h-8.5 !pr-3"
:tooltip="__('Owner: {0}', [_event.owner?.label])"
>
<template #default>
<div class="flex flex-col justify-start items-start text-sm">
<div>{{ _event.owner?.label }}</div>
<div class="text-ink-gray-5">{{ __('Organizer') }}</div>
</div>
</template>
<template #prefix>
<UserAvatar :user="_event.owner?.value" class="-ml-1 !size-5" />
</template>
</Button>
<Button
v-for="att in displayedPeoples"
:key="att.email"
:label="att.email"
variant="ghost"
theme="gray"
class="rounded-full w-fit !text-sm"
:tooltip="getTooltip(att)"
>
<template #prefix>
<UserAvatar :user="att.email" class="-ml-1 !size-5" />
</template>
</Button>
<Button
v-if="!showAllParticipants && peoples.length > 2"
variant="ghost"
:label="__('See all participants')"
iconLeft="more-horizontal"
class="!justify-start w-fit"
@click="showAllParticipants = true"
/>
<Button
v-else-if="showAllParticipants"
variant="ghost"
:label="__('Show less')"
iconLeft="chevron-up"
class="!justify-start w-fit"
@click="showAllParticipants = false"
/>
</div>
</div>
<div
v-if="_event.description && _event.description !== '<p></p>'"
class="mx-4.5 my-2.5 border-t border-outline-gray-1"
/>
<div v-if="_event.description && _event.description !== '<p></p>'">
<div class="flex gap-2 items-center text-ink-gray-7 px-4.5 py-1">
<DescriptionIcon class="size-4" />
{{ __('Description') }}
</div>
<div
class="px-4.5 py-2 text-ink-gray-7 text-p-base"
v-html="_event.description"
/>
</div>
</div>
<!-- Event new, duplicate & edit -->
<div v-else class="flex flex-col overflow-y-auto">
<div class="flex gap-2 items-center px-4.5 py-3">
<Dropdown class="ml-1" :options="colors">
<div
class="flex items-center justify-center size-7 shrink-0 border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 hover:shadow-sm rounded cursor-pointer"
>
<div
class="size-2.5 rounded-full cursor-pointer"
:style="{
backgroundColor: _event.color || '#30A66D',
}"
/>
</div>
</Dropdown>
<TextInput
ref="eventTitle"
class="w-full"
variant="outline"
v-model="_event.title"
:debounce="500"
:placeholder="__('Event title')"
@change="sync"
@keyup.enter="saveEvent"
/>
</div>
<div class="flex justify-between py-2.5 px-4.5 text-ink-gray-6">
<div class="flex items-center">
<Switch v-model="_event.isFullDay" @update:model-value="sync" />
<div class="ml-2">
{{ __('All day') }}
</div>
</div>
<!-- <div class="flex items-center gap-1.5 text-ink-gray-5">
<LucideEarth class="size-4" />
{{ __('GMT+5:30') }}
</div> -->
</div>
<div
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
>
<div class="">{{ __('Date') }}</div>
<div class="flex items-center gap-x-1.5">
<DatePicker
:class="['[&_input]:w-[216px]']"
variant="outline"
:value="_event.fromDate"
:format="'MMM D, YYYY'"
:placeholder="__('May 1, 2025')"
:clearable="false"
@update:modelValue="(date) => updateDate(date, true)"
>
<template #suffix="{ togglePopover }">
<FeatherIcon
name="chevron-down"
class="h-4 w-4 cursor-pointer"
@click="togglePopover"
/>
</template>
</DatePicker>
</div>
</div>
<div
v-if="!_event.isFullDay"
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
>
<div class="w-20">{{ __('Time') }}</div>
<div class="flex items-center gap-x-1.5">
<TimePicker
v-if="!_event.isFullDay"
class="max-w-[105px]"
variant="outline"
:modelValue="_event.fromTime"
:placeholder="__('Start Time')"
@update:modelValue="(time) => updateTime(time, true)"
/>
<TimePicker
class="max-w-[105px]"
variant="outline"
:modelValue="_event.toTime"
:options="toOptions"
:placeholder="__('End Time')"
placement="bottom-end"
@update:modelValue="(time) => updateTime(time)"
/>
</div>
</div>
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
<div
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
>
<div class="">{{ __('Link') }}</div>
<div class="flex items-center gap-x-1.5">
<FormControl
class="w-[216px]"
type="select"
:options="[
{
label: '',
value: '',
},
{
label: __('Lead'),
value: 'CRM Lead',
},
{
label: __('Deal'),
value: 'CRM Deal',
},
]"
v-model="_event.referenceDoctype"
variant="outline"
:placeholder="__('Add Lead or Deal')"
@change="
() => {
_event.referenceDocname = ''
sync()
}
"
/>
</div>
</div>
<div
v-if="_event.referenceDoctype"
class="flex items-center justify-between px-4.5 py-[7px] text-ink-gray-7"
>
<div class="">
{{ _event.referenceDoctype == 'CRM Lead' ? __('Lead') : __('Deal') }}
</div>
<div class="flex items-center gap-x-1.5">
<Link
class="w-[220px]"
v-model="_event.referenceDocname"
:doctype="_event.referenceDoctype"
variant="outline"
@update:model-value="sync"
/>
</div>
</div>
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
<Attendee
class="px-4.5 py-[7px]"
v-model="peoples"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
<div class="mx-4.5 my-2.5 border-t border-outline-gray-1" />
<div class="px-4.5 py-3">
<div class="flex items-center gap-x-2 border rounded py-1">
<TextEditor
editor-class="!prose-sm overflow-auto min-h-[22px] max-h-32 px-2.5 rounded placeholder-ink-gray-4 focus:bg-surface-white focus:ring-0 text-ink-gray-8 transition-colors"
:bubbleMenu="true"
:content="_event.description"
@change="
(val) => {
_event.description = val
sync()
}
"
:placeholder="__('Add description')"
/>
</div>
</div>
</div>
<div v-if="mode != 'details'" class="px-4.5 py-3">
<ErrorMessage class="my-2" :message="error" />
<div class="w-full">
<Button
variant="solid"
class="w-full"
:disabled="!dirty"
@click="saveEvent"
>
{{
mode === 'edit'
? __('Save')
: mode === 'duplicate'
? __('Duplicate event')
: __('Create event')
}}
</Button>
</div>
</div>
</div>
</template>
<script setup>
import PeopleIcon from '@/components/Icons/PeopleIcon.vue'
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import Link from '@/components/Controls/Link.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import DescriptionIcon from '@/components/Icons/DescriptionIcon.vue'
import { globalStore } from '@/stores/global'
import { validateEmail } from '@/utils'
import {
normalizeParticipants,
buildEndTimeOptions,
computeAutoToTime,
validateTimeRange,
parseEventDoc,
} from '@/composables/event'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import {
TextInput,
Switch,
DatePicker,
TimePicker,
TextEditor,
ErrorMessage,
Dropdown,
dayjs,
CalendarColorMap as colorMap,
CalendarActiveEvent as activeEvent,
createDocumentResource,
} from 'frappe-ui'
import ShortcutTooltip from '@/components/ShortcutTooltip.vue'
import { ref, computed, watch, h } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
mode: {
type: String,
default: 'details',
},
})
const emit = defineEmits([
'save',
'edit',
'delete',
'details',
'close',
'sync',
'duplicate',
])
const router = useRouter()
const { $dialog } = globalStore()
const show = defineModel()
const event = defineModel('event')
const _event = ref({})
const peoples = computed({
get() {
return _event.value.event_participants || []
},
set(list) {
_event.value.event_participants = normalizeParticipants(list)
sync()
},
})
const title = computed(() => {
if (props.mode === 'details') return __('Event details')
if (props.mode === 'edit') return __('Editing event')
if (props.mode === 'new') return __('New event')
return __('Duplicate event')
})
const eventTitle = ref(null)
const error = ref(null)
const showAllParticipants = ref(false)
const eventResource = ref({})
const oldEvent = ref(null)
const dirty = computed(() => {
return JSON.stringify(oldEvent.value) !== JSON.stringify(_event.value)
})
const displayedPeoples = computed(() => {
if (showAllParticipants.value) return peoples.value
return peoples.value.slice(0, 2)
})
watch(
[() => props.mode, () => event.value],
([mode, event], [oldMode, oldEvent]) => {
error.value = null
focusOnTitle()
fetchEvent(oldMode)
},
{ immediate: true },
)
function fetchEvent(oldMode) {
if (
event.value.id &&
event.value.id !== 'new-event' &&
event.value.id !== 'duplicate-event'
) {
eventResource.value = createDocumentResource({
doctype: 'Event',
name: event.value.id,
fields: ['*'],
onSuccess: (data) => {
_event.value = parseEventDoc(data)
oldEvent.value = { ..._event.value }
},
})
if (eventResource.value.doc && !event.value.reloadEvent) {
_event.value = parseEventDoc(eventResource.value.doc)
oldEvent.value = { ..._event.value }
} else {
eventResource.value.reload()
}
} else {
_event.value = event.value
if (oldMode !== props.mode) {
oldEvent.value = { ...event.value }
}
if (event.value.id === 'duplicate-event' && oldMode !== 'duplicate') {
_event.value.title = _event.value.title + ' (Copy)'
}
}
showAllParticipants.value = false
}
function focusOnTitle() {
setTimeout(() => {
if (['edit', 'new', 'duplicate'].includes(props.mode)) {
eventTitle.value?.el?.focus()
}
}, 100)
}
function sync() {
emit('sync', _event.value.id, _event.value)
}
function updateDate(d) {
_event.value.fromDate = d
_event.value.toDate = d
sync()
}
function updateTime(t, fromTime = false) {
error.value = null
const prevTo = _event.value.toTime
if (fromTime) {
_event.value.fromTime = t
if (!_event.value.toTime || _event.value.toTime <= t) {
_event.value.toTime = computeAutoToTime(t)
}
} else {
_event.value.toTime = t
}
const { valid, error: err } = validateTimeRange({
fromDate: _event.value.fromDate,
fromTime: _event.value.fromTime,
toTime: _event.value.toTime,
isFullDay: _event.value.isFullDay,
})
if (!valid) {
error.value = err
_event.value.toTime = prevTo
} else {
sync()
}
}
function saveEvent() {
error.value = null
if (!_event.value.title) {
error.value = __('Title is required')
eventTitle.value.el.focus()
return
}
const { valid, error: err } = validateTimeRange({
fromDate: _event.value.fromDate,
fromTime: _event.value.fromTime,
toTime: _event.value.toTime,
isFullDay: _event.value.isFullDay,
})
if (!valid) {
error.value = err
return
}
oldEvent.value = { ..._event.value }
sync()
emit('save', _event.value)
}
function editDetails() {
emit('edit', _event.value)
}
function duplicateEvent() {
if (dirty.value) {
showDiscardChangesModal(() => reset())
} else {
emit('duplicate', _event.value)
}
}
function deleteEvent() {
emit('delete', _event.value.id)
}
function details() {
if (dirty.value) {
showDiscardChangesModal(() => reset())
} else {
emit('details', _event.value)
}
}
function close() {
const _close = () => {
show.value = false
activeEvent.value = ''
emit('close', _event.value)
}
if (dirty.value) {
showDiscardChangesModal(() => {
reset()
if (['new-event', 'duplicate-event'].includes(_event.value.id)) _close()
})
} else {
_close()
}
}
function reset() {
Object.assign(_event.value, oldEvent.value)
sync()
}
function showDiscardChangesModal(action) {
$dialog({
title: __('Discard unsaved changes?'),
message: __(
'Are you sure you want to discard unsaved changes to this event?',
),
actions: [
{
label: __('Cancel'),
onClick: (close) => {
close()
},
},
{
label: __('Discard'),
variant: 'solid',
onClick: (close) => {
action()
close()
},
},
],
})
}
const formattedDateTime = computed(() => {
const date = dayjs(_event.value.fromDate)
if (_event.value.isFullDay) {
return `${__('All day')} - ${date.format('ddd, D MMM YYYY')}`
}
const start = dayjs(_event.value.fromDate + ' ' + _event.value.fromTime)
const end = dayjs(_event.value.toDate + ' ' + _event.value.toTime)
return `${start.format('h:mm a')} - ${end.format('h:mm a')} ${date.format('ddd, D MMM YYYY')}`
})
const colors = Object.keys(colorMap).map((color) => ({
label: color.charAt(0).toUpperCase() + color.slice(1),
value: colorMap[color].color,
icon: h('div', {
class: '!size-2.5 rounded-full',
style: { backgroundColor: colorMap[color].color },
}),
onClick: () => {
_event.value.color = colorMap[color].color
sync()
},
}))
function redirect() {
if (_event.value.referenceDocname) {
let name = _event.value.referenceDoctype === 'CRM Lead' ? 'Lead' : 'Deal'
let params =
_event.value.referenceDoctype == 'CRM Lead'
? { leadId: _event.value.referenceDocname }
: { dealId: _event.value.referenceDocname }
router.push({ name, params })
}
}
function getTooltip(m) {
if (!m) return email
const parts = []
if (m.reference_doctype) parts.push(m.reference_doctype)
if (m.reference_docname) parts.push(m.reference_docname)
return parts.length ? parts.join(': ') : email
}
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
function updateEvent(_e) {
Object.assign(_event.value, _e)
}
defineExpose({ updateEvent })
// Keyboard shortcuts
useKeyboardShortcuts({
active: () => show.value,
shortcuts: [
{ keys: 'Escape', action: () => close() },
{
keys: 'Enter',
guard: () =>
['details', 'edit'].includes(props.mode) && props.mode === 'details',
action: () => editDetails(),
},
{
keys: ['Delete', 'Backspace'],
guard: () => ['details', 'edit'].includes(props.mode),
action: () => deleteEvent(),
},
{
match: (e) =>
['details', 'edit'].includes(props.mode) &&
(e.metaKey || e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'd',
action: () => duplicateEvent(),
},
],
})
</script>

View File

@ -0,0 +1,16 @@
export function allTimeSlots() {
const out = []
for (let h = 0; h < 24; h++) {
for (const m of [0, 15, 30, 45]) {
const hh = String(h).padStart(2, '0')
const mm = String(m).padStart(2, '0')
const ampm = h >= 12 ? 'pm' : 'am'
const hour12 = h % 12 === 0 ? 12 : h % 12
out.push({
value: `${hh}:${mm}`,
label: `${hour12}:${mm} ${ampm}`,
})
}
}
return out
}

View File

@ -0,0 +1,314 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:class="{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<ComboboxRoot
:model-value="tempSelection"
:open="showOptions"
@update:open="(o) => (showOptions = o)"
@update:modelValue="onSelect"
:ignore-filter="true"
>
<ComboboxAnchor
class="flex h-7 max-w-full w-auto items-center gap-2 rounded px-2 py-1 border border-transparent"
:class="[
variant == 'ghost'
? 'bg-surface-white hover:bg-surface-white'
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
inputClass,
]"
>
<ComboboxInput
ref="search"
:value="query"
autocomplete="off"
class="bg-transparent p-0 outline-none border-0 text-base text-ink-gray-8 h-full placeholder:text-ink-gray-4 w-full focus:outline-none focus:ring-0 focus:border-0"
:placeholder="placeholder"
@focus="showOptions = true"
@input="onInput"
@keydown.delete.capture.stop="removeLastValue"
@keydown.enter.prevent="handleEnter"
/>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
class="z-10 mt-1 min-w-48 w-auto max-w-96 bg-surface-modal overflow-hidden rounded-lg shadow-2xl ring-1 ring-black ring-opacity-5"
position="popper"
:align="'start'"
@openAutoFocus.prevent
@closeAutoFocus.prevent
>
<ComboboxViewport class="max-h-60 overflow-auto p-1.5">
<ComboboxEmpty
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon
v-if="showSearchIcon"
name="search"
class="h-4"
/>
{{ emptyStateText }}
</ComboboxEmpty>
<ComboboxItem
v-for="option in options"
:key="option.value"
:value="option.value"
class="text-base leading-none text-ink-gray-7 rounded flex items-center px-2 py-1 relative select-none data-[highlighted]:outline-none data-[highlighted]:bg-surface-gray-3 cursor-pointer"
@mousedown.prevent="onSelect(option.value)"
>
<UserAvatar class="mr-2" :user="option.value" size="lg" />
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">{{ option.label }}</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
>
{{ info }}
</div>
</div>
</template>
<script setup>
// Generic multi-source (users / contacts / free) multi-select email-like input
import UserAvatar from '@/components/UserAvatar.vue'
import { usersStore } from '@/stores/users'
import { createResource } from 'frappe-ui'
import {
ComboboxRoot,
ComboboxAnchor,
ComboboxInput,
ComboboxPortal,
ComboboxContent,
ComboboxViewport,
ComboboxItem,
ComboboxEmpty,
} from 'reka-ui'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
const props = defineProps({
// Behaviour
mode: { type: String, default: null }, // 'users' | 'contacts' | 'free' (fallback to legacy flags)
fetchUsers: { type: Boolean, default: false },
fetchContacts: { type: Boolean, default: false },
existingEmails: { type: Array, default: () => [] },
validate: { type: Function, default: null },
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
emptyPlaceholder: { type: String, default: __('No results found') },
// UI
variant: { type: String, default: 'subtle' },
placeholder: { type: String, default: '' },
inputClass: { type: String, default: '' },
})
// v-model values
const values = defineModel()
// Determine effective mode (backwards compatibility with old components)
const effectiveMode = computed(() => {
if (props.mode) return props.mode
if (props.fetchUsers) return 'users'
if (props.fetchContacts) return 'contacts'
return 'free'
})
// Common state
const emails = ref([])
const search = ref(null)
const error = ref(null)
const info = ref(null)
const query = ref('')
const showOptions = ref(false)
const tempSelection = ref(null)
// Users data
const { users } = usersStore()
// Contacts resource (only if needed)
const filterOptions = ref(null)
const lastLoadedQuery = ref('')
if (effectiveMode.value === 'contacts') {
filterOptions.value = createResource({
url: 'crm.api.contact.search_emails',
method: 'POST',
cache: ['ContactEmails'],
params: { txt: '' },
transform: (data) => {
let allData = (data || []).map((option) => {
const fullName = option[0]
const email = option[1]
const name = option[2]
return { label: fullName || name || email, value: email }
})
if (props.existingEmails?.length) {
allData = allData.filter((o) => !props.existingEmails.includes(o.value))
}
return allData
},
})
watchDebounced(
query,
(val) => {
val = val || ''
if (lastLoadedQuery.value === val && options.value?.length) return
lastLoadedQuery.value = val
reload(val)
},
{ debounce: 300, immediate: true },
)
}
function reload(val) {
if (effectiveMode.value !== 'contacts' || !filterOptions.value) return
filterOptions.value.update({ params: { txt: val } })
filterOptions.value.reload()
}
// Options computed
const options = computed(() => {
const mode = effectiveMode.value
if (mode === 'users') {
let list = users?.data?.allUsers || []
list = list.map((u) => ({
label: u.full_name || u.name || u.email,
value: u.email,
}))
if (props.existingEmails?.length) {
list = list.filter((o) => !props.existingEmails.includes(o.value))
}
if (query.value) {
const q = query.value.toLowerCase()
list = list.filter(
(o) =>
o.label?.toLowerCase().includes(q) ||
o.value?.toLowerCase().includes(q),
)
}
return list
}
if (mode === 'contacts') {
const list = filterOptions.value?.data ? [...filterOptions.value.data] : []
if (!list.length && query.value) {
list.push({ label: query.value, value: query.value })
}
return list
}
// Free / manual mode
return query.value ? [{ label: query.value, value: query.value }] : []
})
const showSearchIcon = computed(() => effectiveMode.value !== 'free')
const emptyStateText = computed(() => {
if (effectiveMode.value === 'free') return __(props.emptyPlaceholder)
return options.value.length ? '' : __(props.emptyPlaceholder)
})
function addValue(input) {
if (!input) return
error.value = null
info.value = null
const parts = input
.split(',')
.map((p) => p.trim())
.filter(Boolean)
for (const email of parts) {
if (values.value?.includes(email)) {
info.value = __('email already exists')
continue
}
if (props.validate && !props.validate(email)) {
error.value = props.errorMessage(email)
query.value = email
break
}
if (!values.value) values.value = [email]
else values.value.push(email)
}
}
function removeValue(value) {
values.value = values.value.filter((v) => v !== value)
}
function removeLastValue() {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.rootRef
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].rootRef
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value?.focus?.()
}
defineExpose({ setFocus })
function onInput(e) {
query.value = e.target.value
showOptions.value = true
}
function onSelect(val) {
if (!val) return
addValue(val)
if (!error.value) {
query.value = ''
tempSelection.value = null
showOptions.value = false
nextTick(() => setFocus())
}
}
function handleEnter() {
if (query.value) onSelect(query.value)
}
</script>

View File

@ -178,21 +178,27 @@
@change="(e) => fieldChange(e.target.checked, field, row)"
/>
</div>
<TimePicker
v-else-if="field.fieldtype === 'Time'"
:value="row[field.fieldname]"
variant="outline"
:format="getFormat('', '', false, true, false)"
input-class="border-none text-sm text-ink-gray-8"
@change="(v) => fieldChange(v, field, row)"
/>
<DatePicker
v-else-if="field.fieldtype === 'Date'"
:value="row[field.fieldname]"
icon-left=""
variant="outline"
:formatter="(date) => getFormat(date, '', true)"
:format="getFormat('', '', true, false, false)"
input-class="border-none text-sm text-ink-gray-8"
@change="(v) => fieldChange(v, field, row)"
/>
<DateTimePicker
v-else-if="field.fieldtype === 'Datetime'"
:value="row[field.fieldname]"
icon-left=""
variant="outline"
:formatter="(date) => getFormat(date, '', true, true)"
:format="getFormat('', '', true, true, false)"
input-class="border-none text-sm text-ink-gray-8"
@change="(v) => fieldChange(v, field, row)"
/>
@ -349,6 +355,7 @@ import { createDocument } from '@/composables/document'
import {
FormControl,
Checkbox,
TimePicker,
DateTimePicker,
DatePicker,
Tooltip,

View File

@ -1,304 +0,0 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:class="{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
:class="[
variant == 'ghost'
? 'bg-surface-white hover:bg-surface-white'
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
inputClass,
]"
:placeholder="placeholder"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<ComboboxOptions
class="p-1.5 max-h-[12rem] overflow-y-auto"
static
>
<div
v-if="!options.length"
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon
v-if="fetchContacts"
name="search"
class="h-4"
/>
{{
fetchContacts
? __('No results found')
: __('Type an email address to invite')
}}
</div>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-3': active },
]"
>
<UserAvatar
class="mr-2"
:user="option.value"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ option.label }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
>
{{ info }}
</div>
</div>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Popover from '@/components/frappe-ui/Popover.vue'
import { createResource } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
import { watchDebounced } from '@vueuse/core'
const props = defineProps({
validate: {
type: Function,
default: null,
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: '',
},
inputClass: {
type: String,
default: '',
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
fetchContacts: {
type: Boolean,
default: true,
},
existingEmails: {
type: Array,
default: () => [],
},
})
const values = defineModel()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const info = ref(null)
const query = ref('')
const text = ref('')
const showOptions = ref(false)
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
val?.value && addValue(val.value)
},
})
watchDebounced(
query,
(val) => {
val = val || ''
if (text.value === val && options.value?.length) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true },
)
const filterOptions = createResource({
url: 'crm.api.contact.search_emails',
method: 'POST',
cache: [text.value, 'Contact'],
params: { txt: text.value },
transform: (data) => {
let allData = data.map((option) => {
let fullName = option[0]
let email = option[1]
let name = option[2]
return {
label: fullName || name || email,
value: email,
}
})
// Filter out existing emails
if (props.existingEmails?.length) {
allData = allData.filter((option) => {
return !props.existingEmails.includes(option.value)
})
}
return allData
},
})
const options = computed(() => {
let searchedContacts = props.fetchContacts ? filterOptions.data : []
if (!searchedContacts?.length && query.value) {
searchedContacts.push({
label: query.value,
value: query.value,
})
}
return searchedContacts || []
})
function reload(val) {
if (!props.fetchContacts) return
filterOptions.update({
params: { txt: val },
})
filterOptions.reload()
}
const addValue = (value) => {
error.value = null
info.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
query.value = value
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
} else {
info.value = __('email already exists')
}
}
})
!error.value && (value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
</script>

View File

@ -1,278 +0,0 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
ref="emails"
v-for="value in values"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:class="{
'rounded bg-surface-white hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<FeatherIcon
class="h-3.5"
name="x"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<Combobox v-model="selectedValue" nullable>
<Popover class="w-full" v-model:show="showOptions">
<template #target="{ togglePopover }">
<ComboboxInput
ref="search"
class="search-input form-input w-full border-none focus:border-none focus:!shadow-none focus-visible:!ring-0"
:class="[
variant == 'ghost'
? 'bg-surface-white hover:bg-surface-white'
: 'bg-surface-gray-2 hover:bg-surface-gray-3',
inputClass,
]"
:placeholder="placeholder"
type="text"
:value="query"
@change="
(e) => {
query = e.target.value
showOptions = true
}
"
autocomplete="off"
@focus="() => togglePopover()"
@keydown.delete.capture.stop="removeLastValue"
/>
</template>
<template #body="{ isOpen }">
<div v-show="isOpen">
<div
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<ComboboxOptions
class="p-1.5 max-h-[12rem] overflow-y-auto"
static
>
<div
v-if="!options.length"
class="flex gap-2 rounded px-2 py-1 text-base text-ink-gray-5"
>
<FeatherIcon
v-if="fetchUsers"
name="search"
class="h-4"
/>
{{
fetchUsers
? __('No results found')
: __('Type an email address to invite')
}}
</div>
<ComboboxOption
v-for="option in options"
:key="option.value"
:value="option"
v-slot="{ active }"
>
<li
:class="[
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
{ 'bg-surface-gray-3': active },
]"
>
<UserAvatar
class="mr-2"
:user="option.value"
size="lg"
/>
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
<div class="text-base font-medium">
{{ option.label }}
</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</li>
</ComboboxOption>
</ComboboxOptions>
</div>
</div>
</template>
</Popover>
</Combobox>
</div>
</div>
<ErrorMessage class="mt-2 pl-2" v-if="error" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-3 mt-2 pl-2"
>
{{ info }}
</div>
</div>
</template>
<script setup>
import {
Combobox,
ComboboxInput,
ComboboxOptions,
ComboboxOption,
} from '@headlessui/vue'
import UserAvatar from '@/components/UserAvatar.vue'
import Popover from '@/components/frappe-ui/Popover.vue'
import { usersStore } from '@/stores/users'
import { ref, computed, nextTick } from 'vue'
const props = defineProps({
validate: {
type: Function,
default: null,
},
variant: {
type: String,
default: 'subtle',
},
placeholder: {
type: String,
default: '',
},
inputClass: {
type: String,
default: '',
},
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
fetchUsers: {
type: Boolean,
default: true,
},
existingEmails: {
type: Array,
default: () => [],
},
})
const values = defineModel()
const { users } = usersStore()
const emails = ref([])
const search = ref(null)
const error = ref(null)
const info = ref(null)
const query = ref('')
const showOptions = ref(false)
const selectedValue = computed({
get: () => query.value || '',
set: (val) => {
query.value = ''
if (val) {
showOptions.value = false
}
val?.value && addValue(val.value)
},
})
const options = computed(() => {
let userEmails = props.fetchUsers ? users?.data?.allUsers : []
if (props.fetchUsers) {
userEmails = userEmails.map((user) => ({
label: user.full_name || user.name || user.email,
value: user.email,
}))
if (props.existingEmails?.length) {
userEmails = userEmails.filter((option) => {
return !props.existingEmails.includes(option.value)
})
}
if (query.value) {
userEmails = userEmails.filter(
(option) =>
option.label.toLowerCase().includes(query.value.toLowerCase()) ||
option.value.toLowerCase().includes(query.value.toLowerCase()),
)
}
} else if (!userEmails?.length && query.value) {
userEmails.push({
label: query.value,
value: query.value,
})
}
return userEmails || []
})
const addValue = (value) => {
error.value = null
info.value = null
if (value) {
const splitValues = value.split(',')
splitValues.forEach((value) => {
value = value.trim()
if (value) {
// check if value is not already in the values array
if (!values.value?.includes(value)) {
// check if value is valid
if (value && props.validate && !props.validate(value)) {
error.value = props.errorMessage(value)
query.value = value
return
}
// add value to values array
if (!values.value) {
values.value = [value]
} else {
values.value.push(value)
}
value = value.replace(value, '')
} else {
info.value = __('email already exists')
}
}
})
!error.value && (value = '')
}
}
const removeValue = (value) => {
values.value = values.value.filter((v) => v !== value)
}
const removeLastValue = () => {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.$el
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].$el
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value.$el.focus()
}
defineExpose({ setFocus })
</script>

View File

@ -20,11 +20,12 @@
<div class="flex flex-col gap-3">
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5">
<span class="text-xs text-ink-gray-4">{{ __('TO') }}:</span>
<MultiSelectEmailInput
<EmailMultiSelect
class="flex-1"
variant="ghost"
v-model="toEmails"
:validate="validateEmail"
:fetchContacts="true"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
@ -54,11 +55,12 @@
</div>
<div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2">
<span class="text-xs text-ink-gray-4">{{ __('CC') }}:</span>
<MultiSelectEmailInput
<EmailMultiSelect
ref="ccInput"
class="flex-1"
variant="ghost"
v-model="ccEmails"
:fetchContacts="true"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
@ -67,11 +69,12 @@
</div>
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2">
<span class="text-xs text-ink-gray-4">{{ __('BCC') }}:</span>
<MultiSelectEmailInput
<EmailMultiSelect
ref="bccInput"
class="flex-1"
variant="ghost"
v-model="bccEmails"
:fetchContacts="true"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
@ -179,7 +182,7 @@ import SmileIcon from '@/components/Icons/SmileIcon.vue'
import EmailTemplateIcon from '@/components/Icons/EmailTemplateIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import AttachmentItem from '@/components/AttachmentItem.vue'
import MultiSelectEmailInput from '@/components/Controls/MultiSelectEmailInput.vue'
import EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui'
import { capture } from '@/telemetry'

View File

@ -130,10 +130,18 @@
</Tooltip>
</template>
</Link>
<TimePicker
v-else-if="field.fieldtype === 'Time'"
:value="data[field.fieldname]"
:format="getFormat('', '', false, true, false)"
:placeholder="getPlaceholder(field)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<DateTimePicker
v-else-if="field.fieldtype === 'Datetime'"
:value="data[field.fieldname]"
:formatter="(date) => getFormat(date, '', true, true)"
:format="getFormat('', '', true, true, false)"
:placeholder="getPlaceholder(field)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
@ -141,7 +149,7 @@
<DatePicker
v-else-if="field.fieldtype === 'Date'"
:value="data[field.fieldname]"
:formatter="(date) => getFormat(date, '', true)"
:format="getFormat('', '', true, false, false)"
:placeholder="getPlaceholder(field)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
@ -225,7 +233,7 @@ import { flt } from '@/utils/numberFormat.js'
import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
import { Tooltip, DatePicker, DateTimePicker, TimePicker } from 'frappe-ui'
import { computed, provide, inject } from 'vue'
const props = defineProps({

View File

@ -0,0 +1,14 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7.66699 12.7109C7.89499 12.7574 8.06641 12.9594 8.06641 13.2012C8.06625 13.4428 7.89489 13.6449 7.66699 13.6914L7.56641 13.7012H1.5C1.22406 13.7011 1.00018 13.4771 1 13.2012C1 12.9251 1.22395 12.7013 1.5 12.7012H7.56641L7.66699 12.7109ZM14.6006 9.24414C14.8284 9.29081 15 9.4928 15 9.73438C14.9999 9.97585 14.8283 10.178 14.6006 10.2246L14.5 10.2344H1.5C1.22403 10.2343 1.00013 10.0103 1 9.73438C1 9.4583 1.22395 9.23448 1.5 9.23438H14.5L14.6006 9.24414ZM3.56934 2.45996C3.78682 2.52747 3.96526 2.69406 4.04297 2.91699L5.2168 6.29199C5.31837 6.58437 5.10155 6.88965 4.79199 6.88965H4.77441C4.57659 6.88946 4.40241 6.75957 4.34473 6.57031L4.11133 5.80664H2.53613L2.30273 6.57129C2.24483 6.7604 2.06986 6.88965 1.87207 6.88965H1.85742C1.54804 6.88951 1.33114 6.5843 1.43262 6.29199L2.59961 2.93066C2.70432 2.62909 2.98841 2.42694 3.30762 2.42676H3.56934V2.45996ZM14.6006 5.77734C14.8283 5.82404 15 6.02602 15 6.26758C14.9999 6.50907 14.8283 6.71112 14.6006 6.75781L14.5 6.76758H7.56641C7.29038 6.7675 7.06648 6.5436 7.06641 6.26758C7.06641 5.99149 7.29033 5.76766 7.56641 5.76758H14.5L14.6006 5.77734ZM2.75 5.1084H3.89844L3.35254 3.31738H3.29688L2.75 5.1084ZM14.6006 2.31055C14.8283 2.35723 14.9999 2.55931 15 2.80078C15 3.04233 14.8283 3.24432 14.6006 3.29102L14.5 3.30078H7.56641C7.29033 3.3007 7.06641 3.07687 7.06641 2.80078C7.06651 2.52478 7.2904 2.30086 7.56641 2.30078H14.5L14.6006 2.31055Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M5.5 1C5.5 0.723858 5.27614 0.5 5 0.5C4.72386 0.5 4.5 0.723858 4.5 1V2.00057C3.42774 2.00446 2.83574 2.03488 2.36942 2.27248C1.89901 2.51217 1.51656 2.89462 1.27688 3.36502C1.00439 3.8998 1.00439 4.59987 1.00439 6V9.0642C1 9.33719 1 9.64625 1 10V11C1 12.4001 1 13.1002 1.27248 13.635C1.51217 14.1054 1.89462 14.4878 2.36502 14.7275C2.8998 15 3.59987 15 5 15L5.00439 15L11 15L11.0044 15L11.2588 14.9999C12.4914 14.9989 13.138 14.983 13.6394 14.7275C14.1098 14.4878 14.4922 14.1054 14.7319 13.635C15.0044 13.1002 15.0044 12.4001 15.0044 11V6C15.0044 4.59987 15.0044 3.8998 14.7319 3.36502C14.4922 2.89462 14.1098 2.51217 13.6394 2.27248C13.1718 2.03423 12.5778 2.0043 11.5 2.00054V1C11.5 0.723858 11.2761 0.5 11 0.5C10.7239 0.5 10.5 0.723858 10.5 1V2H5.5V1ZM10.5 4V3H5.5V4C5.5 4.27614 5.27614 4.5 5 4.5C4.72386 4.5 4.5 4.27614 4.5 4V3.00063C4.05122 3.0023 3.71688 3.00843 3.44383 3.03074C3.08879 3.05975 2.92633 3.11105 2.82341 3.16349C2.54117 3.3073 2.31169 3.53677 2.16788 3.81901C2.11544 3.92194 2.06414 4.0844 2.03513 4.43944C2.00517 4.80615 2.00439 5.28343 2.00439 6V6.49671C2.11748 6.41228 2.23805 6.33718 2.36502 6.27248C2.8998 6 3.59987 6 5 6H11C12.4001 6 13.1002 6 13.635 6.27248C13.7653 6.33886 13.8888 6.41619 14.0044 6.5033V6C14.0044 5.28343 14.0036 4.80615 13.9737 4.43944C13.9446 4.0844 13.8933 3.92194 13.8409 3.81901C13.6971 3.53677 13.4676 3.3073 13.1854 3.16349C13.0825 3.11105 12.92 3.05975 12.565 3.03074C12.2901 3.00829 11.9532 3.00222 11.5 3.00059V4C11.5 4.27614 11.2761 4.5 11 4.5C10.7239 4.5 10.5 4.27614 10.5 4ZM3.44383 13.9693C3.75328 13.9945 4.14147 13.999 4.68573 13.9998L4.87281 14L5.00439 14L11 14L11.0044 14L11.2621 13.9999C11.8362 13.9993 12.2405 13.9954 12.5606 13.9693C12.9156 13.9403 13.0781 13.889 13.181 13.8365C13.4632 13.6927 13.6927 13.4632 13.8365 13.181C13.889 13.0781 13.9403 12.9156 13.9693 12.5606C13.9992 12.1938 14 11.7166 14 11V10C14 9.28343 13.9992 8.80615 13.9693 8.43944C13.9403 8.0844 13.889 7.92194 13.8365 7.81901C13.6927 7.53677 13.4632 7.3073 13.181 7.16349C13.0781 7.11105 12.9156 7.05975 12.5606 7.03074C12.1939 7.00078 11.7166 7 11 7H5C4.28343 7 3.80615 7.00078 3.43944 7.03074C3.0844 7.05975 2.92194 7.11105 2.81901 7.16349C2.53677 7.3073 2.3073 7.53677 2.16349 7.81901C2.11105 7.92194 2.05975 8.0844 2.03074 8.43944C2.01608 8.61883 2.00841 8.82469 2.00439 9.07208V11C2.00439 11.7166 2.00517 12.1938 2.03513 12.5606C2.06414 12.9156 2.11544 13.0781 2.16788 13.181C2.31169 13.4632 2.54117 13.6927 2.82341 13.8365C2.92633 13.889 3.08879 13.9403 3.44383 13.9693ZM6.8125 10.4375C6.8125 9.78166 7.34416 9.25 8 9.25C8.65584 9.25 9.1875 9.78166 9.1875 10.4375C9.1875 11.0933 8.65584 11.625 8 11.625C7.34416 11.625 6.8125 11.0933 6.8125 10.4375ZM8 8.25C6.79188 8.25 5.8125 9.22938 5.8125 10.4375C5.8125 11.6456 6.79188 12.625 8 12.625C9.20812 12.625 10.1875 11.6456 10.1875 10.4375C10.1875 9.22938 9.20812 8.25 8 8.25Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -0,0 +1,14 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8 1C11.866 1 15 4.13401 15 8C15 11.866 11.866 15 8 15C4.13401 15 1 11.866 1 8C1 4.13401 4.13401 1 8 1ZM10.75 9.5C10.0172 9.5 9.50422 9.65253 9.12793 9.84277C8.74778 10.035 8.48265 10.2774 8.24707 10.5049C8.08807 10.6584 8.00001 10.8573 8 11.0352V14C10.4873 14 12.6207 12.4863 13.5303 10.3301C12.9601 9.92399 12.0744 9.5 10.75 9.5ZM4.75 9.5C4.01981 9.5 3.50767 9.6516 3.13184 9.84082C2.85955 9.97794 2.64585 10.1409 2.45996 10.3057C3.24038 12.1787 4.94236 13.5697 7 13.915V11.0352C7.00001 10.7133 7.099 10.4099 7.25781 10.1514C6.69171 9.81028 5.88188 9.50007 4.75 9.5ZM8 2C4.68629 2 2 4.68629 2 8C2 8.43945 2.04801 8.8677 2.1377 9.28027C2.29548 9.1649 2.47567 9.05099 2.68164 8.94727C3.2047 8.68387 3.87233 8.5 4.75 8.5C6.21316 8.50007 7.25578 8.94284 7.96582 9.41309C8.16037 9.25535 8.39427 9.09305 8.67676 8.9502C9.20055 8.68539 9.86925 8.5 10.75 8.5C12.1371 8.5 13.144 8.89812 13.8477 9.33789C13.9457 8.90751 14 8.46009 14 8C14 4.68629 11.3137 2 8 2ZM10.5 5.5C11.1875 5.5 11.75 6.0625 11.75 6.75C11.75 7.4375 11.1875 8 10.5 8C9.8125 8 9.25 7.4375 9.25 6.75C9.25 6.0625 9.8125 5.5 10.5 5.5ZM6 4.5C6.825 4.5 7.5 5.175 7.5 6C7.5 6.825 6.825 7.5 6 7.5C5.175 7.5 4.5 6.825 4.5 6C4.5 5.175 5.175 4.5 6 4.5Z"
fill="currentColor"
/>
</svg>
</template>

View File

@ -157,6 +157,7 @@ import ContactsIcon from '@/components/Icons/ContactsIcon.vue'
import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import CollapseSidebar from '@/components/Icons/CollapseSidebar.vue'
import NotificationsIcon from '@/components/Icons/NotificationsIcon.vue'
@ -233,6 +234,11 @@ const links = [
icon: TaskIcon,
to: 'Tasks',
},
{
label: 'Calendar',
icon: CalendarIcon,
to: 'Calendar',
},
{
label: 'Call Logs',
icon: PhoneIcon,

View File

@ -6,8 +6,8 @@
>
<template #body-content>
<div class="flex gap-1 border rounded mb-4 p-2 text-ink-gray-5">
<FeatherIcon name="info" class="size-3.5" />
<p class="text-sm">
<FeatherIcon name="info" class="size-3.5 mt-0.5" />
<p class="text-p-sm">
{{
__(
'Add existing system users to this CRM. Assign them a role to grant access with their current credentials.',
@ -21,13 +21,14 @@
</label>
<div class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded">
<MultiSelectUserInput
<EmailMultiSelect
v-if="users?.data?.crmUsers?.length"
class="flex-1"
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
:placeholder="__('john@doe.com')"
v-model="newUsers"
:validate="validateEmail"
:fetchUsers="true"
:existingEmails="[
...users.data.crmUsers.map((user) => user.name),
'admin@example.com',
@ -35,6 +36,7 @@
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
:emptyPlaceholder="__('No users found')"
/>
</div>
<FormControl
@ -61,7 +63,7 @@
</template>
<script setup>
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
import EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
import { validateEmail } from '@/utils'
import { usersStore } from '@/stores/users'
import { createResource, toast } from 'frappe-ui'

View File

@ -0,0 +1,473 @@
<template>
<Dialog v-model="show" :options="{ size: 'xl' }">
<template #body-header>
<div class="mb-6 flex items-center justify-between">
<div class="flex items-center space-x-2">
<h3 class="text-2xl font-semibold leading-6 text-ink-gray-9">
{{
mode === 'edit'
? __('Edit an event')
: mode === 'duplicate'
? __('Duplicate an event')
: __('Create an event')
}}
</h3>
</div>
<div class="flex gap-1">
<Button v-if="mode === 'edit'" variant="ghost" @click="deleteEvent">
<template #icon>
<LucideTrash2 class="h-4 w-4 text-ink-gray-9" />
</template>
</Button>
<Button
v-if="mode === 'edit'"
variant="ghost"
@click="duplicateEvent"
>
<template #icon>
<LucideCopy class="h-4 w-4 text-ink-gray-9" />
</template>
</Button>
<Button variant="ghost" @click="show = false">
<template #icon>
<LucideX class="h-4 w-4 text-ink-gray-9" />
</template>
</Button>
</div>
</div>
</template>
<template #body-content>
<div class="flex flex-col gap-4">
<div class="flex items-center">
<div class="text-base text-ink-gray-7 w-3/12">
{{ __('Title') }}
</div>
<div class="flex gap-1 w-9/12">
<Dropdown class="" :options="colors">
<div
class="flex items-center justify-center size-7 shrink-0 border border-outline-gray-2 bg-surface-white hover:border-outline-gray-3 hover:shadow-sm rounded cursor-pointer"
>
<div
class="size-2.5 rounded-full cursor-pointer"
:style="{
backgroundColor: _event.color || '#30A66D',
}"
/>
</div>
</Dropdown>
<TextInput
class="w-full"
ref="title"
size="sm"
v-model="_event.title"
:placeholder="__('Call with John Doe')"
variant="outline"
required
/>
</div>
</div>
<div class="flex items-center">
<div class="text-base text-ink-gray-7 w-3/12">
{{ __('All day') }}
</div>
<Switch v-model="_event.isFullDay" />
</div>
<div class="border-t border-outline-gray-1" />
<div class="flex items-center">
<div class="text-base text-ink-gray-7 w-3/12">
{{ __('Date & Time') }}
</div>
<div class="flex gap-2 w-9/12">
<DatePicker
:class="[_event.isFullDay ? 'w-full' : 'w-[158px]']"
variant="outline"
:value="_event.fromDate"
:format="'MMM D, YYYY'"
:placeholder="__('May 1, 2025')"
:clearable="false"
@update:modelValue="(date) => updateDate(date, true)"
>
<template #suffix="{ togglePopover }">
<FeatherIcon
name="chevron-down"
class="h-4 w-4 cursor-pointer"
@click="togglePopover"
/>
</template>
</DatePicker>
<TimePicker
v-if="!_event.isFullDay"
class="max-w-[112px]"
variant="outline"
:modelValue="_event.fromTime"
:placeholder="__('Start Time')"
@update:modelValue="(time) => updateTime(time, true)"
/>
<TimePicker
v-if="!_event.isFullDay"
class="max-w-[112px]"
variant="outline"
:modelValue="_event.toTime"
:options="toOptions"
:placeholder="__('End Time')"
placement="bottom-end"
@update:modelValue="(time) => updateTime(time)"
/>
</div>
</div>
<div class="flex items-center">
<div class="text-base text-ink-gray-7 w-3/12">
{{ __('Link') }}
</div>
<div class="flex gap-2 w-9/12">
<FormControl
:class="_event.referenceDoctype ? 'w-20' : 'w-full'"
type="select"
:options="linkDoctypeOptions"
v-model="_event.referenceDoctype"
variant="outline"
:placeholder="__('Add Lead or Deal')"
@change="() => (_event.referenceDocname = '')"
/>
<Link
v-if="_event.referenceDoctype"
class="w-full"
v-model="_event.referenceDocname"
:doctype="_event.referenceDoctype"
variant="outline"
:placeholder="
__('Select {0}', [
_event.referenceDoctype == 'CRM Lead'
? __('Lead')
: __('Deal'),
])
"
/>
</div>
</div>
<div class="flex items-start">
<div class="text-base text-ink-gray-7 mt-1.5 w-3/12">
{{ __('Attendees') }}
</div>
<div class="w-9/12">
<Attendee
v-model="peoples"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
</div>
</div>
<div class="flex">
<div class="mt-2 text-base text-ink-gray-7 w-3/12">
{{ __('Description') }}
</div>
<div class="w-9/12">
<TextEditor
editor-class="!prose-sm overflow-auto min-h-[80px] max-h-80 py-1.5 px-2 rounded border border-outline-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-3 hover:border-outline-gray-modals hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors"
:bubbleMenu="true"
:content="_event.description"
@change="(val) => (_event.description = val)"
:placeholder="__('Add description.')"
/>
</div>
</div>
<ErrorMessage class="mt-4" v-if="error" :message="__(error)" />
</div>
</template>
<template #actions>
<div v-if="eventsResource" class="flex gap-2 justify-end">
<Button :label="__('Cancel')" @click="show = false" />
<Button
variant="solid"
:label="
mode === 'edit'
? __('Update')
: mode === 'duplicate'
? __('Duplicate')
: __('Create')
"
:loading="
mode === 'edit'
? eventsResource.setValue.loading
: eventsResource.insert.loading
"
@click="update"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import Attendee from '@/components/Calendar/Attendee.vue'
import {
Switch,
TextEditor,
ErrorMessage,
Dialog,
DatePicker,
TimePicker,
dayjs,
Dropdown,
FormControl,
} from 'frappe-ui'
import { globalStore } from '@/stores/global'
import { validateEmail } from '@/utils'
import {
useEvent,
normalizeParticipants,
buildEndTimeOptions,
computeAutoToTime,
validateTimeRange,
} from '@/composables/event'
import { CalendarColorMap as colorMap } from 'frappe-ui'
import { onMounted, ref, computed, h } from 'vue'
const props = defineProps({
event: {
type: Object,
default: () => ({}),
},
doctype: {
type: String,
default: '',
},
docname: {
type: String,
default: '',
},
})
const { $dialog } = globalStore()
const show = defineModel()
const { eventsResource } = useEvent(props.doctype, props.docname)
const title = ref(null)
const error = ref(null)
const mode = computed(() => {
return _event.value.id == 'duplicate'
? 'duplicate'
: _event.value.id
? 'edit'
: 'create'
})
const _event = ref({
title: '',
description: '',
fromDate: '',
toDate: '',
fromTime: '',
toTime: '',
isFullDay: false,
eventType: 'Public',
color: 'green',
referenceDoctype: '',
referenceDocname: '',
event_participants: [],
})
const peoples = computed({
get() {
return _event.value.event_participants || []
},
set(list) {
_event.value.event_participants = normalizeParticipants(list)
},
})
onMounted(() => {
if (props.event) {
let start = dayjs(props.event.starts_on)
let end = dayjs(props.event.ends_on)
if (!props.event.name) {
start = dayjs()
end = dayjs().add(1, 'hour')
}
_event.value = {
id: props.event.name || '',
title: props.event.subject,
description: props.event.description,
fromDate: start.format('YYYY-MM-DD'),
toDate: end.format('YYYY-MM-DD'),
fromTime: start.format('HH:mm'),
toTime: end.format('HH:mm'),
isFullDay: props.event.all_day,
eventType: props.event.event_type,
color: props.event.color,
referenceDoctype: props.event.reference_doctype,
referenceDocname: props.event.reference_docname,
event_participants: props.event.event_participants || [],
}
setTimeout(() => title.value?.el?.focus(), 100)
}
})
function updateDate(d) {
_event.value.fromDate = d
_event.value.toDate = d
}
function updateTime(t, fromTime = false) {
error.value = null
const prevTo = _event.value.toTime
if (fromTime) {
_event.value.fromTime = t
if (!_event.value.toTime || _event.value.toTime <= t) {
_event.value.toTime = computeAutoToTime(t)
}
} else {
_event.value.toTime = t
}
const { valid, error: err } = validateTimeRange({
fromDate: _event.value.fromDate,
fromTime: _event.value.fromTime,
toTime: _event.value.toTime,
isFullDay: _event.value.isFullDay,
})
if (!valid) {
error.value = err
_event.value.toTime = prevTo
}
}
function update() {
error.value = null
if (!_event.value.title) {
error.value = __('Title is required')
title.value.el.focus()
return
}
const { valid, error: err } = validateTimeRange({
fromDate: _event.value.fromDate,
fromTime: _event.value.fromTime,
toTime: _event.value.toTime,
isFullDay: _event.value.isFullDay,
})
if (!valid) {
error.value = err
return
}
if (_event.value.id && _event.value.id !== 'duplicate') {
updateEvent()
} else {
createEvent()
}
}
function createEvent() {
eventsResource.insert.submit(
{
subject: _event.value.title,
description: _event.value.description,
starts_on: _event.value.fromDate + ' ' + _event.value.fromTime,
ends_on: _event.value.toDate + ' ' + _event.value.toTime,
all_day: _event.value.isFullDay || false,
event_type: _event.value.eventType,
color: _event.value.color,
reference_doctype: props.doctype,
reference_docname: props.docname,
reference_doctype: _event.value.referenceDoctype || props.doctype,
reference_docname: _event.value.referenceDocname || props.docname,
event_participants: _event.value.event_participants,
},
{
onSuccess: async () => {
await eventsResource.reload()
show.value = false
},
},
)
}
function updateEvent() {
if (!_event.value.id) {
error.value = __('Event ID is required')
return
}
eventsResource.setValue.submit(
{
name: _event.value.id,
subject: _event.value.title,
description: _event.value.description,
starts_on: _event.value.fromDate + ' ' + _event.value.fromTime,
ends_on: _event.value.toDate + ' ' + _event.value.toTime,
all_day: _event.value.isFullDay,
event_type: _event.value.eventType,
color: _event.value.color,
reference_doctype: props.doctype,
reference_docname: props.docname,
reference_doctype: _event.value.referenceDoctype || props.doctype,
reference_docname: _event.value.referenceDocname || props.docname,
event_participants: _event.value.event_participants,
},
{
onSuccess: async () => {
await eventsResource.reload()
show.value = false
},
},
)
}
function duplicateEvent() {
if (!_event.value.id) return
_event.value.id = 'duplicate'
_event.value.title = _event.value.title + ' (Copy)'
setTimeout(() => title.value?.el?.focus(), 100)
}
function deleteEvent() {
if (!_event.value.id) return
$dialog({
title: __('Delete'),
message: __('Are you sure you want to delete this event?'),
actions: [
{
label: __('Delete'),
variant: 'solid',
theme: 'red',
onClick: (close) => {
eventsResource.delete.submit(_event.value.id, {
onSuccess: async () => {
await eventsResource.reload()
show.value = false
close()
},
})
},
},
],
})
}
const toOptions = computed(() => buildEndTimeOptions(_event.value.fromTime))
const linkDoctypeOptions = [
{ label: '', value: '' },
{ label: __('Lead'), value: 'CRM Lead' },
{ label: __('Deal'), value: 'CRM Deal' },
]
const colors = Object.keys(colorMap).map((c) => ({
label: c.charAt(0).toUpperCase() + c.slice(1),
value: colorMap[c].color,
icon: h('div', {
class: '!size-2.5 rounded-full',
style: { backgroundColor: colorMap[c].color },
}),
onClick: () => (_event.value.color = colorMap[c].color),
}))
</script>

View File

@ -21,9 +21,11 @@
<template #body-content>
<div class="flex flex-col gap-4">
<div>
<FormControl
<div class="mb-1.5 text-xs text-ink-gray-5">
{{ __('Title') }}
</div>
<TextInput
ref="title"
:label="__('Title')"
v-model="_task.title"
:placeholder="__('Call with John Doe')"
required
@ -225,8 +227,8 @@ async function updateTask() {
function render() {
editMode.value = false
setTimeout(() => title.value?.el?.focus?.(), 100)
nextTick(() => {
title.value?.el?.focus?.()
_task.value = { ...props.task }
if (_task.value.title) {
editMode.value = true

View File

@ -29,6 +29,7 @@
</template>
<script setup>
import { DropdownOption } from '@/utils'
import { Dropdown } from 'frappe-ui'
import { computed, ref } from 'vue'
const props = defineProps({

View File

@ -31,7 +31,7 @@
<div
class="p-2 group bg-surface-gray-2 hover:bg-surface-gray-3 rounded"
>
<MultiSelectUserInput
<EmailMultiSelect
class="flex-1"
inputClass="!bg-surface-gray-2 hover:!bg-surface-gray-3 group-hover:!bg-surface-gray-3"
:placeholder="__('john@doe.com')"
@ -40,7 +40,7 @@
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
:fetchUsers="false"
:emptyPlaceholder="__('Type an email address to invite')"
/>
</div>
<div
@ -100,7 +100,7 @@
</div>
</template>
<script setup>
import MultiSelectUserInput from '@/components/Controls/MultiSelectUserInput.vue'
import EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
import { validateEmail, convertArrayToString } from '@/utils'
import { usersStore } from '@/stores/users'
import {

View File

@ -170,7 +170,15 @@ import AddExistingUserModal from '@/components/Modals/AddExistingUserModal.vue'
import { activeSettingsPage } from '@/composables/settings'
import { usersStore } from '@/stores/users'
import { TemplateOption, DropdownOption } from '@/utils'
import { Avatar, TextInput, toast, call, FeatherIcon, Tooltip } from 'frappe-ui'
import {
Dropdown,
Avatar,
TextInput,
toast,
call,
FeatherIcon,
Tooltip,
} from 'frappe-ui'
import { ref, computed, onMounted } from 'vue'
const { users, isAdmin, isManager } = usersStore()

View File

@ -0,0 +1,104 @@
<template>
<Tooltip v-if="!disabled">
<template #body>
<div
class="rounded bg-surface-gray-7 py-1.5 px-2 text-xs text-ink-white shadow-xl"
>
<span class="flex items-center gap-1">
<span>{{ label }}</span>
<!-- Primary combos (one or many) -->
<template
v-for="(combo, idx) in primaryCombosDisplay"
:key="'prim-' + idx + combo"
>
<KeyboardShortcut
bg
class="!bg-surface-gray-5 !text-ink-gray-2 px-1"
:combo="combo"
/>
</template>
<!-- Alternate combos / equivalents -->
<template
v-for="(alt, idx) in altCombosDisplay"
:key="'alt-' + idx + alt"
>
<KeyboardShortcut
bg
class="!bg-surface-gray-5 !text-ink-gray-2 px-1"
:combo="alt"
/>
</template>
</span>
</div>
</template>
<slot />
</Tooltip>
<slot v-else />
</template>
<script setup lang="ts">
import { Tooltip, KeyboardShortcut } from 'frappe-ui'
import { computed } from 'vue'
interface Props {
label: string
combo?: string | string[]
altCombos?: string[]
disabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
combo: '',
altCombos: () => [],
disabled: false,
})
const isMac = computed(() => {
if (typeof navigator === 'undefined') return false
const platform =
(navigator as any).userAgentData?.platform || navigator.platform || ''
if (/Mac|iPod|iPhone|iPad/i.test(platform)) return true
return /Mac OS X|Macintosh|iPhone|iPad|iPod/i.test(navigator.userAgent || '')
})
function normalizeCombo(raw: string): string {
if (!raw) return ''
if (/^mod\+/i.test(raw)) {
const rest = raw.split('+').slice(1).join('+')
return (isMac.value ? 'Cmd' : 'Ctrl') + '+' + rest
}
return raw
}
function normalizeList(list: string | string[]): string[] {
const arr = Array.isArray(list) ? list : list ? [list] : []
return arr.map(normalizeCombo)
}
// Dedupe Backspace/Delete (prefer Backspace) on macOS
function dedupeMacDeleteVariants(primary: string[], alts: string[]) {
if (!isMac.value) return { primary, alts }
const all = [...primary, ...alts]
if (all.includes('Delete') && all.includes('Backspace')) {
return {
primary: primary.filter((k) => k !== 'Delete'),
alts: alts.filter((k) => k !== 'Delete'),
}
}
return { primary, alts }
}
// Base normalized lists
const normalizedPrimary = computed(() => normalizeList(props.combo))
const normalizedAlt = computed(() => props.altCombos.map(normalizeCombo))
// Apply dedupe once to both arrays to avoid circular dependency
const deduped = computed(() =>
dedupeMacDeleteVariants(normalizedPrimary.value, normalizedAlt.value),
)
const primaryCombosDisplay = computed(() => deduped.value.primary)
const altCombosDisplay = computed(() => deduped.value.alts)
defineOptions({ name: 'ShortcutTooltip' })
</script>

View File

@ -229,19 +229,26 @@
@change="(v) => fieldChange(v, field)"
:onCreate="field.create"
/>
<div
v-else-if="field.fieldtype === 'Time'"
class="form-control"
>
<TimePicker
:value="doc[field.fieldname]"
:format="getFormat('', '', false, true, false)"
:placeholder="field.placeholder"
@change="(v) => fieldChange(v, field)"
/>
</div>
<div
v-else-if="field.fieldtype === 'Datetime'"
class="form-control"
>
<DateTimePicker
icon-left=""
:value="doc[field.fieldname]"
:formatter="
(date) => getFormat(date, '', true, true)
"
:format="getFormat('', '', true, true, false)"
:placeholder="field.placeholder"
placement="left-start"
:hideIcon="true"
@change="(v) => fieldChange(v, field)"
/>
</div>
@ -250,12 +257,10 @@
class="form-control"
>
<DatePicker
icon-left=""
:value="doc[field.fieldname]"
:formatter="(date) => getFormat(date, '', true)"
:format="getFormat('', '', true, false, false)"
:placeholder="field.placeholder"
placement="left-start"
:hideIcon="true"
@change="(v) => fieldChange(v, field)"
/>
</div>
@ -378,7 +383,13 @@ import { usersStore } from '@/stores/users'
import { isMobileView } from '@/composables/settings'
import { getFormat, evaluateDependsOnValue } from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { Tooltip, DateTimePicker, DatePicker, Popover } from 'frappe-ui'
import {
Tooltip,
DateTimePicker,
DatePicker,
TimePicker,
Popover,
} from 'frappe-ui'
import { useDocument } from '@/data/document'
import { ref, computed, getCurrentInstance } from 'vue'

View File

@ -58,8 +58,7 @@ import { getSettings } from '@/stores/settings'
import { showSettings, isMobileView } from '@/composables/settings'
import { showAboutModal } from '@/composables/modals'
import { confirmLoginToFrappeCloud } from '@/composables/frappecloud'
import { Dropdown } from 'frappe-ui'
import { theme, toggleTheme } from '@/stores/theme'
import { Dropdown, useTheme } from 'frappe-ui'
import { computed, h, markRaw } from 'vue'
const props = defineProps({
@ -72,6 +71,7 @@ const props = defineProps({
const { settings, brand } = getSettings()
const { logout } = sessionStore()
const { getUser } = usersStore()
const { currentTheme, toggleTheme } = useTheme()
const user = computed(() => getUser() || {})
@ -134,7 +134,7 @@ function getStandardItem(item) {
}
case 'toggle_theme':
return {
icon: theme.value === 'dark' ? 'sun' : item.icon,
icon: currentTheme.value === 'dark' ? 'sun' : item.icon,
label: __(item.label),
onClick: toggleTheme,
}

View File

@ -60,13 +60,14 @@
class="flex flex-row-reverse gap-2 items-center min-w-11"
>
<Dropdown
placement="right-start"
side="right"
:offset="15"
:options="viewControls.viewActions(item, close)"
>
<template #default>
<Button
variant="ghost"
class="!size-5 hidden group-hover:block"
class="!size-5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity"
icon="more-horizontal"
@click.stop
/>

View File

@ -157,7 +157,7 @@ const props = defineProps({
},
size: {
type: String,
default: 'md',
default: 'sm',
},
variant: {
type: String,
@ -282,7 +282,7 @@ const inputClasses = computed(() => {
let variant = props.disabled ? 'disabled' : props.variant
let variantClasses = {
subtle:
'border border-gray-100 bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
'border border-[--surface-gray-2] bg-surface-gray-2 placeholder-ink-gray-4 hover:border-outline-gray-modals hover:bg-surface-gray-3 focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
outline:
'border border-outline-gray-2 bg-surface-white placeholder-ink-gray-4 hover:border-outline-gray-3 hover:shadow-sm focus:bg-surface-white focus:border-outline-gray-4 focus:shadow-sm focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3',
disabled: [

View File

@ -1,166 +0,0 @@
<template>
<Menu as="div" class="relative inline-block text-left" v-slot="{ open }">
<Popover
:transition="dropdownTransition"
:show="open"
:placement="popoverPlacement"
>
<template #target="{ togglePopover }">
<MenuButton as="template">
<slot v-if="$slots.default" v-bind="{ open, togglePopover }" />
<Button v-else :active="open" v-bind="button">
{{ button ? button?.label || null : 'Options' }}
</Button>
</MenuButton>
</template>
<template #body>
<div
class="mt-2 min-w-40 divide-y divide-outline-gray-modals rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
:class="{
'mt-2': ['bottom', 'left', 'right'].includes(placement),
'ml-2': placement == 'right-start',
}"
>
<MenuItems
class="min-w-40 divide-y divide-outline-gray-modals"
:class="{
'left-0 origin-top-left': placement == 'left',
'right-0 origin-top-right': placement == 'right',
'inset-x-0 origin-top': placement == 'center',
'mt-0 origin-top-right': placement == 'right-start',
}"
>
<div v-for="group in groups" :key="group.key" class="p-1.5">
<div
v-if="group.group && !group.hideLabel"
class="flex h-7 items-center px-2 text-sm font-medium text-ink-gray-4"
>
{{ group.group }}
</div>
<MenuItem
v-for="item in group.items"
:key="item.label"
v-slot="{ active }"
>
<slot name="item" v-bind="{ item, active }">
<component
v-if="item.component"
:is="item.component"
:active="active"
/>
<button
v-else
:class="[
active ? 'bg-surface-gray-3' : 'text-ink-gray-6',
'group flex h-7 w-full items-center rounded px-2 text-base',
]"
@click="item.onClick"
>
<FeatherIcon
v-if="item.icon && typeof item.icon === 'string'"
:name="item.icon"
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
aria-hidden="true"
/>
<component
class="mr-2 h-4 w-4 flex-shrink-0 text-ink-gray-7"
v-else-if="item.icon"
:is="item.icon"
/>
<span class="whitespace-nowrap text-ink-gray-7">
{{ item.label }}
</span>
</button>
</slot>
</MenuItem>
</div>
</MenuItems>
<div v-if="slots.footer" class="border-t p-1.5">
<slot name="footer"></slot>
</div>
</div>
</template>
</Popover>
</Menu>
</template>
<script setup>
import { Menu, MenuButton, MenuItems, MenuItem } from '@headlessui/vue'
import { Popover, Button, FeatherIcon } from 'frappe-ui'
import { computed, useSlots } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
button: {
type: Object,
default: null,
},
options: {
type: Array,
default: () => [],
},
placement: {
type: String,
default: 'left',
},
})
const router = useRouter()
const slots = useSlots()
const dropdownTransition = {
enterActiveClass: 'transition duration-100 ease-out',
enterFromClass: 'transform scale-95 opacity-0',
enterToClass: 'transform scale-100 opacity-100',
leaveActiveClass: 'transition duration-75 ease-in',
leaveFromClass: 'transform scale-100 opacity-100',
leaveToClass: 'transform scale-95 opacity-0',
}
const groups = computed(() => {
let groups = props.options[0]?.group
? props.options
: [{ group: '', items: props.options }]
return groups.map((group, i) => {
return {
key: i,
group: group.group,
hideLabel: group.hideLabel || false,
items: filterOptions(group.items),
}
})
})
const popoverPlacement = computed(() => {
if (props.placement === 'left') return 'bottom-start'
if (props.placement === 'right') return 'bottom-end'
if (props.placement === 'center') return 'bottom-center'
if (props.placement === 'right-start') return 'right-start'
return 'bottom'
})
function normalizeDropdownItem(option) {
let onClick = option.onClick || null
if (!onClick && option.route && router) {
onClick = () => router.push(option.route)
}
return {
name: option.name,
label: option.label,
icon: option.icon,
group: option.group,
component: option.component,
onClick,
}
}
function filterOptions(options) {
return (options || [])
.filter(Boolean)
.filter((option) => (option.condition ? option.condition() : true))
.map((option) => normalizeDropdownItem(option))
}
</script>

View File

@ -1,277 +0,0 @@
<template>
<div ref="reference">
<div
ref="target"
:class="['flex', $attrs.class]"
@click="updatePosition"
@focusin="updatePosition"
@keydown="updatePosition"
@mouseover="onMouseover"
@mouseleave="onMouseleave"
>
<slot
name="target"
v-bind="{ togglePopover, updatePosition, open, close, isOpen }"
/>
</div>
<teleport to="#frappeui-popper-root">
<div
ref="popover"
class="relative z-[100]"
:class="[popoverContainerClass, popoverClass]"
:style="{ minWidth: targetWidth ? targetWidth + 'px' : null }"
@mouseover="pointerOverTargetOrPopup = true"
@mouseleave="onMouseleave"
>
<transition v-bind="popupTransition">
<div v-show="isOpen">
<slot
name="body"
v-bind="{ togglePopover, updatePosition, open, close, isOpen }"
>
<div class="rounded-lg border border-gray-100 bg-surface-white shadow-xl">
<slot
name="body-main"
v-bind="{
togglePopover,
updatePosition,
open,
close,
isOpen,
}"
/>
</div>
</slot>
</div>
</transition>
</div>
</teleport>
</div>
</template>
<script>
import { createPopper } from '@popperjs/core'
export default {
name: 'Popover',
inheritAttrs: false,
props: {
show: {
default: undefined,
},
trigger: {
type: String,
default: 'click', // click, hover
},
hoverDelay: {
type: Number,
default: 0,
},
leaveDelay: {
type: Number,
default: 0,
},
placement: {
type: String,
default: 'bottom-start',
},
popoverClass: [String, Object, Array],
transition: {
default: null,
},
hideOnBlur: {
default: true,
},
},
emits: ['open', 'close', 'update:show'],
expose: ['open', 'close'],
data() {
return {
popoverContainerClass: 'body-container',
showPopup: false,
targetWidth: null,
pointerOverTargetOrPopup: false,
}
},
watch: {
show(val) {
if (val) {
this.open()
} else {
this.close()
}
},
},
created() {
if (typeof window === 'undefined') return
if (!document.getElementById('frappeui-popper-root')) {
const root = document.createElement('div')
root.id = 'frappeui-popper-root'
document.body.appendChild(root)
}
},
mounted() {
this.listener = (e) => {
const clickedElement = e.target
const reference = this.$refs.reference
const popoverBody = this.$refs.popover
const insideClick =
clickedElement === reference ||
clickedElement === popoverBody ||
reference?.contains(clickedElement) ||
popoverBody?.contains(clickedElement)
if (insideClick) {
return
}
const root = document.getElementById('frappeui-popper-root')
const insidePopoverRoot = root.contains(clickedElement)
if (!insidePopoverRoot) {
return this.close()
}
const bodyClass = `.${this.popoverContainerClass}`
const clickedElementBody = clickedElement?.closest(bodyClass)
const currentPopoverBody = reference?.closest(bodyClass)
const isSiblingClicked =
clickedElementBody &&
currentPopoverBody &&
clickedElementBody === currentPopoverBody
if (isSiblingClicked) {
this.close()
}
}
if (this.hideOnBlur) {
document.addEventListener('click', this.listener)
document.addEventListener('mousedown', this.listener)
}
this.$nextTick(() => {
this.targetWidth = this.$refs['target'].clientWidth
})
},
beforeDestroy() {
this.popper && this.popper.destroy()
document.removeEventListener('click', this.listener)
document.removeEventListener('mousedown', this.listener)
},
computed: {
showPropPassed() {
return this.show != null
},
isOpen: {
get() {
if (this.showPropPassed) {
return this.show
}
return this.showPopup
},
set(val) {
val = Boolean(val)
if (this.showPropPassed) {
this.$emit('update:show', val)
} else {
this.showPopup = val
}
if (val === false) {
this.$emit('close')
} else if (val === true) {
this.$emit('open')
}
},
},
popupTransition() {
let templates = {
default: {
enterActiveClass: 'transition duration-150 ease-out',
enterFromClass: 'translate-y-1 opacity-0',
enterToClass: 'translate-y-0 opacity-100',
leaveActiveClass: 'transition duration-150 ease-in',
leaveFromClass: 'translate-y-0 opacity-100',
leaveToClass: 'translate-y-1 opacity-0',
},
}
if (typeof this.transition === 'string') {
return templates[this.transition]
}
return this.transition
},
},
methods: {
setupPopper() {
if (!this.popper) {
this.popper = createPopper(this.$refs.reference, this.$refs.popover, {
placement: this.placement,
})
} else {
this.updatePosition()
}
},
updatePosition() {
this.popper && this.popper.update()
},
togglePopover(flag) {
if (flag instanceof Event) {
flag = null
}
if (flag == null) {
flag = !this.isOpen
}
flag = Boolean(flag)
if (flag) {
this.open()
} else {
this.close()
}
},
open() {
this.isOpen = true
this.$nextTick(() => this.setupPopper())
},
close() {
this.isOpen = false
},
onMouseover() {
this.pointerOverTargetOrPopup = true
if (this.leaveTimer) {
clearTimeout(this.leaveTimer)
this.leaveTimer = null
}
if (this.trigger === 'hover') {
if (this.hoverDelay) {
this.hoverTimer = setTimeout(() => {
if (this.pointerOverTargetOrPopup) {
this.open()
}
}, Number(this.hoverDelay) * 1000)
} else {
this.open()
}
}
},
onMouseleave(e) {
this.pointerOverTargetOrPopup = false
if (this.hoverTimer) {
clearTimeout(this.hoverTimer)
this.hoverTimer = null
}
if (this.trigger === 'hover') {
if (this.leaveTimer) {
clearTimeout(this.leaveTimer)
}
if (this.leaveDelay) {
this.leaveTimer = setTimeout(() => {
if (!this.pointerOverTargetOrPopup) {
this.close()
}
}, Number(this.leaveDelay) * 1000)
} else {
if (!this.pointerOverTargetOrPopup) {
this.close()
}
}
}
},
},
}
</script>

View File

@ -0,0 +1,237 @@
import { usersStore } from '@/stores/users'
import { dayjs, createListResource } from 'frappe-ui'
import { sameArrayContents } from '@/utils'
import { computed, ref } from 'vue'
import { allTimeSlots } from '@/components/Calendar/utils'
export const showEventModal = ref(false)
export const activeEvent = ref(null)
export function useEvent(doctype, docname) {
const { getUser } = usersStore()
const eventsResource = createListResource({
doctype: 'Event',
cache: ['calendar', docname],
fields: [
'name',
'status',
'subject',
'description',
'starts_on',
'ends_on',
'all_day',
'event_type',
'color',
'owner',
'reference_doctype',
'reference_docname',
'creation',
],
filters: {
reference_doctype: doctype,
reference_docname: docname,
},
auto: true,
orderBy: 'creation desc',
onSuccess: (d) => {
console.log(d)
},
})
const eventParticipantsResource = createListResource({
doctype: 'Event Participants',
fields: ['*'],
parent: 'Event',
})
const events = computed(() => {
if (!eventsResource.data) return []
const eventNames = eventsResource.data.map((e) => e.name)
if (
!eventParticipantsResource.data?.length ||
eventsParticipantIsUpdated(eventNames)
) {
eventParticipantsResource.update({
filters: {
parenttype: 'Event',
parentfield: 'event_participants',
parent: ['in', eventNames],
},
})
!eventParticipantsResource.list.loading &&
eventParticipantsResource.reload()
} else {
eventsResource.data.forEach((event) => {
if (typeof event.owner !== 'object') {
event.owner = {
label: getUser(event.owner).full_name,
image: getUser(event.owner).user_image,
name: event.owner,
}
}
event.event_participants = [
...eventParticipantsResource.data.filter(
(participant) => participant.parent === event.name,
),
]
event.participants = [
event.owner,
...eventParticipantsResource.data
.filter((participant) => participant.parent === event.name)
.map((participant) => ({
label: getUser(participant.email).full_name || participant.email,
image: getUser(participant.email).user_image || '',
name: participant.email,
})),
]
})
}
return eventsResource.data
})
function eventsParticipantIsUpdated(eventNames) {
const parentFilter = eventParticipantsResource.filters?.parent?.[1]
if (eventNames.length && !sameArrayContents(parentFilter, eventNames))
return true
let d = eventsResource.setValue.data
if (!d) return false
let newParticipants = d.event_participants.map((p) => p.name)
let oldParticipants = eventParticipantsResource.data
.filter((p) => p.parent === d.name)
.map((p) => p.name)
return !sameArrayContents(newParticipants, oldParticipants)
}
const startEndTime = (
startTime,
endTime,
isFullDay = false,
format = 'h:mm a',
) => {
const start = dayjs(startTime)
const end = dayjs(endTime)
if (isFullDay) return __('All day')
return `${start.format(format)} - ${end.format(format)}`
}
const startDate = (startTime, format = 'ddd, D MMM YYYY') => {
const start = dayjs(startTime)
return start.format(format)
}
return {
eventsResource,
eventParticipantsResource,
events,
startEndTime,
startDate,
}
}
export function normalizeParticipants(list = []) {
const seen = new Set()
const out = []
for (const a of list || []) {
if (!a?.email || seen.has(a.email)) continue
seen.add(a.email)
out.push({
email: a.email,
reference_doctype: a.reference_doctype || 'Contact',
reference_docname: a.reference_docname || '',
})
}
return out
}
export function formatDuration(mins) {
if (mins < 60) return __('{0} mins', [mins])
let hours = mins / 60
if (hours % 1 !== 0 && hours % 1 !== 0.5) {
hours = hours.toFixed(2)
}
if (Number.isInteger(hours)) {
return hours === 1 ? __('1 hr') : __('{0} hrs', [hours])
}
return `${hours} hrs`
}
export function buildEndTimeOptions(fromTime) {
const timeSlots = allTimeSlots()
if (!fromTime) return timeSlots
const startIndex = timeSlots.findIndex((o) => o.value > fromTime)
if (startIndex === -1) return []
const [fh, fm] = fromTime.split(':').map((n) => parseInt(n))
const fromTotal = fh * 60 + fm
return timeSlots.slice(startIndex).map((o) => {
const [th, tm] = o.value.split(':').map((n) => parseInt(n))
const toTotal = th * 60 + tm
const duration = toTotal - fromTotal
return { ...o, label: `${o.label} (${formatDuration(duration)})` }
})
}
export function computeAutoToTime(fromTime) {
if (!fromTime) return ''
const [hour, minute] = fromTime.split(':').map((n) => parseInt(n))
let nh = hour + 1
let nm = minute
if (nh >= 24) {
nh = 23
nm = 59
}
return `${String(nh).padStart(2, '0')}:${String(nm).padStart(2, '0')}`
}
export function validateTimeRange({ fromDate, fromTime, toTime, isFullDay }) {
if (isFullDay) return { valid: true, error: null }
if (!fromTime || !toTime) {
return { valid: false, error: __('Start and end time are required') }
}
const start = dayjs(fromDate + ' ' + fromTime)
const end = dayjs(fromDate + ' ' + toTime)
if (!start.isValid() || !end.isValid()) {
return { valid: false, error: __('Invalid start or end time') }
}
if (end.diff(start, 'minute') <= 0) {
return { valid: false, error: __('End time should be after start time') }
}
return { valid: true, error: null }
}
export function parseEventDoc(doc) {
if (!doc) return {}
const { getUser } = usersStore()
return {
id: doc.name,
title: doc.subject,
description: doc.description,
status: doc.status,
fromDate: dayjs(doc.starts_on).format('YYYY-MM-DD'),
toDate: dayjs(doc.ends_on).format('YYYY-MM-DD'),
fromTime: dayjs(doc.starts_on).format('HH:mm'),
toTime: dayjs(doc.ends_on).format('HH:mm'),
isFullDay: doc.all_day,
eventType: doc.event_type,
color: doc.color,
referenceDoctype: doc.reference_doctype,
referenceDocname: doc.reference_docname,
event_participants: doc.event_participants || [],
owner: doc.owner
? {
label: getUser(doc.owner).full_name,
image: getUser(doc.owner).user_image,
value: doc.owner,
}
: null,
}
}

View File

@ -0,0 +1,75 @@
import { onMounted, onBeforeUnmount, unref } from 'vue'
/**
* Generic global keyboard shortcuts composable.
*
* Usage:
* useKeyboardShortcuts({
* active: () => true, // boolean | () => boolean (reactive allowed)
* shortcuts: [
* { keys: 'Escape', action: close },
* { keys: ['Delete', 'Backspace'], action: onDelete },
* { match: e => (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'd', action: duplicate }
* ],
* ignoreTyping: true // skip when focus is in input/textarea/contenteditable (default true)
* })
*/
export function useKeyboardShortcuts(options) {
const {
active = true,
shortcuts = [],
ignoreTyping = true,
target = typeof window !== 'undefined' ? window : null,
} = options || {}
function isTypingEvent(e) {
if (!ignoreTyping) return false
const el = e.target
if (!el) return false
const tag = el.tagName
return (
el.isContentEditable ||
tag === 'INPUT' ||
tag === 'TEXTAREA' ||
tag === 'SELECT' ||
(el.closest && el.closest('[contenteditable="true"]'))
)
}
function matchShortcut(def, e) {
if (def.match) return def.match(e)
let keys = def.keys
if (!keys) return false
if (!Array.isArray(keys)) keys = [keys]
return keys.some((k) => k === e.key)
}
function handler(e) {
if (!target) return
const isActive = typeof active === 'function' ? active() : unref(active)
if (!isActive) return
if (isTypingEvent(e)) return
for (const def of shortcuts) {
if (!def) continue
if (def.guard && !def.guard(e)) continue
if (matchShortcut(def, e)) {
if (def.preventDefault !== false) e.preventDefault()
if (def.stopPropagation) e.stopPropagation()
def.action && def.action(e)
break
}
}
}
onMounted(() => {
target && target.addEventListener('keydown', handler)
})
onBeforeUnmount(() => {
target && target.removeEventListener('keydown', handler)
})
return {
stop: () => target && target.removeEventListener('keydown', handler),
}
}

View File

@ -0,0 +1,476 @@
<template>
<LayoutHeader>
<template #left-header>
<ViewBreadcrumbs routeName="Calendar" />
</template>
<template #right-header>
<ShortcutTooltip :label="__('Create event')" combo="Mod+E">
<Button
variant="solid"
:label="__('Create')"
:disabled="isCreateDisabled"
@click="newEvent"
>
<template #prefix><FeatherIcon name="plus" class="h-4" /></template>
</Button>
</ShortcutTooltip>
</template>
</LayoutHeader>
<div class="flex h-screen overflow-hidden">
<Calendar
v-if="events.data?.length"
class="flex-1 overflow-hidden"
ref="calendar"
:config="{
defaultMode: 'Week',
isEditMode: true,
eventIcons: {},
allowCustomClickEvents: true,
enableShortcuts: false,
noBorder: true,
}"
:events="events.data"
@create="(event) => createEvent(event)"
@update="(event) => updateEvent(event, true)"
@delete="(eventID) => deleteEvent(eventID)"
:onClick="showDetails"
:onDblClick="editDetails"
:onCellClick="newEvent"
>
<template
#header="{
currentMonthYear,
enabledModes,
activeView,
selectedMonthDate,
decrement,
increment,
updateActiveView,
onMonthYearChange,
setCalendarDate,
}"
>
<div class="my-4 mx-5 flex justify-between">
<!-- left side -->
<!-- Month Year -->
<div class="flex items-center">
<DatePicker
:modelValue="selectedMonthDate"
@update:modelValue="(val) => onMonthYearChange(val)"
:clearable="false"
>
<template #target="{ togglePopover }">
<Button
variant="ghost"
class="text-lg font-medium text-ink-gray-7"
:label="currentMonthYear"
iconRight="chevron-down"
@click="togglePopover"
/>
</template>
</DatePicker>
</div>
<!-- right side -->
<!-- actions buttons for calendar -->
<div class="flex gap-x-1">
<!-- Increment and Decrement Button -->
<Button @click="decrement" variant="ghost" icon="chevron-left" />
<Button
:label="__('Today')"
variant="ghost"
@click="setCalendarDate()"
/>
<Button @click="increment" variant="ghost" icon="chevron-right" />
<!-- View change button, default is months or can be set via props! -->
<TabButtons
:buttons="enabledModes"
class="ml-2"
:modelValue="activeView"
@update:modelValue="updateActiveView($event)"
/>
</div>
</div>
</template>
<template #daily-header="{ parseDateWithDay, currentDate }">
<p class="ml-4 pb-2 text-base text-ink-gray-6">
{{ parseDateWithDay(currentDate) }}
</p>
</template>
</Calendar>
<!-- Event Panel Container -->
<div
class="overflow-hidden flex-none transition-all duration-300 ease-in-out flex flex-col"
:class="
showEventPanel
? 'w-[352px] border-l bg-surface-white'
: 'w-0 border-l-0'
"
>
<CalendarEventPanel
ref="eventPanel"
v-if="showEventPanel"
v-model="showEventPanel"
v-model:event="event"
:mode="mode"
@new="newEvent"
@save="saveEvent"
@edit="editDetails"
@delete="deleteEvent"
@duplicate="duplicateEvent"
@details="showDetails"
@close="close"
@sync="syncEvent"
/>
</div>
</div>
</template>
<script setup>
import CalendarEventPanel from '@/components/Calendar/CalendarEventPanel.vue'
import ViewBreadcrumbs from '@/components/ViewBreadcrumbs.vue'
import LayoutHeader from '@/components/LayoutHeader.vue'
import ShortcutTooltip from '@/components/ShortcutTooltip.vue'
import { sessionStore } from '@/stores/session'
import { globalStore } from '@/stores/global'
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import {
Calendar,
createListResource,
TabButtons,
dayjs,
DatePicker,
CalendarActiveEvent as activeEvent,
call,
} from 'frappe-ui'
import { onMounted, ref, computed } from 'vue'
const { user } = sessionStore()
const { $dialog } = globalStore()
const calendar = ref(null)
const events = createListResource({
doctype: 'Event',
cache: ['calendar', user],
fields: [
'name',
'status',
'subject',
'description',
'starts_on',
'ends_on',
'all_day',
'event_type',
'color',
'reference_doctype',
'reference_docname',
],
filters: { status: 'Open', owner: user },
pageLength: 9999,
auto: true,
transform: (data) =>
data.map((ev) => ({
id: ev.name,
title: ev.subject,
description: ev.description,
status: ev.status,
fromDate: dayjs(ev.starts_on).format('YYYY-MM-DD'),
toDate: dayjs(ev.ends_on).format('YYYY-MM-DD'),
fromTime: dayjs(ev.starts_on).format('HH:mm'),
toTime: dayjs(ev.ends_on).format('HH:mm'),
isFullDay: ev.all_day,
eventType: ev.event_type,
color: ev.color,
referenceDoctype: ev.reference_doctype,
referenceDocname: ev.reference_docname,
})),
})
const eventPanel = ref(null)
const showEventPanel = ref(false)
const event = ref({})
const mode = ref('')
const isCreateDisabled = computed(() =>
['edit', 'new', 'duplicate'].includes(mode.value),
)
// Temp event helpers
const TEMP_EVENT_IDS = new Set(['new-event', 'duplicate-event'])
const isTempEvent = (id) => TEMP_EVENT_IDS.has(id)
function removeTempEvents() {
if (!Array.isArray(events.data)) return
events.data = events.data.filter((ev) => !isTempEvent(ev.id))
}
function openEvent(e, nextMode, reloadEvent = false) {
const _e = e?.calendarEvent || e
if (!_e?.id || isTempEvent(_e.id)) return
removeTempEvents()
showEventPanel.value = true
event.value = { id: _e.id, reloadEvent }
activeEvent.value = _e.id
mode.value = nextMode
}
function saveEvent(_event) {
if (!_event?.id || isTempEvent(_event.id)) return createEvent(_event)
updateEvent(_event)
}
function buildEventPayload(_event) {
return {
subject: _event.title,
description: _event.description,
starts_on: `${_event.fromDate} ${_event.fromTime}`,
ends_on: `${_event.toDate} ${_event.toTime}`,
all_day: _event.isFullDay || false,
event_type: _event.eventType,
color: _event.color,
reference_doctype: _event.referenceDoctype,
reference_docname: _event.referenceDocname,
event_participants: _event.event_participants,
}
}
function createEvent(_event) {
if (!_event?.title) return
events.insert.submit(buildEventPayload(_event), {
onSuccess: async (e) => {
await events.reload()
showDetails({ id: e.name })
},
})
}
async function updateEvent(_event, afterDrag = false) {
if (!_event.id) return
if (
['duplicate', 'new'].includes(mode.value) &&
!['duplicate-event', 'new-event'].includes(_event.id) &&
afterDrag
) {
event.value = { id: _event.id }
activeEvent.value = _event.id
mode.value = 'details'
}
if (mode.value == 'edit' && afterDrag) {
eventPanel.value.updateEvent({
fromDate: _event.fromDate,
toDate: _event.toDate,
fromTime: _event.fromTime,
toTime: _event.toTime,
})
return
}
if (!mode.value || mode.value == 'edit' || mode.value === 'details') {
// Ensure Contacts exist for participants referencing a new/unknown Contact, if not create them
if (
Array.isArray(_event.event_participants) &&
_event.event_participants.length
) {
_event.event_participants = await ensureParticipantContacts(
_event.event_participants,
)
}
events.setValue.submit(
{ name: _event.id, ...buildEventPayload(_event) },
{
onSuccess: async (e) => {
await events.reload()
showEventPanel.value && showDetails({ id: e.name }, true)
},
},
)
} else {
event.value = { ..._event }
}
}
function deleteEvent(eventID) {
if (!eventID) return
$dialog({
title: __('Delete'),
message: __('Are you sure you want to delete this event?'),
actions: [
{
label: __('Delete'),
variant: 'solid',
theme: 'red',
onClick: (close) => {
events.delete.submit(eventID, {
onSuccess: () => events.reload(),
})
showEventPanel.value = false
event.value = {}
activeEvent.value = ''
mode.value = ''
close()
},
},
],
})
}
function syncEvent(eventID, _event) {
if (!eventID) return
Object.assign(events.data.filter((event) => event.id === eventID)[0], _event)
}
onMounted(() => {
activeEvent.value = ''
mode.value = ''
showEventPanel.value = false
})
// Global shortcut: Cmd/Ctrl + E -> new event (when not already creating/editing)
useKeyboardShortcuts({
shortcuts: [
{
match: (e) =>
(e.metaKey || e.ctrlKey) &&
!e.shiftKey &&
!e.altKey &&
e.key.toLowerCase() === 'e',
guard: () => !isCreateDisabled.value,
action: () =>
newEvent({
date: dayjs().format('YYYY-MM-DD'),
time: dayjs().format('HH:mm'),
isFullDay: false,
}),
},
],
})
function showDetails(e, reloadEvent = false) {
openEvent(e, 'details', reloadEvent)
}
function editDetails(e) {
openEvent(e, 'edit')
}
function buildTempEvent(e, duplicate) {
const id = duplicate ? 'duplicate-event' : 'new-event'
return {
id,
title: e.title,
description: e.description || '',
date: e.fromDate,
fromDate: e.fromDate,
toDate: e.toDate,
fromTime: e.fromTime,
toTime: e.toTime,
isFullDay: e.isFullDay || false,
eventType: e.eventType || 'Public',
color: e.color || 'green',
referenceDoctype: e.referenceDoctype,
referenceDocname: e.referenceDocname,
event_participants: e.event_participants || [],
}
}
function newEvent(e = {}, duplicate = false) {
removeTempEvents()
let base = { ...e }
if (!duplicate) {
const [fromTime, toTime] = getFromToTime(e.time)
const fromDate = dayjs(e.date).format('YYYY-MM-DD')
base = {
...base,
fromDate,
toDate: fromDate,
fromTime,
toTime,
isFullDay: e.isFullDay,
}
}
event.value = buildTempEvent(base, duplicate)
events.data.push(event.value)
showEventPanel.value = true
activeEvent.value = event.value.id
mode.value = duplicate ? 'duplicate' : 'new'
}
function duplicateEvent(e) {
newEvent(e, true)
}
function close() {
showEventPanel.value = false
event.value = {}
activeEvent.value = ''
mode.value = ''
removeTempEvents()
}
// utils
function getFromToTime(time) {
const pad = (v) => String(v).padStart(2, '0')
let now = dayjs()
let h = now.hour()
let m = Math.floor(now.minute() / 15) * 15
let fromHour = h
let fromMinute = m
if (time) {
if (/am|pm/i.test(time)) {
const raw = time.trim().replace(' ', '')
const ampm = raw.slice(-2).toLowerCase()
let hour = parseInt(raw.slice(0, -2))
if (ampm === 'pm' && hour < 12) hour += 12
if (ampm === 'am' && hour === 12) hour = 0
fromHour = hour
fromMinute = 0
} else if (/^\d{1,2}:?\d{0,2}$/.test(time)) {
const [hh, mm = '00'] = time.split(':')
fromHour = parseInt(hh)
fromMinute = parseInt(mm) || 0
}
}
const toHour = (fromHour + 1) % 24
return [
`${pad(fromHour)}:${pad(fromMinute)}`,
`${pad(toHour)}:${pad(fromMinute)}`,
]
}
async function ensureParticipantContacts(participants) {
if (!Array.isArray(participants) || !participants.length) return participants
const updated = []
for (const part of participants) {
const p = { ...part }
try {
if (
p.reference_doctype === 'Contact' &&
(!p.reference_docname || p.reference_docname === 'new') &&
p.email
) {
const firstName = p.email.split('@')[0] || p.email
const contactDoc = await call('frappe.client.insert', {
doc: {
doctype: 'Contact',
first_name: firstName,
email_ids: [{ email_id: p.email, is_primary: 1 }],
},
})
if (contactDoc?.name) p.reference_docname = contactDoc.name
}
} catch (e) {
console.error('Failed creating contact for participant', p.email, e)
}
updated.push(p)
}
return updated
}
</script>

View File

@ -324,6 +324,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import EventIcon from '@/components/Icons/EventIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
@ -541,6 +542,11 @@ const tabs = computed(() => {
label: __('Data'),
icon: DetailsIcon,
},
{
name: 'Events',
label: __('Events'),
icon: EventIcon,
},
{
name: 'Calls',
label: __('Calls'),

View File

@ -224,6 +224,7 @@ import EmailIcon from '@/components/Icons/EmailIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import EventIcon from '@/components/Icons/EventIcon.vue'
import PhoneIcon from '@/components/Icons/PhoneIcon.vue'
import TaskIcon from '@/components/Icons/TaskIcon.vue'
import NoteIcon from '@/components/Icons/NoteIcon.vue'
@ -398,6 +399,11 @@ const tabs = computed(() => {
label: __('Data'),
icon: DetailsIcon,
},
{
name: 'Events',
label: __('Events'),
icon: EventIcon,
},
{
name: 'Calls',
label: __('Calls'),

View File

@ -213,7 +213,7 @@ import { getMeta } from '@/stores/meta'
import { usersStore } from '@/stores/users'
import { formatDate, timeAgo } from '@/utils'
import { Tooltip, Avatar, TextEditor, Dropdown, call } from 'frappe-ui'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
const { getFormattedPercent, getFormattedFloat, getFormattedCurrency } =

View File

@ -84,6 +84,11 @@ const routes = [
name: 'Call Logs',
component: () => import('@/pages/CallLogs.vue'),
},
{
path: '/calendar',
name: 'Calendar',
component: () => import('@/pages/Calendar.vue'),
},
{
path: '/welcome',
name: 'Welcome',

View File

@ -1,16 +0,0 @@
import { useStorage } from '@vueuse/core'
export const theme = useStorage('theme', 'light')
export function toggleTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme')
theme.value = currentTheme === 'dark' ? 'light' : 'dark'
document.documentElement.setAttribute('data-theme', theme.value)
}
export function setTheme(value) {
theme.value = value || theme.value
if (['light', 'dark'].includes(theme.value)) {
document.documentElement.setAttribute('data-theme', theme.value)
}
}

View File

@ -45,7 +45,7 @@ export function getFormat(
onlyTime = false,
withDate = true,
) {
if (!date) return ''
if (!date && withDate) return ''
let dateFormat =
window.sysdefaults.date_format
.replace('mm', 'MM')
@ -689,3 +689,33 @@ export function validateConditions(conditions) {
return conditions.length > 0
}
// sameArrayContents: returns true if both arrays have exactly the same elements
// (including duplicate counts) irrespective of order.
// Non-arrays or arrays of different length return false.
export function sameArrayContents(a, b) {
if (a === b) return true
if (!Array.isArray(a) || !Array.isArray(b)) return false
if (a.length !== b.length) return false
if (a.length === 0) return true
const counts = new Map()
for (const v of a) {
counts.set(v, (counts.get(v) || 0) + 1)
}
for (const v of b) {
const c = counts.get(v)
if (!c) return false
if (c === 1) counts.delete(v)
else counts.set(v, c - 1)
}
return counts.size === 0
}
// orderSensitiveEqual: returns true only if arrays are strictly equal index-wise
export function orderSensitiveEqual(a, b) {
if (a === b) return true
if (!Array.isArray(a) || !Array.isArray(b)) return false
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
return true
}

2289
yarn.lock

File diff suppressed because it is too large Load Diff