Merge pull request #748 from shariquerik/google-calendar
This commit is contained in:
commit
181439be1d
@ -1 +1 @@
|
||||
Subproject commit 136f2715c2bd22b7390a2a02f1849a147d16b191
|
||||
Subproject commit 02fc126fd5c49f0ecf6cce117585f89c4ea585c3
|
||||
286
frontend/components.d.ts
vendored
Normal file
286
frontend/components.d.ts
vendored
Normal 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']
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
109
frontend/src/components/Activities/EventArea.vue
Normal file
109
frontend/src/components/Activities/EventArea.vue
Normal 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>
|
||||
322
frontend/src/components/Calendar/Attendee.vue
Normal file
322
frontend/src/components/Calendar/Attendee.vue
Normal 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>
|
||||
706
frontend/src/components/Calendar/CalendarEventPanel.vue
Normal file
706
frontend/src/components/Calendar/CalendarEventPanel.vue
Normal 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>
|
||||
16
frontend/src/components/Calendar/utils.js
Normal file
16
frontend/src/components/Calendar/utils.js
Normal 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
|
||||
}
|
||||
314
frontend/src/components/Controls/EmailMultiSelect.vue
Normal file
314
frontend/src/components/Controls/EmailMultiSelect.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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'
|
||||
|
||||
@ -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({
|
||||
|
||||
14
frontend/src/components/Icons/DescriptionIcon.vue
Normal file
14
frontend/src/components/Icons/DescriptionIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/EventIcon.vue
Normal file
16
frontend/src/components/Icons/EventIcon.vue
Normal 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>
|
||||
14
frontend/src/components/Icons/PeopleIcon.vue
Normal file
14
frontend/src/components/Icons/PeopleIcon.vue
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
|
||||
473
frontend/src/components/Modals/EventModal.vue
Normal file
473
frontend/src/components/Modals/EventModal.vue
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { DropdownOption } from '@/utils'
|
||||
import { Dropdown } from 'frappe-ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
|
||||
104
frontend/src/components/ShortcutTooltip.vue
Normal file
104
frontend/src/components/ShortcutTooltip.vue
Normal 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>
|
||||
@ -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'
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
237
frontend/src/composables/event.js
Normal file
237
frontend/src/composables/event.js
Normal 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,
|
||||
}
|
||||
}
|
||||
75
frontend/src/composables/useKeyboardShortcuts.js
Normal file
75
frontend/src/composables/useKeyboardShortcuts.js
Normal 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),
|
||||
}
|
||||
}
|
||||
476
frontend/src/pages/Calendar.vue
Normal file
476
frontend/src/pages/Calendar.vue
Normal 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>
|
||||
@ -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'),
|
||||
|
||||
@ -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'),
|
||||
|
||||
@ -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 } =
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user