snapshot of https://github.com/frappe/crm.git (develop, 2026-09-18; blobs > 131072B omitted)
Some checks failed
Frontend / Unit Tests & Coverage (push) Has been skipped
Regenerate POT file (translatable strings) / Regenerate POT file (develop) (push) Failing after 1m13s
Regenerate POT file (translatable strings) / Regenerate POT file (main-hotfix) (push) Failing after 6s
Server / Tests (frappe ${{ matrix.frappe-branch }}) (push) Has been skipped
Server / Resolve frappe matrix (push) Failing after 1s
Frontend / Lint (push) Failing after 1s
Frontend / Production Build (push) Failing after 1s
Server / Coverage Wrap Up (push) Has been skipped
Frontend / Coverage Wrap Up (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-18 01:36:01 +05:00
commit db69de1c11
905 changed files with 102713 additions and 0 deletions

6
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
components.d.ts

View File

@@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}

42
frontend/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Frappe UI Starter
This template should help get you started developing custom frontend for Frappe
apps with Vue 3 and the Frappe UI package.
This boilerplate sets up Vue 3, Vue Router, TailwindCSS, and Frappe UI out of
the box.
## Usage
This template is meant to be cloned inside an existing Frappe App. Assuming your
apps name is `todo`. Clone this template in the root folder of your app using `degit`.
```
cd apps/todo
npx degit netchampfaris/frappe-ui-starter frontend
cd frontend
yarn
yarn dev
```
In a development environment, you need to put the below key-value pair in your `site_config.json` file:
```
"ignore_csrf": 1
```
This will prevent `CSRFToken` errors while using the vite dev server. In production environment, the `csrf_token` is attached to the `window` object in `index.html` for you.
The Vite dev server will start on the port `8080`. This can be changed from `vite.config.js`.
The development server is configured to proxy your frappe app (usually running on port `8000`). If you have a site named `todo.test`, open `http://todo.test:8080` in your browser. If you see a button named "Click to send 'ping' request", congratulations!
If you notice the browser URL is `/frontend`, this is the base URL where your frontend app will run in production.
To change this, open `src/router.js` and change the base URL passed to `createWebHistory`.
## Resources
- [Vue 3](https://v3.vuejs.org/guide/introduction.html)
- [Vue Router](https://next.router.vuejs.org/guide/)
- [Frappe UI](https://github.com/frappe/frappe-ui)
- [TailwindCSS](https://tailwindcss.com/docs/utility-first)
- [Vite](https://vitejs.dev/guide/)

View File

@@ -0,0 +1,56 @@
import js from '@eslint/js'
import ts from 'typescript-eslint'
import pluginVue from 'eslint-plugin-vue'
import configPrettier from 'eslint-config-prettier'
import vueParser from 'vue-eslint-parser'
import globals from 'globals'
import importX, { createNodeResolver } from 'eslint-plugin-import-x'
import path from 'node:path'
export default [
{
ignores: ['**/dist/**', '**/node_modules/**', '**/public/dist/**'],
},
js.configs.recommended,
...ts.configs.recommended,
...pluginVue.configs['flat/recommended'],
{
files: ['**/*.vue', '**/*.js', '**/*.ts'],
languageOptions: {
parser: vueParser,
parserOptions: {
parser: ts.parser,
sourceType: 'module',
ecmaVersion: 'latest',
},
globals: {
...globals.browser,
...globals.node,
frappe: 'readonly',
__: 'readonly',
},
},
},
{
plugins: { 'import-x': importX },
settings: {
'import-x/resolver-next': [
createNodeResolver({
alias: { '@': [path.resolve(import.meta.dirname, 'src')] },
extensions: ['.js', '.ts', '.jsx', '.tsx', '.vue', '.json'],
}),
],
},
rules: {
'import-x/no-unresolved': ['error', { ignore: ['^~icons/'] }],
'vue/multi-word-component-names': 'off',
'vue/prop-name-casing': 'off',
'vue/attribute-hyphenation': 'off',
'vue/v-on-event-hyphenation': 'off',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
'no-undef': 'error',
},
},
configPrettier,
]

201
frontend/index.html Normal file
View File

@@ -0,0 +1,201 @@
<!doctype html>
<html class="h-full" lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover maximum-scale=1.0, user-scalable=no"
/>
<title>Frappe CRM</title>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Frappe CRM" />
<meta name="apple-mobile-web-app-status-bar-style" content="white" />
<!-- PWA -->
<link
rel="icon"
type="image/png"
sizes="196x196"
href="/assets/crm/manifest/apple-icon-180.png"
/>
<link
rel="apple-touch-icon"
href="/assets/crm/manifest/apple-icon-180.png"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2048-2732.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2732-2048.jpg"
media="(device-width: 1024px) and (device-height: 1366px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1668-2388.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2388-1668.jpg"
media="(device-width: 834px) and (device-height: 1194px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1536-2048.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2048-1536.jpg"
media="(device-width: 768px) and (device-height: 1024px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1488-2266.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2266-1488.jpg"
media="(device-width: 744px) and (device-height: 1133px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1640-2360.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2360-1640.jpg"
media="(device-width: 820px) and (device-height: 1180px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1668-2224.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2224-1668.jpg"
media="(device-width: 834px) and (device-height: 1112px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1620-2160.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2160-1620.jpg"
media="(device-width: 810px) and (device-height: 1080px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1290-2796.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2796-1290.jpg"
media="(device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1179-2556.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2556-1179.jpg"
media="(device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1284-2778.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2778-1284.jpg"
media="(device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1170-2532.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2532-1170.jpg"
media="(device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1125-2436.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2436-1125.jpg"
media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1242-2688.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2688-1242.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-828-1792.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1792-828.jpg"
media="(device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1242-2208.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-2208-1242.jpg"
media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-750-1334.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1334-750.jpg"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-640-1136.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)"
/>
<link
rel="apple-touch-startup-image"
href="/assets/crm/manifest/apple-splash-1136-640.jpg"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: landscape)"
/>
</head>
<body class="sm:overscroll-y-none no-scrollbar">
<div id="app" class="h-full"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

66
frontend/package.json Normal file
View File

@@ -0,0 +1,66 @@
{
"name": "crm-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build --base=/assets/crm/frontend/ && yarn copy-html-entry",
"copy-html-entry": "cp ../crm/public/frontend/index.html ../crm/www/crm.html",
"serve": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@framework/ui": "link:../../frappe/ui",
"@tiptap/core": "^3.26.0",
"@tiptap/extension-paragraph": "^3.26.0",
"@twilio/voice-sdk": "^2.10.2",
"@vitejs/plugin-vue": "^6.0.8",
"@vitejs/plugin-vue-jsx": "^5.1.6",
"@vueuse/integrations": "^10.3.0",
"dompurify": "^3.3.3",
"frappe-ui": "1.0.0-beta.29",
"gemoji": "^8.1.0",
"leaflet": "^1.9.4",
"leaflet-draw": "^1.0.4",
"leaflet.locatecontrol": "^0.89.0",
"lodash": "^4.18.1",
"mime": "^4.0.1",
"pinia": "^2.0.33",
"socket.io-client": "^4.7.2",
"sortablejs": "^1.15.0",
"vite": "^8.2.0",
"vite-plugin-pwa": "^1.3.0",
"vue": "^3.5.13",
"vue-router": "^4.2.2",
"vuedraggable": "^4.1.0"
},
"resolutions": {
"dompurify": "3.4.0",
"picomatch": "4.0.5",
"prosemirror-model": "1.25.9",
"prosemirror-view": "1.41.9",
"vite": "8.2.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@vitest/coverage-v8": "^4.1.10",
"autoprefixer": "^10.4.14",
"eslint": "^10.0.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-x": "^4.17.1",
"eslint-plugin-vue": "^10.8.0",
"globals": "^17.3.0",
"happy-dom": "^20.9.0",
"postcss": "^8.4.5",
"tailwindcss": "^3.4.15",
"typescript-eslint": "^8.56.1",
"vitest": "^4.1.10",
"vue-eslint-parser": "^10.4.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
frontend/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

46
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,46 @@
<template>
<FrappeUIProvider>
<NotPermitted v-if="$route.name === 'Not Permitted'" />
<router-view v-else-if="$route.name === 'Onboarding'" />
<Layout v-else-if="session.isLoggedIn" class="isolate">
<router-view :key="$route.fullPath" />
</Layout>
<Dialogs />
<DoctypeModals />
</FrappeUIProvider>
</template>
<script setup>
import NotPermitted from '@/pages/NotPermitted.vue'
import DoctypeModals from '@/components/Modals/DoctypeModals.vue'
import { Dialogs } from '@/utils/dialogs'
import { sessionStore } from '@/stores/session'
import { FrappeUIProvider, setConfig, useTheme } from 'frappe-ui'
import { computed, defineAsyncComponent, provide } from 'vue'
const session = sessionStore()
provide('session', session)
const { setTheme } = useTheme()
if (!localStorage.getItem('theme')) {
setTheme('light')
}
const MobileLayout = defineAsyncComponent(
() => import('./components/Layouts/MobileLayout.vue'),
)
const DesktopLayout = defineAsyncComponent(
() => import('./components/Layouts/DesktopLayout.vue'),
)
const Layout = computed(() => {
if (window.innerWidth < 640) {
return MobileLayout
} else {
return DesktopLayout
}
})
setConfig('systemTimezone', window.timezone?.system || null)
setConfig('localTimezone', window.timezone?.user || null)
setConfig('translatedMessages', window.translated_messages || {})
</script>

View File

@@ -0,0 +1,869 @@
<template>
<ActivityHeader
v-model="tabIndex"
v-model:showWhatsappTemplates="showWhatsappTemplates"
v-model:showFilesUploader="showFilesUploader"
v-model:emailBox="emailBox"
:tabs="tabs"
:title="title"
:doc="doc"
:whatsappBox="whatsappBox"
:modalRef="modalRef"
/>
<FadedScrollableDiv class="flex flex-col h-full overflow-y-auto">
<div
v-if="all_activities?.loading"
class="flex flex-1 flex-col items-center justify-center gap-3 text-2xl-medium text-ink-gray-4"
>
<LoadingIndicator class="h-6 w-6" />
<span>{{ __('Loading...') }}</span>
</div>
<div
v-else-if="
activities?.length ||
(whatsappMessages.data?.length && title == 'WhatsApp')
"
class="activities"
>
<div v-if="title == 'WhatsApp' && whatsappMessages.data?.length">
<WhatsAppArea
v-model="whatsappMessages"
v-model:reply="replyMessage"
class="px-3 sm:px-10"
:messages="whatsappMessages.data"
/>
</div>
<div
v-else-if="title == 'Notes'"
class="grid grid-cols-1 gap-4 px-3 pb-3 sm:px-10 sm:pb-5 lg:grid-cols-2 xl:grid-cols-3"
>
<div
v-for="note in activities"
:key="note.name"
@click="modalRef.showNote(note)"
>
<NoteArea
v-model="all_activities"
:note="note"
:modalRef="modalRef"
/>
</div>
</div>
<div v-else-if="title == 'Comments'" class="pb-5">
<div v-for="(comment, i) in activities" :key="comment.name">
<div
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-2 px-3 sm:gap-4 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-elevation-2"
:class="
i != activities.length - 1 ? 'before:h-full' : 'before:h-4'
"
>
<div
class="flex h-8 w-7 items-center justify-center bg-surface-base"
>
<CommentIcon class="text-ink-gray-8" />
</div>
</div>
<CommentArea
class="mb-4"
:activity="comment"
@reload="all_activities.reload()"
/>
</div>
</div>
</div>
<div v-else-if="title == 'Tasks'" class="px-3 pb-3 sm:px-10 sm:pb-5">
<TaskArea :modalRef="modalRef" :tasks="activities" :doctype="doctype" />
</div>
<div v-else-if="title == 'Calls'" class="activity">
<div v-for="(call, i) in activities" :key="call.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-elevation-2"
:class="
i != activities.length - 1 ? 'before:h-full' : 'before:h-4'
"
>
<div
class="flex h-8 w-7 items-center justify-center bg-surface-base text-ink-gray-8"
>
<MissedCallIcon
v-if="call.status == 'No Answer'"
class="text-ink-red-8"
/>
<DeclinedCallIcon v-else-if="call.status == 'Busy'" />
<component
:is="
call.type == 'Incoming' ? InboundCallIcon : OutboundCallIcon
"
v-else
/>
</div>
</div>
<CallArea class="mb-4" :activity="call" />
</div>
</div>
</div>
<div
v-else-if="title == 'Attachments'"
class="px-3 pb-3 sm:px-10 sm:pb-5"
>
<AttachmentArea
:attachments="activities"
@reload="all_activities.reload() && scroll()"
/>
</div>
<template v-else>
<div
v-for="(activity, i) in activities"
:key="activity.name"
class="activity px-3 sm:px-10"
:class="
['Activity', 'Emails'].includes(title)
? 'grid grid-cols-[30px_minmax(auto,_1fr)] gap-2 sm:gap-4'
: ''
"
>
<div
v-if="['Activity', 'Emails'].includes(title)"
class="z-0 relative flex justify-center before:absolute before:left-[50%] before:-z-[1] before:top-0 before:border-l before:border-outline-elevation-2"
:class="[
i != activities.length - 1 ? 'before:h-full' : 'before:h-4',
]"
>
<div
class="flex h-7 w-7 items-center justify-center bg-surface-base"
:class="{
'mt-2.5': ['communication'].includes(activity.activity_type),
'bg-surface-base': ['added', 'removed', 'changed'].includes(
activity.activity_type,
),
'h-8': [
'comment',
'communication',
'incoming_call',
'outgoing_call',
].includes(activity.activity_type),
}"
>
<UserAvatar
v-if="activity.activity_type == 'communication'"
:user="activity.data.sender"
size="md"
/>
<MissedCallIcon
v-else-if="
['incoming_call', 'outgoing_call'].includes(
activity.activity_type,
) && activity.status == 'No Answer'
"
class="text-ink-red-8"
/>
<DeclinedCallIcon
v-else-if="
['incoming_call', 'outgoing_call'].includes(
activity.activity_type,
) && activity.status == 'Busy'
"
/>
<component
:is="activity.icon"
v-else
:class="
['added', 'removed', 'changed'].includes(
activity.activity_type,
)
? 'text-ink-gray-4'
: 'text-ink-gray-8'
"
/>
</div>
</div>
<div
v-if="activity.activity_type == 'communication'"
class="pb-5 mt-px"
>
<EmailArea :activity="activity" :emailBox="emailBox" />
</div>
<div
v-else-if="activity.activity_type == 'comment'"
:id="activity.name"
class="mb-4"
>
<CommentArea
:activity="activity"
@reload="all_activities.reload()"
/>
</div>
<div
v-else-if="activity.activity_type == 'attachment_log'"
:id="activity.name"
class="mb-4 flex flex-col gap-2 py-1.5"
>
<div class="flex items-center justify-stretch gap-2 text-base">
<div
class="inline-flex items-center flex-wrap gap-1.5 text-ink-gray-8 font-medium"
>
<span class="font-medium">{{ activity.owner_name }}</span>
<span class="text-ink-gray-5">{{
__(activity.data.type)
}}</span>
<a
v-if="activity.data.file_url"
:href="activity.data.file_url"
target="_blank"
>
<span>{{ activity.data.file_name }}</span>
</a>
<span v-else>{{ activity.data.file_name }}</span>
<span
v-if="activity.data.is_private"
class="lucide-lock size-3"
aria-hidden="true"
/>
</div>
<div class="ml-auto whitespace-nowrap">
<TimelineTimestamp :date="activity.creation" />
</div>
</div>
</div>
<div
v-else-if="
activity.activity_type == 'incoming_call' ||
activity.activity_type == 'outgoing_call'
"
class="mb-4"
>
<CallArea :activity="activity" />
</div>
<div v-else class="mb-4 flex flex-col gap-2 py-1.5">
<div class="flex items-center justify-stretch gap-2 text-base">
<div
v-if="activity.other_versions"
class="inline-flex flex-wrap gap-1.5 text-ink-gray-8 font-medium"
>
<span>{{
activity.show_others ? __('Hide') : __('Show')
}}</span>
<span> +{{ activity.other_versions.length + 1 }} </span>
<span>{{ __('changes from') }}</span>
<span>{{ activity.owner_name }}</span>
<Button
class="!size-4"
variant="ghost"
:icon="SelectIcon"
@click="activity.show_others = !activity.show_others"
/>
</div>
<div
v-else
class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5"
>
<span class="font-medium text-ink-gray-8">
{{ activity.owner_name }}
</span>
<span v-if="activity.type">{{ __(activity.type) }}</span>
<span
v-if="activity.data?.field_label"
class="max-w-xs truncate font-medium text-ink-gray-8"
>
{{ __(activity.data.field_label) }}
</span>
<span v-if="activity.value">{{ __(activity.value) }}</span>
<span
v-if="activity.data?.old_value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
v-if="activity.options == 'User'"
class="flex items-center gap-1"
>
<UserAvatar :user="activity.data.old_value" size="xs" />
{{ getUser(activity.data.old_value).full_name }}
</div>
<div v-else class="truncate">
{{ activity.data.old_value }}
</div>
</span>
<span v-if="activity.to">{{ __('to') }}</span>
<span
v-if="activity.data?.value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
v-if="activity.options == 'User'"
class="flex items-center gap-1"
>
<UserAvatar :user="activity.data.value" size="xs" />
{{ getUser(activity.data.value).full_name }}
</div>
<div v-else class="truncate">
{{ activity.data.value }}
</div>
</span>
</div>
<div class="ml-auto whitespace-nowrap">
<TimelineTimestamp :date="activity.creation" />
</div>
</div>
<div
v-if="activity.other_versions && activity.show_others"
class="flex flex-col gap-0.5"
>
<div
v-for="a in sortByCreation([
activity,
...activity.other_versions,
])"
:key="a.creation"
class="flex items-start justify-stretch gap-2 py-1.5 text-base"
>
<div class="inline-flex flex-wrap gap-1 text-ink-gray-5">
<span
v-if="a.data?.field_label"
class="max-w-xs truncate text-ink-gray-5"
>
{{ __(a.data.field_label) }}
</span>
<span
class="lucide-arrow-right mx-1 h-4 w-4 text-ink-gray-5"
aria-hidden="true"
/>
<span v-if="a.type">
{{ startCase(__(a.type)) }}
</span>
<span
v-if="a.data?.old_value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
v-if="a.options == 'User'"
class="flex items-center gap-1"
>
<UserAvatar :user="a.data.old_value" size="xs" />
{{ getUser(a.data.old_value).full_name }}
</div>
<div v-else class="truncate">
{{ a.data.old_value }}
</div>
</span>
<span v-if="a.to">{{ __('to') }}</span>
<span
v-if="a.data?.value"
class="max-w-xs font-medium text-ink-gray-8"
>
<div
v-if="a.options == 'User'"
class="flex items-center gap-1"
>
<UserAvatar :user="a.data.value" size="xs" />
{{ getUser(a.data.value).full_name }}
</div>
<div v-else class="truncate">
{{ a.data.value }}
</div>
</span>
</div>
<div class="ml-auto whitespace-nowrap">
<TimelineTimestamp :date="a.creation" />
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
<DataFields
v-model:fieldLayoutTabIndex="fieldLayoutTabIndex"
v-model:fieldLayoutTabName="fieldLayoutTabName"
:doctype="doctype"
:docname="docname"
@beforeSave="(data) => emit('beforeSave', data)"
@afterSave="(data) => emit('afterSave', data)"
/>
</div>
<EmptyState
v-else
:title="emptyText"
:description="emptyTextDescription"
:icon="emptyTextIcon"
:top="top"
/>
</FadedScrollableDiv>
<div>
<CommunicationArea
v-if="['Emails', 'Comments', 'Activity'].includes(title)"
ref="emailBox"
v-model="doc"
v-model:reload="reload_email"
:doctype="doctype"
@scroll="scroll"
/>
<WhatsAppBox
v-if="title == 'WhatsApp'"
ref="whatsappBox"
v-model="doc"
v-model:reply="replyMessage"
v-model:whatsapp="whatsappMessages"
:doctype="doctype"
@scroll="scroll"
/>
</div>
<WhatsappTemplateSelectorModal
v-if="whatsappEnabled"
v-model="showWhatsappTemplates"
:doctype="doctype"
@send="(t) => sendTemplate(t)"
/>
<AllModals
ref="modalRef"
v-model="all_activities"
:doctype="doctype"
:doc="doc"
/>
<FilesUploader
v-model="showFilesUploader"
:doctype="doctype"
:docname="docname"
@after="
() => {
all_activities.reload()
changeTabTo('attachments')
}
"
/>
</template>
<script setup>
import ActivityHeader from '@/components/Activities/ActivityHeader.vue'
import EmailArea from '@/components/Activities/EmailArea.vue'
import CommentArea from '@/components/Activities/CommentArea.vue'
import CallArea from '@/components/Activities/CallArea.vue'
import NoteArea from '@/components/Activities/NoteArea.vue'
import TaskArea from '@/components/Activities/TaskArea.vue'
import AttachmentArea from '@/components/Activities/AttachmentArea.vue'
import DataFields from '@/components/Activities/DataFields.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import ActivityIcon from '@/components/Icons/ActivityIcon.vue'
import EmailIcon from '@/components/Icons/EmailIcon.vue'
import DetailsIcon from '@/components/Icons/DetailsIcon.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 WhatsAppArea from '@/components/Activities/WhatsAppArea.vue'
import WhatsAppBox from '@/components/Activities/WhatsAppBox.vue'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import EmptyState from '@/components/ListViews/EmptyState.vue'
import LeadsIcon from '@/components/Icons/LeadsIcon.vue'
import DealsIcon from '@/components/Icons/DealsIcon.vue'
import DotIcon from '@/components/Icons/DotIcon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import SelectIcon from '@/components/Icons/SelectIcon.vue'
import MissedCallIcon from '@/components/Icons/MissedCallIcon.vue'
import DeclinedCallIcon from '@/components/Icons/DeclinedCallIcon.vue'
import InboundCallIcon from '@/components/Icons/InboundCallIcon.vue'
import OutboundCallIcon from '@/components/Icons/OutboundCallIcon.vue'
import FadedScrollableDiv from '@/components/FadedScrollableDiv.vue'
import CommunicationArea from '@/components/CommunicationArea.vue'
import WhatsappTemplateSelectorModal from '@/components/Modals/WhatsappTemplateSelectorModal.vue'
import AllModals from '@/components/Activities/AllModals.vue'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
import TimelineTimestamp from '@/components/Activities/TimelineTimestamp.vue'
import { startCase } from '@/utils'
import { globalStore } from '@/stores/global'
import { usersStore } from '@/stores/users'
import { useTimelinePreferences } from '@/composables/useTimelinePreferences'
import { whatsappEnabled } from '@/composables/whatsapp'
import { useDocument } from '@/data/document'
import { useTelemetry } from 'frappe-ui/frappe'
import { Button, createResource, toast } from 'frappe-ui'
import { useElementVisibility } from '@vueuse/core'
import {
ref,
computed,
h,
markRaw,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from 'vue'
import { useRoute } from 'vue-router'
const { $socket } = globalStore()
const { getUser } = usersStore()
const { capture } = useTelemetry()
const { isNewestFirst } = useTimelinePreferences()
const props = defineProps({
doctype: { type: String, default: 'CRM Lead' },
docname: { type: String, default: '' },
tabs: { type: Array, default: () => [] },
})
const emit = defineEmits(['beforeSave', 'afterSave'])
const route = useRoute()
const reload = defineModel('reload', { type: Boolean, default: false })
const tabIndex = defineModel('tabIndex', { type: Number, default: 0 })
const { document: _document } = useDocument(props.doctype, props.docname)
const doc = computed(() => _document.doc || {})
const reload_email = ref(false)
const modalRef = ref(null)
const showFilesUploader = ref(false)
const fieldLayoutTabIndex = ref(0)
const fieldLayoutTabName = ref('')
const title = computed(() => props.tabs?.[tabIndex.value]?.name || 'Activity')
const changeTabTo = (tabName) => {
const tabNames = props.tabs?.map((tab) => tab.name?.toLowerCase())
const index = tabNames?.indexOf(tabName)
if (index == -1) return
tabIndex.value = index
}
const all_activities = createResource({
url: 'crm.api.activities.get_activities',
params: { name: props.docname },
cache: ['activity', props.docname],
auto: true,
transform: ([versions, calls, notes, tasks, attachments]) => {
return { versions, calls, notes, tasks, attachments }
},
onSuccess: () => nextTick(() => scroll()),
onError: (error) => {
toast.error(error.messages?.[0] || __('Failed to load activities'))
},
})
const showWhatsappTemplates = ref(false)
const whatsappMessages = createResource({
url: 'crm.api.whatsapp.get_whatsapp_messages',
cache: ['whatsapp_messages', props.docname],
params: {
reference_doctype: props.doctype,
reference_name: props.docname,
},
auto: false,
transform: (data) => sortByCreation(data),
onSuccess: () => nextTick(() => scroll()),
})
watch(
whatsappEnabled,
(enabled) => {
if (enabled) whatsappMessages.fetch()
},
{ immediate: true },
)
onBeforeUnmount(() => {
$socket.off('whatsapp_message')
$socket.off('docinfo_update', handleDocinfoUpdate)
$socket.emit('doc_unsubscribe', props.doctype, props.docname)
})
onMounted(() => {
$socket.emit('doc_subscribe', props.doctype, props.docname)
$socket.on('docinfo_update', handleDocinfoUpdate)
$socket.on('whatsapp_message', (data) => {
if (
data.reference_doctype === props.doctype &&
data.reference_name === props.docname
) {
whatsappMessages.reload()
}
})
nextTick(() => {
const hash = route.hash.slice(1) || null
let tabNames = props.tabs?.map((tab) => tab.name)
if (!tabNames?.includes(hash)) {
scroll(hash)
}
})
})
function handleDocinfoUpdate({ doc, key }) {
// 'comments' covers comment activity; 'communications' covers new/updated
// emails (e.g. a reply arriving, or a read-receipt coming in) so the
// timeline reflects them live instead of only after a manual reload.
if (key !== 'comments' && key !== 'communications') return
if (doc.reference_doctype !== props.doctype) return
if (doc.reference_name !== props.docname) return
all_activities.reload()
_document.reload()
}
function sendTemplate(template) {
showWhatsappTemplates.value = false
capture('send_whatsapp_template', { doctype: props.doctype })
createResource({
url: 'crm.api.whatsapp.send_whatsapp_template',
params: {
reference_doctype: props.doctype,
reference_name: props.docname,
to: doc.value.mobile_no,
template,
},
auto: true,
onError: (error) => {
toast.error(error.messages?.[0] || __('Failed to send WhatsApp template'))
},
onSuccess: () => whatsappMessages.reload(),
})
}
const replyMessage = ref({})
function get_activities() {
if (!all_activities.data?.versions) return []
if (!all_activities.data?.calls.length)
return all_activities.data.versions || []
return [...all_activities.data.versions, ...all_activities.data.calls]
}
const activities = computed(() => {
let _activities = []
if (title.value == 'Activity') {
_activities = get_activities()
} else if (title.value == 'Emails') {
if (!all_activities.data?.versions) return []
_activities = all_activities.data.versions.filter(
(activity) => activity.activity_type === 'communication',
)
} else if (title.value == 'Comments') {
if (!all_activities.data?.versions) return []
_activities = all_activities.data.versions.filter(
(activity) => activity.activity_type === 'comment',
)
} else if (title.value == 'Calls') {
if (!all_activities.data?.calls) return []
return sortByCreation(all_activities.data.calls, isNewestFirst.value)
} else if (title.value == 'Tasks') {
if (!all_activities.data?.tasks) return []
return sortByModified(all_activities.data.tasks)
} else if (title.value == 'Notes') {
if (!all_activities.data?.notes) return []
return sortByModified(all_activities.data.notes)
} else if (title.value == 'Attachments') {
if (!all_activities.data?.attachments) return []
return sortByModified(all_activities.data.attachments)
}
_activities.forEach((activity) => {
activity.icon = timelineIcon(activity.activity_type, activity.is_lead)
if (
activity.activity_type == 'incoming_call' ||
activity.activity_type == 'outgoing_call' ||
activity.activity_type == 'communication'
)
return
update_activities_details(activity)
if (activity.other_versions) {
activity.show_others = false
activity.other_versions.forEach((other_version) => {
update_activities_details(other_version)
})
}
})
return sortByCreation(_activities, isNewestFirst.value)
})
function sortByCreation(list, newestFirst = false) {
// Direction comes from the comparator operand order (like sortByModified),
// not .reverse(). A consistent comparator keeps .sort() idempotent, so
// sorting the reactive array in place doesn't re-trigger this computed.
return list.sort((a, b) =>
newestFirst
? new Date(b.creation) - new Date(a.creation)
: new Date(a.creation) - new Date(b.creation),
)
}
function sortByModified(list) {
return list.sort((b, a) => new Date(a.modified) - new Date(b.modified))
}
function update_activities_details(activity) {
activity.owner_name = getUser(activity.owner).full_name
activity.type = ''
activity.value = ''
activity.to = ''
if (activity.activity_type == 'creation') {
activity.type = activity.data
} else if (activity.activity_type == 'added') {
activity.type = 'added'
activity.value = 'as'
} else if (activity.activity_type == 'removed') {
activity.type = 'removed'
activity.value = 'value'
} else if (activity.activity_type == 'changed') {
activity.type = 'changed'
activity.value = 'from'
activity.to = 'to'
}
}
const top = computed(() => {
if (['Activity', 'Emails', 'Comments'].includes(title.value)) {
return '32.3%'
}
return '30%'
})
const emptyText = computed(() => {
let text = __('No Activities Found')
if (title.value == 'Emails') {
text = __('No Emails Found')
} else if (title.value == 'Comments') {
text = __('No Comments Found')
} else if (title.value == 'Data') {
text = __('No Data Fields Added Yet')
} else if (title.value == 'Calls') {
text = __('No Call History')
} else if (title.value == 'Notes') {
text = __('No Notes Found')
} else if (title.value == 'Tasks') {
text = __('No Tasks Found')
} else if (title.value == 'Attachments') {
text = __('No Attachments Found')
} else if (title.value == 'WhatsApp') {
text = __('No WhatsApp Messages Found')
}
return text
})
const emptyTextDescription = computed(() => {
let description = __(
'There are no activities to display here. Go ahead and make some changes.',
)
if (title.value == 'Emails') {
description = __(
'No emails found in your inbox. New messages will appear here soon.',
)
} else if (title.value == 'Comments') {
description = __('Be the first to add one.')
} else if (title.value == 'Data') {
description = __('No data fields have been added yet.')
} else if (title.value == 'Calls') {
description = __(
'No recent calls to display. Log a call or call someone now!',
)
} else if (title.value == 'Notes') {
description = __(
'Nothing here for now. Add a note to keep track of things.',
)
} else if (title.value == 'Tasks') {
description = __(
'Nothing to do at the moment. Start organizing by adding one here.',
)
} else if (title.value == 'Attachments') {
description = __(
'No files have been attached yet. Upload files to see them here.',
)
} else if (title.value == 'WhatsApp') {
description = __('Start a conversation now!')
}
return description
})
const emptyTextIcon = computed(() => {
let icon = ActivityIcon
if (title.value == 'Emails') {
icon = EmailIcon
} else if (title.value == 'Comments') {
icon = CommentIcon
} else if (title.value == 'Data') {
icon = DetailsIcon
} else if (title.value == 'Calls') {
icon = PhoneIcon
} else if (title.value == 'Notes') {
icon = NoteIcon
} else if (title.value == 'Tasks') {
icon = TaskIcon
} else if (title.value == 'Attachments') {
icon = AttachmentIcon
} else if (title.value == 'WhatsApp') {
icon = WhatsAppIcon
}
return h(icon, { class: 'text-ink-gray-4' })
})
function timelineIcon(activity_type, is_lead) {
let icon
switch (activity_type) {
case 'creation':
icon = is_lead ? LeadsIcon : DealsIcon
break
case 'deal':
icon = DealsIcon
break
case 'comment':
icon = CommentIcon
break
case 'incoming_call':
icon = InboundCallIcon
break
case 'outgoing_call':
icon = OutboundCallIcon
break
case 'attachment_log':
icon = AttachmentIcon
break
default:
icon = DotIcon
}
return markRaw(icon)
}
const emailBox = ref(null)
const whatsappBox = ref(null)
watch([reload, reload_email], ([reload_value, reload_email_value]) => {
if (reload_value || reload_email_value) {
all_activities.reload()
_document.reload()
reload.value = false
reload_email.value = false
}
})
function scroll(hash) {
if (['tasks', 'notes'].includes(route.hash?.slice(1))) return
setTimeout(() => {
let el
if (!hash) {
let e = document.getElementsByClassName('activity')
el = isNewestFirst.value ? e[0] : e[e.length - 1]
} else {
el = document.getElementById(hash)
}
if (el && !useElementVisibility(el).value) {
el.scrollIntoView({ behavior: 'smooth' })
el.focus()
}
}, 500)
}
defineExpose({ emailBox, all_activities, changeTabTo })
</script>

View File

@@ -0,0 +1,179 @@
<template>
<div
v-if="title !== 'Data'"
class="flex items-center justify-between text-lg-medium sm:mx-10 sm:mb-4 sm:mt-8"
>
<div class="flex h-8 items-center text-2xl-semibold text-ink-gray-8">
{{ __(title) }}
</div>
<Button
v-if="title == 'Emails'"
variant="solid"
:label="__('New Email')"
iconLeft="plus"
@click="emailBox.show = true"
/>
<Button
v-else-if="title == 'Comments'"
variant="solid"
:label="__('New Comment')"
iconLeft="plus"
@click="emailBox.showComment = true"
/>
<MultiActionButton
v-else-if="title == 'Calls'"
variant="solid"
:options="callActions"
/>
<Button
v-else-if="title == 'Notes'"
variant="solid"
:label="__('New Note')"
iconLeft="plus"
@click="modalRef.showNote()"
/>
<Button
v-else-if="title == 'Tasks'"
variant="solid"
:label="__('New Task')"
iconLeft="plus"
@click="modalRef.showTask()"
/>
<Button
v-else-if="title == 'Attachments'"
variant="solid"
:label="__('Upload Attachment')"
iconLeft="plus"
@click="showFilesUploader = true"
/>
<div v-else-if="title == 'WhatsApp'" class="flex gap-2 shrink-0">
<Button
:label="__('Send Template')"
@click="showWhatsappTemplates = true"
/>
<Button
variant="solid"
:label="__('New Message')"
iconLeft="plus"
@click="whatsappBox.show()"
/>
</div>
<Dropdown v-else :options="defaultActions" @click.stop>
<template #default="{ open }">
<Button
variant="solid"
class="flex items-center gap-1"
:label="__('New')"
iconLeft="plus"
:iconRight="open ? 'chevron-up' : 'chevron-down'"
/>
</template>
</Dropdown>
</div>
</template>
<script setup>
import MultiActionButton from '@/components/MultiActionButton.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import CommentIcon from '@/components/Icons/CommentIcon.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 { globalStore } from '@/stores/global'
import { whatsappEnabled } from '@/composables/whatsapp'
import { callEnabled } from '@/composables/telephony'
import { Dropdown } from 'frappe-ui'
import { computed, h } from 'vue'
const props = defineProps({
tabs: { type: Array, default: () => [] },
title: { type: String, default: '' },
doc: { type: Object, default: () => ({}) },
modalRef: { type: Object, default: () => ({}) },
whatsappBox: { type: Object, default: () => ({}) },
})
const { makeCall } = globalStore()
const tabIndex = defineModel({ type: Number })
const showWhatsappTemplates = defineModel('showWhatsappTemplates', {
type: Boolean,
})
const showFilesUploader = defineModel('showFilesUploader', { type: Boolean })
const emailBox = defineModel('emailBox', { type: Object, default: () => ({}) })
const defaultActions = computed(() => {
let actions = [
{
icon: h(Email2Icon, { class: 'h-4 w-4' }),
label: __('Email'),
onClick: () => (emailBox.value.show = true),
},
{
icon: h(CommentIcon, { class: 'h-4 w-4' }),
label: __('Comment'),
onClick: () => (emailBox.value.showComment = true),
},
{
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
label: __('Log a Call'),
onClick: () => props.modalRef.createCallLog(),
},
{
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
label: __('Make a Call'),
onClick: () => makeCall(props.doc.mobile_no),
condition: () => callEnabled.value,
},
{
icon: h(NoteIcon, { class: 'h-4 w-4' }),
label: __('Note'),
onClick: () => props.modalRef.showNote(),
},
{
icon: h(TaskIcon, { class: 'h-4 w-4' }),
label: __('Task'),
onClick: () => props.modalRef.showTask(),
},
{
icon: h(AttachmentIcon, { class: 'h-4 w-4' }),
label: __('Upload Attachment'),
onClick: () => (showFilesUploader.value = true),
},
{
icon: h(WhatsAppIcon, { class: 'h-4 w-4' }),
label: __('WhatsApp Message'),
onClick: () => (tabIndex.value = getTabIndex('WhatsApp')),
condition: () => whatsappEnabled.value,
},
]
return actions.filter((action) =>
action.condition ? action.condition() : true,
)
})
function getTabIndex(name) {
return props.tabs.findIndex((tab) => tab.name === name)
}
const callActions = computed(() => {
let actions = [
{
label: __('Log a Call'),
icon: 'plus',
onClick: () => props.modalRef.createCallLog(),
},
{
label: __('Make a Call'),
icon: h(PhoneIcon, { class: 'h-4 w-4' }),
onClick: () => makeCall(props.doc.mobile_no),
condition: () => callEnabled.value,
},
]
return actions.filter((action) =>
action.condition ? action.condition() : true,
)
})
</script>

View File

@@ -0,0 +1,133 @@
<script setup>
import { useDoctypeModal } from '@/composables/doctypeModal'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { call } from 'frappe-ui'
import { useRoute, useRouter } from 'vue-router'
const props = defineProps({
doctype: { type: String, default: '' },
doc: { type: Object, default: () => ({}) },
})
const activities = defineModel({ type: Object })
const { showModal } = useDoctypeModal()
const { updateOnboardingStep } = useOnboarding('frappecrm')
const { capture } = useTelemetry()
// Tasks
function showTask(task) {
showModal({
name: task?.name,
doctype: 'CRM Task',
title: 'Task',
defaults: {
reference_doctype: props.doctype,
reference_docname: props.doc?.name,
},
callbacks: {
afterInsert: (d) => afterDoctype(d, true),
afterUpdate: afterDoctype,
},
})
}
async function deleteTask(name) {
await call('frappe.client.delete', {
doctype: 'CRM Task',
name,
})
activities.value.reload()
}
function updateTaskStatus(status, task) {
call('frappe.client.set_value', {
doctype: 'CRM Task',
name: task.name,
fieldname: 'status',
value: status,
}).then(() => {
activities.value.reload()
})
}
// Notes
function showNote(note) {
showModal({
name: note?.name,
doctype: 'FCRM Note',
title: 'Note',
defaults: {
reference_doctype: props.doctype,
reference_docname: props.doc?.name,
},
callbacks: {
afterInsert: (d) => afterDoctype(d, true),
afterUpdate: afterDoctype,
},
})
}
function afterDoctype(d, isInsert = false) {
activities.value.reload()
let name =
d.doctype == 'FCRM Note'
? 'note'
: d.doctype == 'CRM Task'
? 'task'
: 'call_log'
let redirectHash = name + 's'
if (d.doctype == 'CRM Call Log') {
redirectHash = 'calls'
}
if (isInsert) {
updateOnboardingStep('create_first_' + name)
capture(name + '_created')
} else {
capture(name + '_updated')
}
redirect(redirectHash)
}
// Call Logs
function createCallLog() {
showModal({
doctype: 'CRM Call Log',
title: 'Call Log',
defaults: {
reference_doctype: props.doctype,
reference_docname: props.doc?.name,
reference_doc: { ...props.doc },
},
callbacks: {
afterInsert: (d) => afterDoctype(d, true),
afterUpdate: afterDoctype,
},
})
}
// common
const route = useRoute()
const router = useRouter()
function redirect(tabName) {
if (route.name == 'Lead' || route.name == 'Deal') {
let hash = '#' + tabName
if (route.hash != hash) {
router.push({ ...route, hash })
}
}
}
defineExpose({
showTask,
deleteTask,
updateTaskStatus,
showNote,
createCallLog,
})
</script>

View File

@@ -0,0 +1,158 @@
<template>
<div v-if="attachments.length">
<div v-for="(attachment, i) in attachments" :key="attachment.name">
<div
class="activity flex justify-between gap-2 hover:bg-surface-sidebar rounded text-base p-2.5 cursor-pointer"
@click="openFile(attachment)"
>
<div class="flex gap-2 truncate">
<div
class="size-11 bg-surface-base rounded overflow-hidden flex-shrink-0 flex justify-center items-center"
:class="{ border: !isImage(attachment.file_type) }"
>
<img
v-if="isImage(attachment.file_type)"
class="size-full object-cover"
:src="attachment.file_url"
:alt="attachment.file_name"
/>
<component
:is="fileIcon(attachment.file_type)"
v-else
class="size-4 text-ink-gray-7"
/>
</div>
<div class="flex flex-col justify-center gap-1 truncate">
<div class="text-base text-ink-gray-8 truncate">
{{ attachment.file_name }}
</div>
<div class="mb-1 text-sm text-ink-gray-5">
{{ convertSize(attachment.file_size) }}
</div>
</div>
</div>
<div class="flex flex-col items-end gap-2 flex-shrink-0">
<TimelineTimestamp :date="attachment.creation" />
<div class="flex gap-1">
<Button
:tooltip="
attachment.is_private ? __('Make Public') : __('Make Private')
"
class="!size-5"
@click.stop="
togglePrivate(attachment.name, attachment.is_private)
"
>
<template #icon>
<FeatherIcon
:name="attachment.is_private ? 'lock' : 'unlock'"
class="size-3 text-ink-gray-7"
/>
</template>
</Button>
<Button
:tooltip="__('Delete Attachment')"
class="!size-5"
@click.stop="() => deleteAttachment(attachment.name)"
>
<template #icon>
<span
class="lucide-trash-2 size-3 text-ink-gray-7"
aria-hidden="true"
/>
</template>
</Button>
</div>
</div>
</div>
<div
v-if="i < attachments.length - 1"
class="mx-2 h-px border-t border-outline-elevation-2"
/>
</div>
</div>
</template>
<script setup>
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
import { globalStore } from '@/stores/global'
import { call } from 'frappe-ui'
import TimelineTimestamp from '@/components/Activities/TimelineTimestamp.vue'
import { convertSize, isImage } from '@/utils'
defineProps({
attachments: { type: Array, default: () => [] },
})
const emit = defineEmits(['reload'])
const { $dialog } = globalStore()
function openFile(attachment) {
window.open(attachment.file_url, '_blank')
}
function togglePrivate(fileName, isPrivate) {
let changeTo = isPrivate ? __('public') : __('private')
let title = __('Make attachment {0}', [changeTo])
let message = __('Are you sure you want to make this attachment {0}?', [
changeTo,
])
$dialog({
title,
message,
actions: [
{
label: __('Make {0}', [changeTo]),
variant: 'solid',
onClick: async (close) => {
await call('frappe.client.set_value', {
doctype: 'File',
name: fileName,
fieldname: {
is_private: !isPrivate,
},
})
emit('reload')
close()
},
},
],
})
}
function deleteAttachment(fileName) {
$dialog({
title: __('Delete Attachment'),
message: __('Are you sure you want to delete this attachment?'),
actions: [
{
label: __('Delete'),
variant: 'solid',
theme: 'red',
onClick: async (close) => {
await call('frappe.client.delete', {
doctype: 'File',
name: fileName,
})
emit('reload')
close()
},
},
],
})
}
function fileIcon(type) {
if (!type) return FileTextIcon
let audioExtentions = ['wav', 'mp3', 'ogg', 'flac', 'aac']
let videoExtentions = ['mp4', 'avi', 'mkv', 'flv', 'mov']
if (audioExtentions.includes(type.toLowerCase())) {
return FileAudioIcon
} else if (videoExtentions.includes(type.toLowerCase())) {
return FileVideoIcon
}
return FileTextIcon
}
</script>

View File

@@ -0,0 +1,233 @@
<template>
<div class="w-full text-sm text-ink-gray-5">
<div class="flex items-center gap-2">
<Button
variant="ghost"
class="text-ink-gray-5"
:icon="isPaused ? PlayIcon : PauseIcon"
@click="playPause"
/>
<div class="flex gap-2 items-center justify-between flex-1">
<input
id="track"
class="w-full slider !h-[0.5] bg-surface-gray-3 [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb:hover]:outline [&::-webkit-slider-thumb:hover]:outline-[0.5px]"
:style="{
background: `linear-gradient(to right, var(--surface-gray-10, #171717) ${progress}%, var(--surface-gray-3, #ededed) ${progress}%)`,
}"
type="range"
min="0"
:max="duration"
:value="currentTime"
step="0.01"
@input="(e) => (audio.currentTime = e.target.value)"
/>
<div class="shrink-0">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
</div>
<div class="flex items-center gap-1">
<div class="flex group gap-2 items-center">
<input
id="volume"
class="slider opacity-0 group-hover:opacity-100 w-0 group-hover:w-20 !h-[0.5] [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb:hover]:outline [&::-webkit-slider-thumb:hover]:outline-[0.5px]"
:style="{
background: `linear-gradient(to right, #171717 ${volumnProgress}%, #ededed ${volumnProgress}%)`,
}"
type="range"
min="0"
max="1"
:value="currentVolumn"
step="0.01"
@input="(e) => updateVolumnProgress(e.target.value)"
/>
<Button variant="ghost">
<template #icon>
<MuteIcon
v-if="volumnProgress == 0"
class="size-4"
@click="updateVolumnProgress('1')"
/>
<VolumnLowIcon
v-else-if="volumnProgress <= 40"
class="size-4"
@click="updateVolumnProgress('0')"
/>
<VolumnHighIcon
v-else-if="volumnProgress > 20"
class="size-4"
@click="updateVolumnProgress('0')"
/>
</template>
</Button>
</div>
<Dropdown :options="options">
<Button
icon="lucide-more-horizontal"
variant="ghost"
@click="showPlaybackSpeed = false"
/>
</Dropdown>
</div>
</div>
<audio
ref="audio"
:src="src"
crossorigin="anonymous"
@loadedmetadata="setupDuration"
@timeupdate="updateCurrentTime"
@ended="isPaused = true"
></audio>
</div>
</template>
<script setup>
import PlayIcon from '@/components/Icons/PlayIcon.vue'
import PauseIcon from '@/components/Icons/PauseIcon.vue'
import VolumnLowIcon from '@/components/Icons/VolumnLowIcon.vue'
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 'frappe-ui'
import { computed, h, ref } from 'vue'
const props = defineProps({
src: { type: String, default: '' },
})
const audio = ref(null)
const isPaused = ref(true)
const duration = ref(0)
const currentTime = ref(0)
const progress = computed(() => (currentTime.value / duration.value) * 100)
const currentVolumn = ref(1)
const volumnProgress = ref(100)
function setupDuration() {
duration.value = audio.value.duration
}
function updateCurrentTime() {
currentTime.value = audio.value.currentTime
}
function playPause() {
if (audio.value.paused) {
audio.value.play()
isPaused.value = false
} else {
audio.value.pause()
isPaused.value = true
}
}
function formatTime(time) {
if (isNaN(time)) return '00:00'
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
function updateVolumnProgress(value) {
audio.value.volume = value
currentVolumn.value = value
volumnProgress.value = value * 100
}
const showPlaybackSpeed = ref(false)
const currentPlaybackSpeed = ref(1)
const options = computed(() => {
let playbackSpeeds = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
let playbackSpeedOptions = playbackSpeeds.map((speed) => {
let label = `${speed}x`
if (speed === 1) {
label = __('Normal')
}
return {
component: () =>
h(PlaybackSpeedOption, {
label,
active: speed === currentPlaybackSpeed.value,
onClick: () => {
audio.value.playbackRate = speed
showPlaybackSpeed.value = false
currentPlaybackSpeed.value = speed
},
}),
}
})
let _options = [
{
icon: 'download',
label: __('Download'),
onClick: () => {
const a = document.createElement('a')
a.href = props.src
a.download = props.src.split('/').pop()
a.click()
},
},
{
icon: () => h(PlaybackSpeedIcon, { class: 'size-4' }),
label: __('Playback Speed'),
onClick: (e) => {
e.preventDefault()
e.stopPropagation()
showPlaybackSpeed.value = true
},
},
]
return showPlaybackSpeed.value ? playbackSpeedOptions : _options
})
</script>
<style scoped>
.slider {
--trackHeight: 2px;
--thumbRadius: 14px;
-webkit-appearance: none;
appearance: none;
background: transparent;
padding: 0;
margin: 0;
border-radius: 100px;
cursor: pointer;
}
.slider::-webkit-slider-runnable-track {
appearance: none;
height: var(--trackHeight);
border-radius: 100px;
}
.slider:focus-visible {
outline: none;
}
.slider::-webkit-slider-thumb {
width: var(--thumbRadius);
height: var(--thumbRadius);
margin-top: calc((var(--trackHeight) - var(--thumbRadius)) / 2);
background: #fff;
border-radius: 100px;
pointer-events: all;
appearance: none;
z-index: 1;
}
.slider::-webkit-slider-thumb {
width: var(--thumbRadius);
height: var(--thumbRadius);
margin-top: calc((var(--trackHeight) - var(--thumbRadius)) / 2);
background: #fff;
border-radius: 100px;
pointer-events: all;
appearance: none;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<div>
<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="call._caller.image"
:label="call._caller.label"
size="md"
/>
<span class="font-medium text-ink-gray-8 ml-1">
{{ call._caller.label }}
</span>
<span>{{
call.type == 'Incoming'
? __('has reached out')
: __('has made a call')
}}</span>
</div>
<div class="ml-auto whitespace-nowrap">
<TimelineTimestamp :date="call.creation" />
</div>
</div>
<div
class="flex flex-col gap-2 border cursor-pointer border-outline-elevation-2 rounded-md bg-surface-elevation-1 px-3 py-2.5 text-ink-gray-9"
@click="showCallLogDetailModal = true"
>
<div class="flex items-center justify-between">
<div class="inline-flex gap-2 items-center text-base-medium">
<div>
{{
call.type == 'Incoming' ? __('Inbound Call') : __('Outbound Call')
}}
</div>
</div>
<div>
<MultipleAvatar
:avatars="[
{
image: call._caller.image,
label: call._caller.label,
name: call._caller.label,
},
{
image: call._receiver.image,
label: call._receiver.label,
name: call._receiver.label,
},
]"
size="sm"
/>
</div>
</div>
<div class="flex items-center flex-wrap gap-2">
<Badge :label="formatDate(call.creation, 'MMM D, dddd')">
<template #prefix>
<CalendarIcon class="size-3" />
</template>
</Badge>
<Badge v-if="call.status == 'Completed'" :label="call._duration">
<template #prefix>
<DurationIcon class="size-3" />
</template>
</Badge>
<Badge
v-if="call.recording_url"
:label="call.show_recording ? __('Hide Recording') : __('Listen')"
class="cursor-pointer"
@click.stop="call.show_recording = !call.show_recording"
>
<template #prefix>
<PlayIcon class="size-3" />
</template>
</Badge>
<Badge
:label="getCallStatusLabel(call.status, call.type)"
:theme="statusColorMap[call.status]"
/>
</div>
<div
v-if="
call.show_recording &&
call.recording_url &&
callLog?.data?.recording_url_path
"
class="flex flex-col items-center justify-between"
@click.stop
>
<AudioPlayer :src="callLog.data.recording_url_path" />
</div>
</div>
<CallLogDetailModal
v-model="showCallLogDetailModal"
v-model:callLog="callLog"
/>
</div>
</template>
<script setup>
import PlayIcon from '@/components/Icons/PlayIcon.vue'
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import DurationIcon from '@/components/Icons/DurationIcon.vue'
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import AudioPlayer from '@/components/Activities/AudioPlayer.vue'
import CallLogDetailModal from '@/components/Modals/CallLogDetailModal.vue'
import TimelineTimestamp from '@/components/Activities/TimelineTimestamp.vue'
import { getCallStatusLabel, statusColorMap } from '@/utils/callLog.js'
import { formatDate } from '@/utils'
import { Avatar, Badge, createResource } from 'frappe-ui'
import { reactive, ref } from 'vue'
const props = defineProps({
activity: { type: Object, default: () => ({}) },
})
const call = reactive(props.activity)
const callLog = createResource({
url: 'crm.fcrm.doctype.crm_call_log.crm_call_log.get_call_log',
params: { name: call.name },
cache: ['call_log', call.name],
auto: true,
})
const showCallLogDetailModal = ref(false)
</script>

View File

@@ -0,0 +1,147 @@
<template>
<div :id="activity.name">
<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">
<UserAvatar class="mr-1" :user="activity.owner" size="md" />
<span class="font-medium text-ink-gray-8">
{{ activity.owner_name }}
</span>
<span>{{ __('added a') }}</span>
<span class="max-w-xs truncate font-medium text-ink-gray-8">
{{ __('comment') }}
</span>
</div>
<div class="ml-auto flex items-center gap-1 whitespace-nowrap">
<TimelineTimestamp :date="activity.creation" />
<Dropdown
v-if="isOwner && !editing"
:options="menuOptions"
placement="right"
@click="confirmingDelete = false"
>
<Button
icon="lucide-more-horizontal"
variant="ghost"
class="!h-6 !w-6"
/>
</Dropdown>
</div>
</div>
<div
class="rounded bg-surface-gray-1 px-3 py-[7.5px] text-base leading-6 transition-all duration-300 ease-in-out"
>
<template v-if="editing">
<RichTextField
:content="editContent"
editor-class="prose-sm max-w-none min-h-[3rem]"
@change="editContent = $event"
/>
<div class="mt-2 flex justify-end gap-2">
<Button :label="__('Cancel')" @click="cancelEdit" />
<Button
variant="solid"
:label="__('Save')"
:loading="saving"
@click="saveEdit"
/>
</div>
</template>
<template v-else>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="prose-f" v-html="sanitizeHTML(activity.content)" />
<div
v-if="activity.attachments?.length"
class="mt-2 flex flex-wrap gap-2"
>
<AttachmentItem
v-for="a in activity.attachments"
:key="a.file_url"
:label="a.file_name"
:url="a.file_url"
/>
</div>
</template>
</div>
</div>
</template>
<script setup>
import UserAvatar from '@/components/UserAvatar.vue'
import AttachmentItem from '@/components/AttachmentItem.vue'
import RichTextField from '@/components/RichTextField.vue'
import { Dropdown, Button, call, toast } from 'frappe-ui'
import TimelineTimestamp from '@/components/Activities/TimelineTimestamp.vue'
import { sanitizeHTML, ConfirmDelete } from '@/utils'
import { sessionStore } from '@/stores/session'
import { computed, ref } from 'vue'
const props = defineProps({
activity: { type: Object, default: () => ({}) },
})
const emit = defineEmits(['reload'])
const { user } = sessionStore()
const isOwner = computed(() => props.activity.owner === user)
const editing = ref(false)
const saving = ref(false)
const editContent = ref('')
const confirmingDelete = ref(false)
const menuOptions = computed(() => [
{
label: __('Edit'),
icon: 'edit-2',
onClick: startEdit,
},
...ConfirmDelete({
onConfirmDelete: deleteComment,
isConfirmingDelete: confirmingDelete,
}),
])
function startEdit() {
editContent.value = props.activity.content || ''
editing.value = true
}
function cancelEdit() {
editing.value = false
editContent.value = ''
}
async function saveEdit() {
if (editContent.value === props.activity.content) {
editing.value = false
return
}
saving.value = true
try {
await call('frappe.client.set_value', {
doctype: 'Comment',
name: props.activity.name,
fieldname: 'content',
value: editContent.value,
})
editing.value = false
emit('reload')
} catch {
toast.error(__('Failed to update comment'))
} finally {
saving.value = false
}
}
async function deleteComment() {
try {
await call('frappe.client.delete', {
doctype: 'Comment',
name: props.activity.name,
})
emit('reload')
} catch {
toast.error(__('Failed to delete comment'))
}
}
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div
class="my-3 flex items-center justify-between text-lg-medium sm:mb-4 sm:mt-8"
>
<div class="flex h-8 items-center text-2xl-semibold text-ink-gray-8">
{{ __('Data') }}
<Badge
v-if="document.isDirty"
class="ml-3"
:label="__('Not Saved')"
theme="orange"
/>
</div>
<div class="flex gap-1">
<Button
v-if="isManager() && !isMobileView"
:tooltip="__('Edit Fields Layout')"
:icon="EditIcon"
@click="showDataFieldsModal = true"
/>
<Button
label="Save"
:disabled="!document.isDirty"
variant="solid"
:loading="document.save.loading"
@click="saveChanges"
/>
</div>
</div>
<div
v-if="document.get.loading"
class="flex flex-1 flex-col items-center justify-center gap-3 text-2xl-medium text-ink-gray-6"
>
<LoadingIndicator class="h-6 w-6" />
<span>{{ __('Loading...') }}</span>
</div>
<div v-else class="pb-8">
<FieldLayout
v-if="tabs.data"
v-model:tabIndex="fieldLayoutTabIndex"
v-model:tabName="fieldLayoutTabName"
:tabs="tabs.data"
:data="document.doc"
:doctype="doctype"
/>
</div>
<DataFieldsModal
v-if="showDataFieldsModal"
v-model="showDataFieldsModal"
:doctype="doctype"
@reload="
() => {
tabs.reload()
document.reload()
}
"
/>
</template>
<script setup>
import EditIcon from '@/components/Icons/EditIcon.vue'
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import { Badge, createResource } from 'frappe-ui'
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import { isMobileView } from '@/composables/settings'
import { ref, watch, getCurrentInstance, computed } from 'vue'
const props = defineProps({
doctype: { type: String, required: true },
docname: { type: String, required: true },
})
const emit = defineEmits(['beforeSave', 'afterSave'])
const fieldLayoutTabIndex = defineModel('fieldLayoutTabIndex', {
type: Number,
default: 0,
})
const fieldLayoutTabName = defineModel('fieldLayoutTabName', {
type: String,
default: '',
})
const { isManager } = usersStore()
const instance = getCurrentInstance()
const attrs = instance?.vnode?.props ?? {}
const showDataFieldsModal = ref(false)
const { document } = useDocument(props.doctype, props.docname)
const fieldLayoutTabStorageKey = computed(
() => `fieldLayoutTab:${props.doctype}:${props.docname}`,
)
watch(
fieldLayoutTabStorageKey,
(key) => {
const tabName = sessionStorage.getItem(key)
if (tabName && fieldLayoutTabName.value !== tabName) {
fieldLayoutTabName.value = tabName
}
},
{ immediate: true },
)
watch(fieldLayoutTabName, (tabName) => {
if (tabName) {
sessionStorage.setItem(fieldLayoutTabStorageKey.value, tabName)
} else {
sessionStorage.removeItem(fieldLayoutTabStorageKey.value)
}
})
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['DataFields', props.doctype],
params: { doctype: props.doctype, type: 'Data Fields' },
auto: true,
})
function saveChanges() {
if (!document.isDirty) return
const updatedDoc = { ...document.doc }
const oldDoc = { ...document.originalDoc }
const changes = Object.keys(updatedDoc).reduce((acc, key) => {
if (JSON.stringify(updatedDoc[key]) !== JSON.stringify(oldDoc[key])) {
acc[key] = updatedDoc[key]
}
return acc
}, {})
const hasListener = attrs['onBeforeSave'] !== undefined
if (hasListener) {
emit('beforeSave', changes)
} else {
document.save.submit(null, {
onSuccess: () => emit('afterSave', changes),
})
}
}
watch(
() => document.doc,
(newValue, oldValue) => {
if (!oldValue) return
if (newValue && oldValue) {
const isDirty =
JSON.stringify(newValue) !== JSON.stringify(document.originalDoc)
document.isDirty = isDirty
if (isDirty) {
document.save.loading = false
}
}
},
{ deep: true },
)
</script>

View File

@@ -0,0 +1,153 @@
<template>
<div
class="cursor-pointer flex flex-col rounded-md shadow-sm bg-surface-elevation-1 px-3 py-1.5 text-base transition-all duration-300 ease-in-out"
>
<div
class="-mb-0.5 flex items-center justify-between gap-2 truncate text-ink-gray-9"
>
<div class="flex items-center gap-2 truncate">
<span>{{ activity.data.sender_full_name }}</span>
<span class="sm:flex hidden text-sm text-ink-gray-5">
{{ '<' + activity.data.sender + '>' }}
</span>
<Badge
v-if="activity.communication_type == 'Automated Message'"
:label="__('Notification')"
variant="subtle"
theme="green"
/>
</div>
<div class="flex items-center gap-2 shrink-0">
<Badge
v-if="status.label"
:label="__(status.label)"
variant="subtle"
:theme="status.color"
/>
<TimelineTimestamp :date="activity.communication_date" />
<div class="flex gap-0.5">
<Button
:tooltip="__('Reply')"
variant="ghost"
class="text-ink-gray-7"
:icon="ReplyIcon"
@click="reply(activity.data)"
/>
<Button
:tooltip="__('Reply All')"
variant="ghost"
:icon="ReplyAllIcon"
class="text-ink-gray-7"
@click="reply(activity.data, true)"
/>
</div>
</div>
</div>
<div class="flex flex-col gap-1 text-base leading-5 text-ink-gray-8">
<div>{{ activity.data.subject }}</div>
<div>
<span class="mr-1 text-ink-gray-5"> {{ __('To') }}: </span>
<span>{{ activity.data.recipients }}</span>
<span v-if="activity.data.cc">, </span>
<span v-if="activity.data.cc" class="mr-1 text-ink-gray-5">
{{ __('CC') }}:
</span>
<span v-if="activity.data.cc">{{ activity.data.cc }}</span>
<span v-if="activity.data.bcc">, </span>
<span v-if="activity.data.bcc" class="mr-1 text-ink-gray-5">
{{ __('BCC') }}:
</span>
<span v-if="activity.data.bcc">{{ activity.data.bcc }}</span>
</div>
</div>
<div class="border-0 border-t mt-3 mb-1 border-outline-elevation-2" />
<EmailContent :content="activity.data.content" />
<div v-if="activity.data?.attachments?.length" class="flex flex-wrap gap-2">
<AttachmentItem
v-for="a in activity.data.attachments"
:key="a.file_url"
:label="a.file_name"
:url="a.file_url"
/>
</div>
</div>
</template>
<script setup>
import ReplyIcon from '@/components/Icons/ReplyIcon.vue'
import ReplyAllIcon from '@/components/Icons/ReplyAllIcon.vue'
import AttachmentItem from '@/components/AttachmentItem.vue'
import EmailContent from '@/components/Activities/EmailContent.vue'
import { Badge } from 'frappe-ui'
import TimelineTimestamp from '@/components/Activities/TimelineTimestamp.vue'
import { reactive, computed } from 'vue'
const props = defineProps({
activity: { type: Object, default: () => ({}) },
emailBox: { type: Object, default: () => ({}) },
})
const emailBox = reactive(props.emailBox)
function reply(email, reply_all = false) {
emailBox.show = true
let editor = emailBox.editor
let message = email.content
let recipients = email.recipients.split(',').map((r) => r.trim())
editor.fromEmail = email.sender
editor.toEmails = [email.sender]
editor.cc = editor.bcc = false
editor.ccEmails = []
editor.bccEmails = []
if (!email.subject.startsWith('Re:')) {
editor.subject = `Re: ${email.subject}`
} else {
editor.subject = email.subject
}
if (reply_all) {
let cc = email.cc?.split(',').map((r) => r.trim())
let bcc = email.bcc?.split(',').map((r) => r.trim())
if (cc?.length) {
recipients = recipients.filter((r) => !cc?.includes(r))
cc.push(...recipients)
} else {
cc = recipients
}
editor.cc = cc ? true : false
editor.bcc = bcc ? true : false
editor.ccEmails = cc
editor.bccEmails = bcc
}
let repliedMessage = `<blockquote>${message}</blockquote>`
editor.editor
.chain()
.clearContent()
.updateAttributes('paragraph', { class: 'reply-to-content' })
.insertContent(repliedMessage)
.focus('all')
.insertContentAt(0, { type: 'paragraph' })
.focus('start')
.run()
}
const status = computed(() => {
let _status = props.activity?.data?.delivery_status
let indicator_color = 'red'
if (['Sent', 'Clicked'].includes(_status)) {
indicator_color = 'green'
} else if (['Sending', 'Scheduled'].includes(_status)) {
indicator_color = 'orange'
} else if (['Opened', 'Read'].includes(_status)) {
indicator_color = 'blue'
} else if (_status == 'Error') {
indicator_color = 'red'
}
return { label: _status, color: indicator_color }
})
</script>

View File

@@ -0,0 +1,138 @@
<template>
<iframe
ref="iframeRef"
:srcdoc="htmlContent"
class="prose-f block h-10 max-h-[500px] w-full"
/>
</template>
<script setup>
import { ref, watch } from 'vue'
import emailContentStyles from './emailContent.css?inline'
const props = defineProps({
content: { type: String, required: true },
})
const iframeRef = ref(null)
const _content = ref(props.content)
const parser = new DOMParser()
const doc = parser.parseFromString(_content.value, 'text/html')
const gmailReplyToContent = doc.querySelectorAll('div.gmail_quote')
const outlookReplyToContent = doc.querySelectorAll('div#appendonsend')
const replyToContent = doc.querySelectorAll('p.reply-to-content')
if (gmailReplyToContent.length) {
_content.value = parseReplyToContent(doc, 'div.gmail_quote', true)
} else if (outlookReplyToContent.length) {
_content.value = parseReplyToContent(doc, 'div#appendonsend')
} else if (replyToContent.length) {
_content.value = parseReplyToContent(doc, 'p.reply-to-content')
}
function parseReplyToContent(doc, selector, forGmail = false) {
function handleAllInstances(doc) {
const replyToContentElements = doc.querySelectorAll(selector)
if (replyToContentElements.length === 0) return
const replyToContentElement = replyToContentElements[0]
replaceReplyToContent(replyToContentElement, forGmail)
handleAllInstances(doc)
}
handleAllInstances(doc)
return doc.body.innerHTML
}
function replaceReplyToContent(replyToContentElement, forGmail) {
if (!replyToContentElement) return
let randomId = Math.random().toString(36).substring(2, 7)
const wrapper = doc.createElement('div')
wrapper.classList.add('replied-content')
const collapseLabel = doc.createElement('label')
collapseLabel.classList.add('collapse')
collapseLabel.setAttribute('for', randomId)
collapseLabel.innerHTML = '...'
wrapper.appendChild(collapseLabel)
const collapseInput = doc.createElement('input')
collapseInput.setAttribute('id', randomId)
collapseInput.setAttribute('class', 'replyCollapser')
collapseInput.setAttribute('type', 'checkbox')
wrapper.appendChild(collapseInput)
if (forGmail) {
const prevSibling = replyToContentElement.previousElementSibling
if (prevSibling && prevSibling.tagName === 'BR') {
prevSibling.remove()
}
let cloned = replyToContentElement.cloneNode(true)
cloned.classList.remove('gmail_quote')
wrapper.appendChild(cloned)
} else {
const allSiblings = Array.from(replyToContentElement.parentElement.children)
const replyToContentIndex = allSiblings.indexOf(replyToContentElement)
const followingSiblings = allSiblings.slice(replyToContentIndex + 1)
if (followingSiblings.length === 0) return
let clonedFollowingSiblings = followingSiblings.map((sibling) =>
sibling.cloneNode(true),
)
const div = doc.createElement('div')
div.append(...clonedFollowingSiblings)
wrapper.append(div)
// Remove all siblings after the reply-to-content element
for (let i = replyToContentIndex + 1; i < allSiblings.length; i++) {
replyToContentElement.parentElement.removeChild(allSiblings[i])
}
}
replyToContentElement.parentElement.replaceChild(
wrapper,
replyToContentElement,
)
}
const htmlContent = `
<!DOCTYPE html>
<html>
<head>
<style>${emailContentStyles}</style>
</head>
<body>
<div ref="emailContentRef" class="email-content prose-f">${_content.value}</div>
</body>
</html>
`
watch(iframeRef, (iframe) => {
if (iframe) {
iframe.onload = () => {
const emailContent =
iframe.contentWindow.document.querySelector('.email-content')
let parent = emailContent.closest('html')
let theme = document.documentElement.getAttribute('data-theme')
parent.setAttribute('data-theme', theme)
iframe.style.height = parent.offsetHeight + 1 + 'px'
let replyCollapsers = emailContent.querySelectorAll('.replyCollapser')
if (replyCollapsers.length) {
replyCollapsers.forEach((replyCollapser) => {
replyCollapser.addEventListener('change', () => {
iframe.style.height = parent.offsetHeight + 1 + 'px'
})
})
}
}
}
})
</script>

View File

@@ -0,0 +1,88 @@
<template>
<div
class="activity group flex h-48 cursor-pointer flex-col justify-between gap-2 rounded-md bg-surface-gray-1 px-4 py-3 hover:bg-surface-gray-2"
>
<div class="flex items-center justify-between">
<div class="truncate text-lg-medium text-ink-gray-8">
{{ note.title }}
</div>
<Dropdown
:options="[
{
label: __('Edit'),
icon: 'edit-2',
onClick: () => modalRef.showNote(note),
},
{
label: __('Delete'),
icon: 'trash-2',
onClick: () => deleteNote(note.name),
},
]"
class="h-6 w-6"
@click.stop
>
<Button
icon="lucide-more-horizontal"
variant="ghost"
class="!h-6 !w-6 hover:bg-surface-gray-2"
@click.stop.prevent
/>
</Dropdown>
</div>
<!-- content is passed through sanitizeHTML() (DOMPurify) before rendering, so v-html is safe here -->
<!-- eslint-disable vue/no-v-html -->
<div
v-if="note.content"
class="prose-f prose-sm text-p-sm max-w-none text-ink-gray-5 flex-1 overflow-hidden"
v-html="sanitizeHTML(note.content)"
/>
<!-- eslint-enable vue/no-v-html -->
<div class="mt-1 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 truncate">
<UserAvatar :user="note.owner" size="xs" />
<div
class="truncate text-sm text-ink-gray-8"
:title="getUser(note.owner).full_name"
>
{{ getUser(note.owner).full_name }}
</div>
</div>
<TimelineTimestamp
:date="note.modified"
class-name="truncate text-sm text-ink-gray-7"
/>
</div>
</div>
</template>
<script setup>
import UserAvatar from '@/components/UserAvatar.vue'
import TimelineTimestamp from '@/components/Activities/TimelineTimestamp.vue'
import { Dropdown, call, toast } from 'frappe-ui'
import { usersStore } from '@/stores/users'
import { sanitizeHTML } from '@/utils'
defineProps({
note: { type: Object, default: () => ({}) },
modalRef: { type: Object, default: () => ({}) },
})
const notes = defineModel({ type: Object })
const { getUser } = usersStore()
async function deleteNote(name) {
await toast.promise(
call('frappe.client.delete', {
doctype: 'FCRM Note',
name,
}),
{
loading: __('Deleting note...'),
success: __('Note deleted'),
error: __('Failed to delete note'),
},
)
notes.value?.reload()
}
</script>

View File

@@ -0,0 +1,21 @@
<template>
<div>
<Button
class="flex justify-between w-full rounded text-base"
variant="ghost"
:label="label"
@click="onClick"
>
<template v-if="active" #suffix>
<span class="lucide-check size-4" aria-hidden="true" />
</template>
</Button>
</div>
</template>
<script setup>
defineProps({
label: { type: String, default: '' },
active: { type: Boolean, default: false },
onClick: { type: Function, default: () => {} },
})
</script>

View File

@@ -0,0 +1,111 @@
<template>
<div v-if="tasks.length">
<div v-for="(task, i) in tasks" :key="task.name">
<div
class="activity flex cursor-pointer gap-6 rounded p-2.5 duration-300 ease-in-out hover:bg-surface-gray-1"
@click="modalRef.showTask(task)"
>
<div class="flex flex-1 flex-col gap-1.5 text-base truncate">
<div class="font-medium text-ink-gray-9 truncate">
{{ task.title }}
</div>
<div class="flex gap-1.5 text-ink-gray-8">
<div class="flex items-center gap-1.5">
<UserAvatar :user="task.assigned_to" size="xs" />
{{ getUser(task.assigned_to).full_name }}
</div>
<div v-if="task.due_date" class="flex items-center justify-center">
<DotIcon class="h-2.5 w-2.5 text-ink-gray-5" :radius="2" />
</div>
<div v-if="task.due_date">
<Tooltip
:text="formatDate(task.due_date, 'ddd, MMM D, YYYY | hh:mm a')"
>
<div class="flex gap-2">
<CalendarIcon />
<div>{{ formatDate(task.due_date, 'D MMM, hh:mm a') }}</div>
</div>
</Tooltip>
</div>
<div class="flex items-center justify-center">
<DotIcon class="h-2.5 w-2.5 text-ink-gray-5" :radius="2" />
</div>
<div class="flex gap-2">
<TaskPriorityIcon class="!h-2 !w-2" :priority="task.priority" />
{{ task.priority }}
</div>
</div>
</div>
<div class="flex items-center gap-1">
<Dropdown
:options="taskStatusOptions(modalRef.updateTaskStatus, task)"
>
<Button
:tooltip="__('Change Status')"
variant="ghost"
class="hover:bg-surface-gray-4"
@click.stop.prevent
>
<TaskStatusIcon :status="task.status" />
</Button>
</Dropdown>
<Dropdown
:options="[
{
label: __('Delete'),
icon: 'trash-2',
onClick: () => {
$dialog({
title: __('Delete Task'),
message: __('Are you sure you want to delete this task?'),
actions: [
{
label: __('Delete'),
theme: 'red',
variant: 'solid',
onClick(close) {
modalRef.deleteTask(task.name)
close()
},
},
],
})
},
},
]"
>
<Button
icon="lucide-more-horizontal"
variant="ghost"
class="hover:bg-surface-gray-4 text-ink-gray-9"
@click.stop.prevent
/>
</Dropdown>
</div>
</div>
<div
v-if="i < tasks.length - 1"
class="mx-2 h-px border-t border-outline-elevation-2"
/>
</div>
</div>
</template>
<script setup>
import CalendarIcon from '@/components/Icons/CalendarIcon.vue'
import TaskStatusIcon from '@/components/Icons/TaskStatusIcon.vue'
import TaskPriorityIcon from '@/components/Icons/TaskPriorityIcon.vue'
import DotIcon from '@/components/Icons/DotIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import { formatDate, taskStatusOptions } from '@/utils'
import { usersStore } from '@/stores/users'
import { globalStore } from '@/stores/global'
import { Tooltip, Dropdown } from 'frappe-ui'
defineProps({
tasks: { type: Array, default: () => [] },
modalRef: { type: Object, default: () => ({}) },
})
const { getUser } = usersStore()
const { $dialog } = globalStore()
</script>

View File

@@ -0,0 +1,31 @@
<template>
<Tooltip :text="tooltipText">
<div :class="className">{{ displayText }}</div>
</Tooltip>
</template>
<script setup>
import { Tooltip } from 'frappe-ui'
import { timeAgo, formatDate } from '@/utils'
import { useTimelinePreferences } from '@/composables/useTimelinePreferences'
import { computed } from 'vue'
const props = defineProps({
date: { type: [String, Object], default: '' },
// Format used for the exact timestamp (falls back to the default in formatDate)
format: { type: String, default: '' },
className: { type: String, default: 'text-sm text-ink-gray-5' },
})
const { showExactTimestamp } = useTimelinePreferences()
const relative = computed(() => __(timeAgo(props.date)))
const exact = computed(() => formatDate(props.date, props.format || undefined))
const displayText = computed(() =>
showExactTimestamp.value ? exact.value : relative.value,
)
const tooltipText = computed(() =>
showExactTimestamp.value ? relative.value : exact.value,
)
</script>

View File

@@ -0,0 +1,289 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div>
<div
v-for="whatsapp in messages"
:key="whatsapp.name"
class="activity group flex gap-2"
:class="[
whatsapp.type == 'Outgoing' ? 'flex-row-reverse' : '',
whatsapp.reaction ? 'mb-7' : 'mb-3',
]"
>
<div
:id="whatsapp.name"
class="group/message relative max-w-[90%] rounded-md bg-surface-gray-1 text-ink-gray-9 p-1.5 pl-2 text-base shadow-sm"
>
<Badge
v-if="whatsapp.status == 'failed'"
theme="red"
:label="whatsapp.status"
class="absolute -top-2 right-0"
/>
<div
v-if="whatsapp.is_reply"
class="mb-1 cursor-pointer rounded border-0 border-l-4 bg-surface-gray-3 p-2 text-ink-gray-5"
:class="
whatsapp.reply_to_type == 'Incoming'
? 'border-green-500'
: 'border-blue-400'
"
@click="() => scrollToMessage(whatsapp.reply_to)"
>
<div
class="mb-1 text-sm-bold"
:class="
whatsapp.reply_to_type == 'Incoming'
? 'text-ink-green-5'
: 'text-ink-blue-link'
"
>
{{ whatsapp.reply_to_from || __('You') }}
</div>
<div class="flex flex-col gap-2 max-h-12 overflow-hidden">
<div v-if="whatsapp.header" class="text-base-semibold">
{{ whatsapp.header }}
</div>
<div v-html="formatWhatsAppMessage(whatsapp.reply_message)" />
<div v-if="whatsapp.footer" class="text-xs text-ink-gray-5">
{{ whatsapp.footer }}
</div>
</div>
</div>
<div class="flex gap-2 justify-between">
<div
v-if="whatsapp.status != 'failed'"
class="absolute -right-0.5 -top-0.5 flex cursor-pointer gap-1 rounded-full bg-surface-base pb-2 pl-2 pr-1.5 pt-1.5 opacity-0 group-hover/message:opacity-100"
:style="{
background:
'radial-gradient(circle at 50% 50%, rgba(255, 255, 255, 1) 0%, rgba(255, 255, 255, 1) 35%, rgba(238, 130, 238, 0) 100%)',
}"
>
<Dropdown :options="messageOptions(whatsapp)">
<span
class="lucide-chevron-down size-4 text-ink-gray-5"
aria-hidden="true"
/>
</Dropdown>
</div>
<div
v-if="whatsapp.reaction"
class="absolute -bottom-5 flex gap-1 rounded-full border bg-surface-base p-1 pb-[3px] shadow-sm"
>
<div class="flex size-4 items-center justify-center">
{{ whatsapp.reaction }}
</div>
</div>
<div
v-if="whatsapp.message_type == 'Template'"
class="flex flex-col gap-2"
>
<div v-if="whatsapp.header" class="text-base-semibold">
{{ whatsapp.header }}
</div>
<div v-html="formatWhatsAppMessage(whatsapp.template)" />
<div v-if="whatsapp.footer" class="text-xs text-ink-gray-5">
{{ whatsapp.footer }}
</div>
</div>
<div
v-else-if="whatsapp.content_type == 'text'"
v-html="formatWhatsAppMessage(whatsapp.message)"
/>
<div
v-else-if="whatsapp.content_type == 'button'"
v-html="formatWhatsAppMessage(whatsapp.message)"
/>
<div v-else-if="whatsapp.content_type == 'image'">
<img
:src="whatsapp.attach"
class="h-40 cursor-pointer rounded-md"
@click="() => openFileInAnotherTab(whatsapp.attach)"
/>
<div
v-if="!whatsapp.message.startsWith('/files/')"
class="mt-1.5"
v-html="formatWhatsAppMessage(whatsapp.message)"
/>
</div>
<div
v-else-if="whatsapp.content_type == 'document'"
class="flex items-center gap-2"
>
<DocumentIcon
class="size-10 cursor-pointer rounded-md text-ink-gray-4"
@click="() => openFileInAnotherTab(whatsapp.attach)"
/>
<div class="text-ink-gray-5">Document</div>
</div>
<div
v-else-if="whatsapp.content_type == 'audio'"
class="flex items-center gap-2"
>
<audio :src="whatsapp.attach" controls class="cursor-pointer" />
</div>
<div
v-else-if="whatsapp.content_type == 'video'"
class="flex-col items-center gap-2"
>
<video
:src="whatsapp.attach"
controls
class="h-40 cursor-pointer rounded-md"
/>
<div
v-if="!whatsapp.message.startsWith('/files/')"
class="mt-1.5"
v-html="formatWhatsAppMessage(whatsapp.message)"
/>
</div>
<div class="-mb-1 flex shrink-0 items-end gap-1 text-ink-gray-5">
<Tooltip :text="formatDate(whatsapp.creation, 'ddd, MMM D, YYYY')">
<div class="text-2xs">
{{ formatDate(whatsapp.creation, 'hh:mm a') }}
</div>
</Tooltip>
<div v-if="whatsapp.type == 'Outgoing'">
<CheckIcon
v-if="['sent', 'Success'].includes(whatsapp.status)"
class="size-4"
/>
<DoubleCheckIcon
v-else-if="['read', 'delivered'].includes(whatsapp.status)"
class="size-4"
:class="{ 'text-ink-blue-5': whatsapp.status == 'read' }"
/>
</div>
</div>
</div>
</div>
<div
v-if="whatsapp.status != 'failed'"
class="flex items-center justify-center opacity-0 transition-all ease-in group-hover:opacity-100"
>
<IconPicker
v-slot="{ togglePopover }"
v-model="emoji"
v-model:reaction="reaction"
@update:modelValue="() => reactOnMessage(whatsapp.name, emoji)"
>
<Button
class="rounded-full !size-6 mt-0.5"
@click="() => (reaction = true) && togglePopover()"
>
<template #icon>
<ReactIcon class="text-ink-gray-3" />
</template>
</Button>
</IconPicker>
</div>
</div>
</div>
</template>
<script setup>
import IconPicker from '@/components/IconPicker.vue'
import CheckIcon from '@/components/Icons/CheckIcon.vue'
import DoubleCheckIcon from '@/components/Icons/DoubleCheckIcon.vue'
import DocumentIcon from '@/components/Icons/DocumentIcon.vue'
import ReactIcon from '@/components/Icons/ReactIcon.vue'
import { formatDate, sanitizeHTML } from '@/utils'
import { useTelemetry } from 'frappe-ui/frappe'
import { Tooltip, Dropdown, createResource, toast } from 'frappe-ui'
import { ref } from 'vue'
defineProps({
messages: { type: Array, default: () => [] },
})
const list = defineModel({ type: Object })
const { capture } = useTelemetry()
function openFileInAnotherTab(url) {
window.open(url, '_blank')
}
function formatWhatsAppMessage(message) {
// if message contains _text_, make it italic
message = message.replace(/_(.*?)_/g, '<i>$1</i>')
// if message contains *text*, make it bold
message = message.replace(/\*(.*?)\*/g, '<b>$1</b>')
// if message contains ~text~, make it strikethrough
message = message.replace(/~(.*?)~/g, '<s>$1</s>')
// if message contains ```text```, make it monospace
message = message.replace(/```(.*?)```/g, '<code>$1</code>')
// if message contains `text`, make it inline code
message = message.replace(/`(.*?)`/g, '<code>$1</code>')
// if message contains > text, make it a blockquote
message = message.replace(/^> (.*)$/gm, '<blockquote>$1</blockquote>')
// if contain /n, make it a new line
message = message.replace(/\n/g, '<br>')
// if contains *<space>text, make it a bullet point
message = message.replace(/\* (.*?)(?=\s*\*|$)/g, '<li>$1</li>')
message = message.replace(/- (.*?)(?=\s*-|$)/g, '<li>$1</li>')
message = message.replace(/(\d+)\. (.*?)(?=\s*(\d+)\.|$)/g, '<li>$2</li>')
return sanitizeHTML(message)
}
const emoji = ref('')
const reaction = ref(true)
function reactOnMessage(name, emoji) {
createResource({
url: 'crm.api.whatsapp.react_on_whatsapp_message',
params: {
emoji,
reply_to_name: name,
},
auto: true,
onSuccess() {
capture('whatsapp_react_on_message')
list.value.reload()
},
onError(error) {
toast.error(
error.messages?.[0] || __('Failed to add reaction to the message'),
)
},
})
}
const reply = defineModel('reply', { type: Object, default: () => ({}) })
const replyMode = ref(false)
function messageOptions(message) {
return [
{
label: 'Reply',
onClick: () => {
replyMode.value = true
reply.value = {
...message,
message: formatWhatsAppMessage(message.message),
}
},
},
// {
// label: 'Forward',
// onClick: () => console.log('Forward'),
// },
// {
// label: 'Delete',
// onClick: () => console.log('Delete'),
// },
]
}
function scrollToMessage(name) {
const element = document.getElementById(name)
element.scrollIntoView({ behavior: 'smooth' })
// Highlight the message
element.classList.add('bg-yellow-100')
setTimeout(() => {
element.classList.remove('bg-yellow-100')
}, 1000)
}
</script>

View File

@@ -0,0 +1,185 @@
<!-- eslint-disable vue/no-v-html -->
<template>
<div
v-if="reply?.message"
class="flex items-center justify-around gap-2 px-3 pt-2 sm:px-10"
>
<div
class="mb-1 ml-13 flex-1 cursor-pointer rounded border-0 border-l-4 border-green-500 bg-surface-gray-2 p-2 text-base text-ink-gray-5"
:class="reply.type == 'Incoming' ? 'border-green-500' : 'border-blue-400'"
>
<div
class="mb-1 text-sm-bold"
:class="
reply.type == 'Incoming' ? 'text-ink-green-5' : 'text-ink-blue-link'
"
>
{{ reply.from_name || __('You') }}
</div>
<div
class="max-h-12 overflow-hidden"
v-html="sanitizeHTML(reply.message)"
/>
</div>
<Button variant="ghost" icon="lucide-x" @click="reply = {}" />
</div>
<div class="flex items-end gap-2 px-3 py-2.5 sm:px-10" v-bind="$attrs">
<div class="flex h-8 items-center gap-2">
<FileUploader @success="(file) => uploadFile(file)">
<template #default="{ openFileSelector }">
<div class="flex items-center space-x-2">
<Dropdown :options="uploadOptions(openFileSelector)">
<span
class="lucide-plus size-4.5 cursor-pointer text-ink-gray-5"
aria-hidden="true"
/>
</Dropdown>
</div>
</template>
</FileUploader>
<IconPicker
v-slot="{ togglePopover }"
v-model="emoji"
@update:modelValue="
() => {
content += emoji
$refs.textareaRef.el.focus()
capture('whatsapp_emoji_added')
}
"
>
<SmileIcon
class="flex size-4.5 cursor-pointer rounded-sm text-2xl leading-none text-ink-gray-4"
@click="togglePopover"
/>
</IconPicker>
</div>
<Textarea
ref="textareaRef"
v-model="content"
type="textarea"
class="min-h-8 w-full"
:rows="rows"
:placeholder="placeholder"
@focus="rows = 6"
@blur="rows = 1"
@keydown.enter.stop="(e) => sendTextMessage(e)"
/>
</div>
</template>
<script setup>
import IconPicker from '@/components/IconPicker.vue'
import SmileIcon from '@/components/Icons/SmileIcon.vue'
import { sanitizeHTML } from '@/utils'
import { useTelemetry } from 'frappe-ui/frappe'
import {
createResource,
Textarea,
FileUploader,
Dropdown,
toast,
} from 'frappe-ui'
import { ref, nextTick, watch } from 'vue'
const props = defineProps({
doctype: { type: String, default: '' },
})
const doc = defineModel({ type: Object, default: () => ({}) })
const whatsapp = defineModel('whatsapp', { type: Object, default: () => ({}) })
const reply = defineModel('reply', { type: Object, default: () => ({}) })
const { capture } = useTelemetry()
const rows = ref(1)
const textareaRef = ref(null)
const emoji = ref('')
const content = ref('')
const placeholder = ref(__('Type your message here...'))
const fileType = ref('')
function show() {
nextTick(() => textareaRef.value.el.focus())
}
function uploadFile(file) {
whatsapp.value.attach = file.file_url
whatsapp.value.content_type = fileType.value
sendWhatsAppMessage()
capture('whatsapp_upload_file')
}
function sendTextMessage(event) {
if (event.shiftKey) return
sendWhatsAppMessage()
textareaRef.value.el?.blur()
content.value = ''
capture('whatsapp_send_message')
}
async function sendWhatsAppMessage() {
let args = {
reference_doctype: props.doctype,
reference_name: doc.value.name,
message: content.value,
to: doc.value.mobile_no,
attach: whatsapp.value.attach || '',
reply_to: reply.value?.name || '',
content_type: whatsapp.value.content_type,
}
content.value = ''
fileType.value = ''
whatsapp.value.attach = ''
whatsapp.value.content_type = 'text'
reply.value = {}
createResource({
url: 'crm.api.whatsapp.create_whatsapp_message',
params: args,
auto: true,
onSuccess: () => whatsapp.value.reload(),
onError: (error) => {
toast.error(error.messages?.[0] || __('Failed to send WhatsApp message'))
},
})
}
function uploadOptions(openFileSelector) {
return [
{
label: __('Upload Document'),
icon: 'file',
onClick: () => {
fileType.value = 'document'
openFileSelector()
},
},
{
label: __('Upload Image'),
icon: 'image',
onClick: () => {
fileType.value = 'image'
openFileSelector('image/*')
},
},
{
label: __('Upload Video'),
icon: 'video',
onClick: () => {
fileType.value = 'video'
openFileSelector('video/*')
},
},
]
}
watch(reply, (value) => {
if (value?.message) {
show()
}
})
defineExpose({ show })
</script>

View File

@@ -0,0 +1,126 @@
@config '../../../tailwind.email.config.js';
@tailwind base;
@tailwind components;
@layer components {
.prose-f {
@apply break-all
max-w-none
prose
prose-code:break-all
prose-code:whitespace-pre-wrap
prose-img:border
prose-img:rounded-lg
prose-sm
prose-table:table-fixed
prose-td:border
prose-td:border-outline-gray-2
prose-td:p-2
prose-td:relative
prose-th:bg-surface-gray-2
prose-th:border
prose-th:border-outline-gray-2
prose-th:p-2
prose-th:relative;
}
}
:root {
--bg-surface-gray-3: #ededed;
--bg-surface-gray-4: #e2e2e2;
}
[data-theme='dark'] {
--bg-surface-gray-3: #343434;
--bg-surface-gray-4: #424242;
}
.replied-content .collapse {
display: flex;
visibility: visible;
width: 23px;
height: 12px;
margin: 10px 0;
cursor: pointer;
align-items: center;
justify-content: center;
border-radius: 5px;
background: var(--bg-surface-gray-3);
font-size: larger;
font-weight: 700;
line-height: 0.1;
}
.replied-content .collapse:hover {
background: var(--bg-surface-gray-4);
}
.replied-content .collapse + input,
.replied-content .collapse + input + div {
display: none;
}
.replied-content .collapse + input:checked + div {
display: block;
}
.email-content {
word-break: break-word;
}
.email-content
:is(:where(table):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
table-layout: auto;
}
.email-content
:where(table):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
width: unset;
margin-top: unset;
margin-bottom: unset;
table-layout: auto;
text-align: unset;
font-size: unset;
line-height: unset;
}
.email-content
:where(tbody tr):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
border-bottom-width: 0;
border-bottom-color: transparent;
}
.email-content
:is(:where(td):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
position: unset;
border-width: 0;
border-color: transparent;
padding: 0;
}
.email-content
:where(tbody td):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
vertical-align: revert;
}
.email-content
:is(:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *))) {
border-width: 0;
}
.email-content
:where(img):not(:where([class~='not-prose'], [class~='not-prose'] *)) {
margin: 0;
}
.email-content
:where(blockquote p:first-of-type):not(
:where([class~='not-prose'], [class~='not-prose'] *)
)::before,
.email-content
:where(blockquote p:last-of-type):not(
:where([class~='not-prose'], [class~='not-prose'] *)
)::after {
content: none;
}

View File

@@ -0,0 +1,99 @@
<template>
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<div class="flex items-center" @click="togglePopover">
<component
:is="assignees?.length == 1 ? 'Button' : 'div'"
v-if="assignees?.length"
>
<MultipleAvatar :avatars="assignees" />
</component>
<Button v-else :label="__('Assign To')" />
</div>
</template>
<template #body="{ isOpen }">
<AssignToBody
v-show="isOpen"
v-model="assignees"
:docname="docname"
:doctype="doctype"
:open="isOpen"
:onUpdate="ownerField && saveAssignees"
/>
</template>
</Popover>
</template>
<script setup>
import MultipleAvatar from '@/components/MultipleAvatar.vue'
import AssignToBody from '@/components/AssignToBody.vue'
import { useDocument } from '@/data/document'
import { toast, Popover } from 'frappe-ui'
import { computed } from 'vue'
const props = defineProps({
doctype: { type: String, default: '' },
docname: { type: String, default: '' },
})
const { document } = useDocument(props.doctype, props.docname)
const assignees = defineModel({ type: Array, default: () => [] })
const ownerField = computed(() => {
if (props.doctype === 'CRM Lead') {
return 'lead_owner'
} else if (props.doctype === 'CRM Deal') {
return 'deal_owner'
} else {
return null
}
})
async function saveAssignees(
addedAssignees,
removedAssignees,
addAssignees,
removeAssignees,
) {
if (removedAssignees.length) await removeAssignees.submit(removedAssignees)
if (addedAssignees.length) await addAssignees.submit(addedAssignees)
const nextAssignee = assignees.value.find(
(a) => a.name !== document.doc[ownerField.value],
)
let owner = ownerField.value.replace('_', ' ')
if (
document.doc[ownerField.value] &&
removedAssignees.includes(document.doc[ownerField.value])
) {
document.doc[ownerField.value] = nextAssignee ? nextAssignee.name : ''
document.save.submit()
if (nextAssignee) {
toast.info(
__(
'Since you removed {0} from the assignee, the {0} has been changed to the next available assignee {1}.',
[owner, nextAssignee.label || nextAssignee.name],
),
)
} else {
toast.info(
__(
'Since you removed {0} from the assignee, the {0} has also been removed.',
[owner],
),
)
}
} else if (!document.doc[ownerField.value] && nextAssignee) {
document.doc[ownerField.value] = nextAssignee ? nextAssignee.name : ''
toast.info(
__('Since you added a new assignee, the {0} has been set to {1}.', [
owner,
nextAssignee.label || nextAssignee.name,
]),
)
}
}
</script>

View File

@@ -0,0 +1,203 @@
<template>
<div
class="flex flex-col gap-2 my-2 w-[470px] rounded-lg bg-surface-elevation-2 shadow-2xl ring-1 ring-black p-3 ring-opacity-5 focus:outline-none"
>
<div class="text-base text-ink-gray-5">{{ __('Assign To') }}</div>
<Link
class="form-control"
value=""
doctype="User"
:placeholder="__('John Doe')"
:filters="{
name: ['in', users.data.crmUsers?.map((user) => user.name)],
ignore_user_type: 1,
}"
:hideMe="true"
@change="(option) => addValue(option) && ($refs.input.value = '')"
>
<template #target="{ togglePopover }">
<div
class="w-full min-h-12 flex flex-wrap items-center gap-1.5 p-1.5 pb-5 rounded-lg bg-surface-gray-2 cursor-text"
@click.stop="togglePopover"
>
<Tooltip
v-for="assignee in assignees"
:key="assignee.name"
:text="assignee.name"
@click.stop
>
<div
class="flex items-center text-sm p-0.5 text-ink-gray-6 border border-outline-gray-1 bg-surface-elevation-2 rounded-full cursor-pointer"
@click.stop
>
<UserAvatar :user="assignee.name" size="sm" />
<div class="ml-1">{{ getUser(assignee.name).full_name }}</div>
<Button
variant="ghost"
class="rounded-full !size-4 m-1"
@click.stop="removeValue(assignee.name)"
>
<template #icon>
<span
class="lucide-x h-3 w-3 text-ink-gray-6"
aria-hidden="true"
/>
</template>
</Button>
</div>
</Tooltip>
</div>
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer text-ink-gray-9">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
<div class="flex items-center justify-between gap-2">
<div
class="text-base text-ink-gray-5 cursor-pointer select-none"
@click="assignToMe = !assignToMe"
>
{{ __('Assign To Me') }}
</div>
<Switch v-model="assignToMe" @click.stop />
</div>
</div>
</template>
<script setup>
import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import { usersStore } from '@/stores/users'
import { Tooltip, Switch, createResource } from 'frappe-ui'
import { useTelemetry } from 'frappe-ui/frappe'
import { ref, watch } from 'vue'
const props = defineProps({
doctype: { type: String, default: '' },
docname: { type: String, default: '' },
open: { type: Boolean, default: false },
onUpdate: { type: Function, default: null },
})
const { capture } = useTelemetry()
const assignees = defineModel({ type: Array, default: () => [] })
const oldAssignees = ref([])
const assignToMe = ref(false)
const error = ref('')
const { users, getUser } = usersStore()
const removeValue = (value) => {
if (value === getUser('').name) {
assignToMe.value = false
}
assignees.value = assignees.value.filter(
(assignee) => assignee.name !== value,
)
}
const addValue = (value) => {
if (value === getUser('').name) {
assignToMe.value = true
}
error.value = ''
let obj = {
name: value,
image: getUser(value).user_image,
label: getUser(value).full_name,
}
if (!assignees.value.find((assignee) => assignee.name === value)) {
assignees.value.push(obj)
}
}
watch(assignToMe, (val) => {
let user = getUser('')
if (val) {
addValue(user.name)
} else {
removeValue(user.name)
}
})
watch(
() => props.open,
(val) => {
if (val) {
oldAssignees.value = [...(assignees.value || [])]
assignToMe.value = assignees.value.some(
(assignee) => assignee.name === getUser('').name,
)
} else {
updateAssignees()
}
},
{ immediate: true },
)
async function updateAssignees() {
if (JSON.stringify(oldAssignees.value) === JSON.stringify(assignees.value))
return
const removedAssignees = oldAssignees.value
.filter(
(assignee) => !assignees.value.find((a) => a.name === assignee.name),
)
.map((assignee) => assignee.name)
const addedAssignees = assignees.value
.filter(
(assignee) => !oldAssignees.value.find((a) => a.name === assignee.name),
)
.map((assignee) => assignee.name)
if (props.onUpdate) {
props.onUpdate(
addedAssignees,
removedAssignees,
addAssignees,
removeAssignees,
)
} else {
if (removedAssignees.length) {
await removeAssignees.submit(removedAssignees)
}
if (addedAssignees.length) {
addAssignees.submit(addedAssignees)
}
}
}
const addAssignees = createResource({
url: 'frappe.desk.form.assign_to.add',
makeParams: (addedAssignees) => ({
doctype: props.doctype,
name: props.docname,
assign_to: addedAssignees,
}),
onSuccess: () => {
capture('assign_to', { doctype: props.doctype })
},
})
const removeAssignees = createResource({
url: 'crm.api.doc.remove_assignments',
makeParams: (removedAssignees) => ({
doctype: props.doctype,
name: props.docname,
assignees: removedAssignees,
}),
})
</script>

View File

@@ -0,0 +1,68 @@
<template>
<span>
<a :href="isShowable ? null : url" target="_blank">
<Button
:label="label"
theme="gray"
variant="outline"
:iconLeft="getIcon()"
@click="toggleDialog()"
>
<template #suffix>
<slot name="suffix" />
</template>
</Button>
</a>
<Dialog v-model:open="showDialog" :title="label" :size="'4xl'">
<template #default>
<div
v-if="isText"
class="prose prose-sm max-w-none whitespace-pre-wrap"
>
{{ content }}
</div>
<img v-if="isImage" :src="url" class="m-auto rounded border" />
</template>
</Dialog>
</span>
</template>
<script setup>
import { ref } from 'vue'
import mime from 'mime'
import FileTypeIcon from '@/components/Icons/FileTypeIcon.vue'
import FileImageIcon from '@/components/Icons/FileImageIcon.vue'
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
import FileSpreadsheetIcon from '@/components/Icons/FileSpreadsheetIcon.vue'
import FileIcon from '@/components/Icons/FileIcon.vue'
const props = defineProps({
label: { type: String, default: null },
url: { type: String, default: null },
})
const showDialog = ref(false)
const mimeType = mime.getType(props.label) || ''
const isImage = mimeType.startsWith('image/')
const isPdf = mimeType === 'application/pdf'
const isSpreadsheet = mimeType.includes('spreadsheet')
const isText = mimeType === 'text/plain'
const isShowable = props.url && (isText || isImage)
const content = ref('')
function getIcon() {
if (isText) return FileTypeIcon
else if (isImage) return FileImageIcon
else if (isPdf) return FileTextIcon
else if (isSpreadsheet) return FileSpreadsheetIcon
else return FileIcon
}
function toggleDialog() {
if (!isShowable) return
if (isText) {
fetch(props.url).then((res) => res.text().then((t) => (content.value = t)))
}
showDialog.value = !showDialog.value
}
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div v-if="brand?.logo">
<img :src="brand.logo" class="h-full w-full object-cover" />
</div>
<CRMLogo v-else class="size-8 shrink-0 rounded" />
</template>
<script setup>
import CRMLogo from '@/components/Icons/CRMLogo.vue'
const brand = defineModel({ type: Object, default: () => ({}) })
</script>

View File

@@ -0,0 +1,146 @@
<template>
<Dialog v-model:open="show" :size="'xl'">
<template #body>
<div class="bg-surface-elevation-2 px-4 pb-6 pt-5 sm:px-6">
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-3xl-semibold leading-6 text-ink-gray-9">
{{ __('Delete') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" icon="lucide-x" @click="show = false" />
</div>
</div>
<div>
<div class="text-ink-gray-5 text-base">
{{
__('Are you sure you want to delete {0} items?', [
props.items?.length,
])
}}
</div>
</div>
</div>
<div class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
:label="__('Delete {0} items', [props.items.length])"
icon-left="lucide-trash-2"
variant="solid"
theme="red"
@click="confirmDelete()"
/>
<Button
:label="__('Unlink & Delete {0} items', [props.items.length])"
icon-left="lucide-unlock"
variant="solid"
@click="confirmUnlink()"
/>
</div>
</div>
<div
v-if="confirmDeleteInfo.show"
class="bg-surface-elevation-2 px-4 pb-6 pt-5 sm:px-6"
>
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-3xl-semibold leading-6 text-ink-gray-9">
{{ __('Delete') }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" icon="lucide-x" @click="show = false" />
</div>
</div>
<div>
<div class="text-ink-gray-5 text-base">
{{
confirmDeleteInfo.delete
? __(
'This will delete selected items and items linked to it, are you sure?',
)
: __(
'This will delete selected items and unlink linked items to it, are you sure?',
)
}}
</div>
</div>
</div>
<div v-if="confirmDeleteInfo.show" class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
:label="
confirmDeleteInfo.delete ? __('Delete') : __('Unlink & Delete')
"
:icon-left="confirmDeleteInfo.delete ? 'trash-2' : 'unlock'"
variant="solid"
theme="red"
@click="deleteDocs()"
/>
<Button
:label="__('Cancel')"
variant="subtle"
@click="confirmDeleteInfo.show = false"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { call } from 'frappe-ui'
import { ref } from 'vue'
const show = defineModel({ type: Boolean })
const props = defineProps({
doctype: { type: String, required: true },
items: { type: Array, required: true },
reload: { type: Function, required: true },
})
const confirmDeleteInfo = ref({
show: false,
title: '',
message: '',
delete: false,
})
const confirmDelete = () => {
confirmDeleteInfo.value = {
show: true,
title: __('Delete'),
message: __('Are you sure you want to delete {0} linked doc(s)?', [
props.items.length,
]),
delete: true,
}
}
const confirmUnlink = () => {
confirmDeleteInfo.value = {
show: true,
title: __('Unlink'),
message: __('Are you sure you want to unlink {0} linked doc(s)?', [
props.items.length,
]),
delete: false,
}
}
const deleteDocs = () => {
call('crm.api.doc.delete_bulk_docs', {
items: props.items,
doctype: props.doctype,
delete_linked: confirmDeleteInfo.value.delete,
}).then(() => {
confirmDeleteInfo.value = {
show: false,
title: '',
}
show.value = false
props.reload()
})
}
</script>

View File

@@ -0,0 +1,89 @@
<template>
<div>
<slot name="header" v-bind="{ opened, hide, open, close, toggle }">
<div
v-if="!hide"
class="section-header flex items-center justify-between"
:class="headerClass"
>
<div
class="flex text-ink-gray-9 max-w-fit cursor-pointer items-center gap-2 text-base"
:class="labelClass"
@click="collapsible && toggle()"
>
<span
v-if="collapsible && collapseIconPosition === 'left'"
class="lucide-chevron-right h-4 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
aria-hidden="true"
/>
<span>
{{ __(label) || __('Untitled') }}
</span>
<Badge
v-if="count"
:label="count"
:variant="countVariant"
:theme="countTheme"
/>
<span
v-if="collapsible && collapseIconPosition === 'right'"
class="lucide-chevron-right h-4 transition-all duration-300 ease-in-out"
:class="{ 'rotate-90': opened }"
aria-hidden="true"
/>
</div>
<slot name="actions"></slot>
</div>
</slot>
<transition
enter-active-class="duration-300 ease-in"
leave-active-class="duration-300 ease-[cubic-bezier(0, 1, 0.5, 1)]"
enter-to-class="max-h-[200px] overflow-hidden"
leave-from-class="max-h-[200px] overflow-hidden"
enter-from-class="max-h-0 overflow-hidden"
leave-to-class="max-h-0 overflow-hidden"
>
<div v-show="opened" class="columns" v-bind="$attrs">
<slot v-bind="{ opened, open, close, toggle }" />
</div>
</transition>
</div>
</template>
<script setup>
import { Badge } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
label: { type: String, default: '' },
count: { type: [String, Number], default: '' },
countVariant: { type: String, default: 'subtle' },
countTheme: { type: String, default: 'gray' },
hideLabel: { type: Boolean, default: false },
opened: { type: Boolean, default: true },
collapsible: { type: Boolean, default: true },
collapseIconPosition: { type: String, default: 'left' },
labelClass: { type: [String, Object, Array], default: '' },
headerClass: { type: [String, Object, Array], default: '' },
})
const hide = ref(props.hideLabel)
const opened = ref(props.opened)
function toggle() {
opened.value = !opened.value
}
function open() {
opened.value = true
}
function close() {
opened.value = false
}
</script>
<script>
export default {
inheritAttrs: false,
}
</script>

View File

@@ -0,0 +1,312 @@
<template>
<Popover placement="bottom-end">
<template #target="{ togglePopover }">
<Button :label="__('Columns')" @click="togglePopover">
<template v-if="hideLabel" #icon>
<ColumnsIcon class="h-4" />
</template>
<template v-if="!hideLabel" #prefix>
<ColumnsIcon class="h-4" />
</template>
</Button>
</template>
<template #body="{ close }">
<div
class="my-2 p-1.5 min-w-40 rounded-lg bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div v-if="!edit">
<Draggable
:list="columns"
:delay="isTouchScreenDevice() ? 200 : 0"
item-key="key"
class="list-group"
@end="apply"
>
<template #item="{ element }">
<div
class="flex cursor-grab items-center justify-between gap-6 rounded px-2 py-1.5 text-base text-ink-gray-8 hover:bg-surface-gray-2"
>
<div class="flex items-center gap-2">
<DragIcon class="h-3.5" />
<div>{{ __(element.label) }}</div>
</div>
<div class="flex cursor-pointer items-center gap-0.5">
<Button
variant="ghost"
class="!h-5 w-5 !p-1"
@click="editColumn(element)"
>
<template #icon>
<EditIcon class="h-3.5" />
</template>
</Button>
<Button
variant="ghost"
class="!h-5 w-5 !p-1"
@click="removeColumn(element)"
>
<template #icon>
<span class="lucide-x h-3.5" aria-hidden="true" />
</template>
</Button>
</div>
</div>
</template>
</Draggable>
<div
class="mt-1.5 flex flex-col gap-1 border-t border-outline-elevation-2 pt-1.5"
>
<Combobox
:model-value="null"
:options="fields"
@update:selected-option="(e) => addColumn(e)"
>
<template #trigger="{ open, setOpen }">
<Button
class="w-full !justify-start !text-ink-gray-5"
variant="ghost"
:label="__('Add Column')"
iconLeft="plus"
@click="setOpen(!open)"
/>
</template>
</Combobox>
<Button
v-if="columnsUpdated"
class="w-full !justify-start !text-ink-gray-5"
variant="ghost"
:label="__('Reset Changes')"
:iconLeft="ReloadIcon"
@click="reset(close)"
/>
<Button
v-if="!is_default"
class="w-full !justify-start !text-ink-gray-5"
variant="ghost"
:label="__('Reset to Default')"
:iconLeft="ReloadIcon"
@click="resetToDefault(close)"
/>
</div>
</div>
<div v-else>
<div
class="flex flex-col items-center justify-between gap-2 rounded px-2 py-1.5 text-base text-ink-gray-8"
>
<div class="flex flex-col items-center gap-3">
<FormControl
v-model="column.label"
type="text"
size="md"
:label="__('Label')"
class="sm:w-full w-52"
:placeholder="__('First Name')"
/>
<FormControl
v-model="column.width"
type="text"
size="md"
:label="__('Width')"
class="sm:w-full w-52"
placeholder="10rem"
:description="
__(
'Width can be in number, pixel or rem (eg. 3, 30px, 10rem)',
)
"
:debounce="500"
/>
</div>
<div class="flex w-full gap-2 border-t pt-2">
<Button
variant="subtle"
:label="__('Cancel')"
class="w-full flex-1"
@click="cancelUpdate"
/>
<Button
variant="solid"
:label="__('Update')"
class="w-full flex-1"
@click="updateColumn(column)"
/>
</div>
</div>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import ColumnsIcon from '@/components/Icons/ColumnsIcon.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import DragIcon from '@/components/Icons/DragIcon.vue'
import ReloadIcon from '@/components/Icons/ReloadIcon.vue'
import { isTouchScreenDevice } from '@/utils'
import { getMeta } from '@/stores/meta'
import { Combobox, Popover } from 'frappe-ui'
import Draggable from 'vuedraggable'
import { computed, ref } from 'vue'
import { watchOnce } from '@vueuse/core'
const props = defineProps({
doctype: { type: String, required: true },
hideLabel: { type: Boolean, default: false },
})
const emit = defineEmits(['update'])
const columnsUpdated = ref(false)
const oldValues = ref({
columns: [],
rows: [],
isDefault: false,
})
const list = defineModel({ type: Object, default: () => ({}) })
const edit = ref(false)
const column = ref({
old: {},
label: '',
key: '',
width: '10rem',
})
const is_default = computed({
get: () => list.value?.data?.is_default,
set: (val) => {
list.value.data.is_default = val
},
})
const columns = computed({
get: () => list.value?.data?.columns,
set: (val) => {
list.value.data.columns = val
},
})
const rows = computed({
get: () => list.value?.data?.rows,
set: (val) => {
list.value.data.rows = val
},
})
const { getFields } = getMeta(props.doctype)
const fields = computed(() => {
const _fields = getFields({ withStandardFields: true }) || []
if (!_fields.length) return []
let existingFields = []
if (columns.value.length) {
existingFields = columns.value.map((column) => column.key)
}
return _fields
.filter((field) => {
return (
!columns.value.find((column) => column.key === field.fieldname) &&
!existingFields.includes(field.fieldname)
)
})
.map((field) => ({ ...field, value: field.fieldname }))
})
function addColumn(c) {
if (!c) return
let align = ['Float', 'Int', 'Percent', 'Currency', 'Duration'].includes(
c.fieldtype,
)
? 'right'
: 'left'
let _column = {
label: c.label,
type: c.fieldtype,
key: c.fieldname,
options: c.options,
width: '10rem',
align,
}
columns.value.push(_column)
rows.value.push(c.value)
apply(true)
}
function removeColumn(c) {
columns.value = columns.value.filter((column) => column.key !== c.key)
if (c.key !== 'name') {
rows.value = rows.value.filter((row) => row !== c.key)
}
apply()
}
function editColumn(c) {
edit.value = true
column.value = c
column.value.old = { ...c }
}
function updateColumn(c) {
edit.value = false
let index = columns.value.findIndex((column) => column.key === c.key)
columns.value[index].label = c.label
columns.value[index].width = c.width
if (columns.value[index].old) {
delete columns.value[index].old
}
apply()
}
function cancelUpdate() {
edit.value = false
column.value.label = column.value.old.label
column.value.width = column.value.old.width
delete column.value.old
}
function reset(close) {
apply(true, false, true)
close()
}
function resetToDefault(close) {
apply(true, true)
close()
}
function apply(reload = false, isDefault = false, reset = false) {
is_default.value = isDefault
columnsUpdated.value = true
let obj = {
columns: reset ? oldValues.value.columns : columns.value,
rows: reset ? oldValues.value.rows : rows.value,
isDefault: reset ? oldValues.value.isDefault : isDefault,
reload,
reset,
}
emit('update', obj)
if (reload) {
// will have think of a better way to do this
setTimeout(() => {
is_default.value = reset ? oldValues.value.isDefault : isDefault
columnsUpdated.value = !reset
}, 100)
}
}
watchOnce(
() => list.value.data,
(val) => {
if (!val) return
oldValues.value.columns = JSON.parse(JSON.stringify(val.columns))
oldValues.value.rows = JSON.parse(JSON.stringify(val.rows))
oldValues.value.isDefault = val.is_default
},
)
</script>

View File

@@ -0,0 +1,153 @@
<template>
<Editor
ref="commentEditor"
v-model="content"
:extensions="extensions"
:placeholder="placeholder"
:editable="editable"
:upload-function="(file) => uploadFile(file, doctype, modelValue.name)"
>
<div class="relative w-full">
<EditorContent
:class="[
'prose-sm max-w-none',
editable &&
'mx-4 max-h-[50vh] min-h-[7rem] overflow-y-auto border-t py-3',
]"
/>
<EditorTableMenu />
<div v-if="editable" class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 px-4">
<AttachmentItem
v-for="a in attachments"
:key="a.file_url"
:label="a.file_name"
>
<template #suffix>
<span
class="lucide-x h-3.5"
aria-hidden="true"
@click.stop="removeAttachment(a)"
/>
</template>
</AttachmentItem>
</div>
<div
class="flex justify-between gap-2 overflow-hidden border-t px-4 py-2.5"
>
<div class="flex gap-1 items-center overflow-x-auto">
<FileUploader
:upload-args="{
doctype: doctype,
docname: modelValue.name,
private: true,
}"
@success="(f) => attachments.push(f)"
>
<template #default="{ openFileSelector }">
<Button
:tooltip="__('Attach a File')"
variant="ghost"
:icon="AttachmentIcon"
@click="openFileSelector()"
/>
</template>
</FileUploader>
<EditorFixedMenu :items="fullToolbar" />
<IconPicker
v-slot="{ togglePopover }"
v-model="emoji"
@update:modelValue="() => appendEmoji()"
>
<Button
:tooltip="__('Insert Emoji')"
:icon="SmileIcon"
variant="ghost"
@click="togglePopover()"
/>
</IconPicker>
</div>
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
<Button v-bind="discardButtonProps || {}" :label="__('Discard')" />
<Button
variant="solid"
v-bind="submitButtonProps || {}"
:label="`${__('Comment')} (${submitShortcutLabel})`"
/>
</div>
</div>
</div>
</div>
</Editor>
</template>
<script setup>
import IconPicker from '@/components/IconPicker.vue'
import SmileIcon from '@/components/Icons/SmileIcon.vue'
import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue'
import AttachmentItem from '@/components/AttachmentItem.vue'
import {
buildEditorExtensions,
fullToolbar,
uploadFile,
} from '@/components/editor/config'
import { submitShortcutLabel } from '@/utils'
import { usersStore } from '@/stores/users'
import { useTelemetry } from 'frappe-ui/frappe'
import { FileUploader } from 'frappe-ui'
import {
Editor,
EditorContent,
EditorFixedMenu,
EditorTableMenu,
} from 'frappe-ui/editor'
import { ref, computed } from 'vue'
defineProps({
placeholder: { type: String, default: null },
editable: { type: Boolean, default: true },
doctype: { type: String, default: 'CRM Lead' },
editorProps: { type: Object, default: () => ({}) },
submitButtonProps: { type: Object, default: () => ({}) },
discardButtonProps: { type: Object, default: () => ({}) },
})
const modelValue = defineModel({ type: Object })
const attachments = defineModel('attachments', {
type: Array,
default: () => [],
})
const content = defineModel('content', { type: String, default: '' })
const { users: usersList } = usersStore()
const { capture } = useTelemetry()
const commentEditor = ref(null)
const emoji = ref('')
const editor = computed(() => commentEditor.value?.editor)
const users = computed(
() =>
usersList.data?.crmUsers
?.filter((user) => user.enabled)
.map((user) => ({
id: user.name,
label: user.full_name?.trim() || user.name,
})) || [],
)
const extensions = buildEditorExtensions({ mentions: () => users.value })
function appendEmoji() {
editor.value.commands.insertContent(emoji.value)
editor.value.commands.focus()
emoji.value = ''
capture('emoji_inserted_in_comment', { emoji: emoji.value })
}
function removeAttachment(attachment) {
attachments.value = attachments.value.filter((a) => a !== attachment)
}
defineExpose({ editor })
</script>

View File

@@ -0,0 +1,333 @@
<template>
<div class="flex justify-between gap-3 border-t px-4 py-2.5">
<div class="flex gap-1.5">
<Button
variant="ghost"
:class="[
showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
]"
:label="__('Reply')"
:iconLeft="Email2Icon"
@click="toggleEmailBox()"
/>
<Button
variant="ghost"
:label="__('Comment')"
:class="[
showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '',
]"
:iconLeft="CommentIcon"
@click="toggleCommentBox()"
/>
</div>
</div>
<div
v-show="showEmailBox"
@keydown.ctrl.enter.capture.stop="submitEmail"
@keydown.meta.enter.capture.stop="submitEmail"
>
<EmailEditor
ref="newEmailEditor"
v-model:content="newEmail"
v-model="doc"
v-model:attachments="attachments"
:submitButtonProps="{
variant: 'solid',
onClick: submitEmail,
disabled: emailEmpty,
}"
:discardButtonProps="{
onClick: async () => {
await deleteAttachedFiles()
showEmailBox = false
newEmailEditor.subject = subject
newEmailEditor.toEmails = doc.email ? [doc.email] : []
newEmailEditor.ccEmails = []
newEmailEditor.bccEmails = []
newEmailEditor.cc = false
newEmailEditor.bcc = false
newEmail = ''
},
}"
:editable="showEmailBox"
:doctype="doctype"
:subject="subject"
:placeholder="
__('Hi John, \n\nCan you please provide more details on this...')
"
/>
</div>
<div
v-show="showCommentBox"
@keydown.ctrl.enter.capture.stop="submitComment"
@keydown.meta.enter.capture.stop="submitComment"
>
<CommentBox
ref="newCommentEditor"
v-model:content="newComment"
v-model="doc"
v-model:attachments="attachments"
:submitButtonProps="{
variant: 'solid',
onClick: submitComment,
disabled: commentEmpty,
}"
:discardButtonProps="{
onClick: async () => {
await deleteAttachedFiles()
showCommentBox = false
newComment = ''
},
}"
:editable="showCommentBox"
:doctype="doctype"
:placeholder="__('@John, can you please check this?')"
/>
</div>
</template>
<script setup>
import EmailEditor from '@/components/EmailEditor.vue'
import CommentBox from '@/components/CommentBox.vue'
import CommentIcon from '@/components/Icons/CommentIcon.vue'
import Email2Icon from '@/components/Icons/Email2Icon.vue'
import { isContentEmpty } from '@/utils'
import { usersStore } from '@/stores/users'
import { useStorage } from '@vueuse/core'
import { useOnboarding, useTelemetry } from 'frappe-ui/frappe'
import { call, createResource, toast } from 'frappe-ui'
import { ref, watch, computed } from 'vue'
const props = defineProps({
doctype: { type: String, default: 'CRM Lead' },
})
const doc = defineModel({ type: Object, default: () => ({}) })
const reload = defineModel('reload', { type: Boolean })
const emit = defineEmits(['scroll'])
const { getUser } = usersStore()
const { updateOnboardingStep } = useOnboarding('frappecrm')
const { capture } = useTelemetry()
const showEmailBox = ref(false)
const showCommentBox = ref(false)
const newEmail = useStorage(
`emailBoxContent-${getUser().email}-${props.doctype}-${doc.value.name}`,
'',
)
// A restored draft already carries its signature; this flag (persisted with the
// draft) stops setSignature from prepending another one on every reopen/reload.
const signatureAdded = useStorage(
`emailSignatureAdded-${getUser().email}-${props.doctype}-${doc.value.name}`,
false,
)
const newComment = useStorage(
`commentBoxContent-${getUser().email}-${props.doctype}-${doc.value.name}`,
'',
)
const newEmailEditor = ref(null)
const newCommentEditor = ref(null)
const attachments = useStorage(
`attachments-${getUser().email}-${props.doctype}-${doc.value.name}`,
[],
localStorage,
{
serializer: {
read: (v) => (v ? JSON.parse(v) : []),
write: (v) => JSON.stringify(v),
},
},
)
const subject = computed(() => {
let prefix = ''
if (doc.value?.lead_name) {
prefix = doc.value.lead_name
} else if (doc.value?.organization) {
prefix = doc.value.organization
}
return `${prefix} (#${doc.value.name})`
})
const signature = createResource({
url: 'crm.api.get_user_signature',
cache: 'user-email-signature',
auto: true,
})
function setSignature(editor) {
if (!signature.data || signatureAdded.value) return
const sig = signature.data.replace(/\n/g, '<br>')
let emailContent = editor.getHTML()
emailContent = emailContent.startsWith('<p></p>')
? emailContent.slice(7)
: emailContent
editor.commands.setContent(sig + emailContent)
editor.commands.focus('start')
signatureAdded.value = true
}
// Clearing the draft (send / discard) resets the guard so the next fresh
// compose gets its signature again.
watch(newEmail, (value) => {
if (!value) signatureAdded.value = false
})
watch(
() => showEmailBox.value,
(value) => {
if (value) {
let editor = newEmailEditor.value.editor
editor.commands.focus()
setSignature(editor)
}
},
)
watch(
() => showCommentBox.value,
(value) => {
if (value) {
newCommentEditor.value.editor.commands.focus()
}
},
)
const commentEmpty = computed(() => isContentEmpty(newComment.value))
const emailEmpty = computed(
() =>
isContentEmpty(newEmail.value) || !newEmailEditor.value?.toEmails?.length,
)
async function sendMail() {
let fromEmail = newEmailEditor.value.fromEmail || getUser().email
let recipients = newEmailEditor.value.toEmails
let subject = newEmailEditor.value.subject
let cc = newEmailEditor.value.ccEmails || []
let bcc = newEmailEditor.value.bccEmails || []
if (attachments.value.length) {
capture('email_attachments_added')
}
await call('frappe.core.doctype.communication.email.make', {
recipients: recipients.join(', '),
attachments: attachments.value.map((x) => x.name),
cc: cc.join(', '),
bcc: bcc.join(', '),
subject: subject,
content: newEmail.value,
doctype: props.doctype,
name: doc.value.name,
send_email: 1,
sender: fromEmail,
sender_full_name: getUser()?.full_name || undefined,
})
}
async function sendComment() {
let _attachments = attachments.value.length
? attachments.value.map((x) => x.name)
: []
let comment = await call('crm.api.comment.add_comment', {
reference_doctype: props.doctype,
reference_name: doc.value.name,
content: newComment.value,
attachments: _attachments,
})
if (comment && attachments.value.length) {
capture('comment_attachments_added')
}
}
async function deleteAttachedFiles() {
if (!attachments.value || attachments.value.length === 0) return
const deletePromises = attachments.value.map(async (file) => {
try {
await call('frappe.client.delete', {
doctype: 'File',
name: file.name,
})
} catch (error) {
console.warn(`Failed to delete file ${file.name}:`, error)
}
})
await Promise.all(deletePromises)
attachments.value = []
}
async function submitEmail() {
if (emailEmpty.value) return
showEmailBox.value = false
// toast.promise returns the toast id (not the promise), so await the send
// itself — otherwise the reload below fires before the email is committed and
// the new email is missing from the refetched list.
const sending = sendMail()
toast.promise(sending, {
loading: __('Sending email...'),
success: __('Email sent'),
error: (e) => e?.messages?.[0] || __('Failed to send email!'),
})
try {
await sending
} catch {
return
}
newEmail.value = ''
attachments.value = []
reload.value = true
emit('scroll')
capture('email_sent', { doctype: props.doctype })
updateOnboardingStep('send_first_email')
}
async function submitComment() {
if (commentEmpty.value) return
showCommentBox.value = false
const sending = sendComment()
toast.promise(sending, {
loading: __('Sending comment...'),
success: __('Comment sent'),
error: (e) => e?.messages?.[0] || __('Failed to send comment!'),
})
try {
await sending
} catch {
return
}
newComment.value = ''
attachments.value = []
reload.value = true
emit('scroll')
capture('comment_sent', { doctype: props.doctype })
updateOnboardingStep('add_first_comment')
}
function toggleEmailBox() {
if (showCommentBox.value) {
showCommentBox.value = false
}
showEmailBox.value = !showEmailBox.value
}
function toggleCommentBox() {
if (showEmailBox.value) {
showEmailBox.value = false
}
showCommentBox.value = !showCommentBox.value
}
defineExpose({
show: showEmailBox,
showComment: showCommentBox,
editor: newEmailEditor,
})
</script>

View File

@@ -0,0 +1,423 @@
<template>
<div
class="flex gap-2"
:class="[
{
'items-center': !props.isGroup,
},
]"
>
<div
class="flex gap-2 w-full"
:class="[
{
'items-center justify-between': !props.isGroup,
},
]"
>
<div :class="'text-end text-base text-ink-gray-5'">
<div v-if="props.itemIndex == 0" class="min-w-[66px] text-start">
{{ __('Where') }}
</div>
<div v-else class="min-w-[66px] flex items-start">
<Button
variant="subtle"
class="w-max"
icon-right="lucide-refresh-cw"
:disabled="props.itemIndex > 2"
:label="conjunction"
@click="toggleConjunction"
/>
</div>
</div>
<div v-if="!props.isGroup" class="flex items-center gap-2 w-full">
<div id="fieldname" class="w-full">
<Combobox
trigger="button"
:options="filterableFields.data || []"
:model-value="condition[0]"
:placeholder="__('Field')"
@update:selected-option="updateField"
/>
</div>
<div id="operator">
<FormControl
v-if="!condition[0]"
disabled
type="text"
:placeholder="__('Operator')"
class="w-[100px]"
/>
<FormControl
v-else
v-model="condition[1]"
:disabled="!condition[0]"
type="select"
:options="getOperators()"
class="w-max min-w-[100px] text-ink-gray-8"
@update:modelValue="updateOperator"
/>
</div>
<div id="value" class="w-full">
<FormControl
v-if="!condition[0]"
disabled
type="text"
:placeholder="__('Condition')"
class="w-full"
/>
<component
:is="getValueControl()"
v-else
v-model="condition[2]"
:placeholder="__('Condition')"
@change="updateValue"
/>
</div>
</div>
<CFConditions
v-if="props.isGroup && !(props.level == 2 || props.level == 4)"
:conditions="condition"
:isChild="true"
:level="props.level"
:disableAddCondition="props.disableAddCondition"
:doctype="props.doctype"
/>
<Button
v-if="props.isGroup && (props.level == 2 || props.level == 4)"
variant="outline"
:label="__('Open Nested Conditions')"
@click="show = true"
/>
</div>
<div :class="'w-max'">
<Dropdown placement="right" :options="dropdownOptions">
<Button variant="ghost" icon="lucide-more-horizontal" />
</Dropdown>
</div>
</div>
<Dialog v-model:open="show" :size="'3xl'" :title="__('Nested Conditions')">
<template #default>
<CFConditions
:conditions="condition"
:isChild="true"
:level="props.level"
:disableAddCondition="props.disableAddCondition"
:doctype="props.doctype"
/>
</template>
</Dialog>
</template>
<script setup>
import GroupIcon from '~icons/lucide/group'
import UnGroupIcon from '~icons/lucide/ungroup'
import CFConditions from './CFConditions.vue'
import Link from '@/components/Controls/Link.vue'
import {
Combobox,
Button,
DatePicker,
DateRangePicker,
DateTimePicker,
Dialog,
Dropdown,
FormControl,
Rating,
} from 'frappe-ui'
import { filterableFields } from './filterableFields'
import { reactive, computed, h, ref } from 'vue'
const show = ref(false)
const emit = defineEmits([
'remove',
'unGroupConditions',
'toggleConjunction',
'turnIntoGroup',
])
const props = defineProps({
condition: { type: Array, required: true },
isChild: { type: Boolean, default: false },
itemIndex: { type: Number, default: 0 },
level: { type: Number, default: 0 },
isGroup: { type: Boolean, default: false },
conjunction: { type: String, default: 'and' },
disableAddCondition: { type: Boolean, default: false },
doctype: { type: String, default: '' },
})
const condition = reactive(props.condition)
const dropdownOptions = computed(() => {
const options = []
if (!props.isGroup && props.level < 4) {
options.push({
label: __('Turn into a Group'),
icon: () => h(GroupIcon),
onClick: () => {
emit('turnIntoGroup')
},
})
}
if (props.isGroup) {
options.push({
label: __('Ungroup Conditions'),
icon: () => h(UnGroupIcon),
onClick: () => {
emit('unGroupConditions')
},
})
}
options.push({
label: __('Remove'),
icon: 'trash-2',
variant: 'red',
onClick: () => emit('remove'),
condition: () => !props.isGroup,
})
options.push({
label: __('Remove Group'),
icon: 'trash-2',
variant: 'red',
onClick: () => emit('remove'),
condition: () => props.isGroup,
})
return options
})
const typeCheck = ['Check']
const typeLink = ['Link', 'Dynamic Link']
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
const typeSelect = ['Select']
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
const typeDate = ['Date', 'Datetime']
const typeRating = ['Rating']
function toggleConjunction() {
emit('toggleConjunction', props.conjunction)
}
const updateField = (field) => {
condition[0] = field?.fieldname
resetConditionValue()
}
const resetConditionValue = () => {
condition[2] = ''
}
function getValueControl() {
const [field, operator] = condition
if (!field) return null
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
if (!fieldData) return null
const { fieldtype, options } = fieldData
if (operator == 'is') {
return h(FormControl, {
type: 'select',
options: [
{
label: 'Set',
value: 'set',
},
{
label: 'Not Set',
value: 'not set',
},
],
})
} else if (['like', 'not like', 'in', 'not in'].includes(operator)) {
return h(FormControl, { type: 'text' })
} else if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
const _options =
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
return h(FormControl, {
type: 'select',
options: _options.map((o) => ({
label: o,
value: o,
})),
})
} else if (typeLink.includes(fieldtype)) {
if (fieldtype == 'Dynamic Link') {
return h(FormControl, { type: 'text' })
}
return h(Link, {
class: 'form-control',
doctype: options,
value: condition[2],
})
} else if (typeNumber.includes(fieldtype)) {
return h(FormControl, { type: 'number' })
} else if (typeDate.includes(fieldtype) && operator == 'between') {
return h(DateRangePicker, { value: condition[2], iconLeft: '' })
} else if (typeDate.includes(fieldtype)) {
return h(fieldtype == 'Date' ? DatePicker : DateTimePicker, {
value: condition[2],
iconLeft: '',
})
} else if (typeRating.includes(fieldtype)) {
return h(Rating, {
modelValue: condition[2] || 0,
class: 'truncate',
'update:modelValue': (v) => updateValue(v),
})
} else {
return h(FormControl, { type: 'text' })
}
}
function updateValue(value) {
value = value.target ? value.target.value : value
if (condition[1] === 'between') {
condition[2] = [value.split(',')[0], value.split(',')[1]]
} else {
condition[2] = isNaN(value) ? value : Number(value)
}
}
function getSelectOptions(options) {
return options.split('\n')
}
function updateOperator() {
condition[2] = getDefaultValue(condition[0])
resetConditionValue()
}
function getOperators() {
let options = []
const field = condition[0]
if (!field) return options
const fieldData = filterableFields.data?.find((f) => f.fieldname == field)
if (!fieldData) return options
const { fieldtype, fieldname } = fieldData
if (typeString.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (fieldname === '_assign') {
options = [
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'Is', value: 'is' },
]
}
if (typeNumber.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
{ label: '<', value: '<' },
{ label: '>', value: '>' },
{ label: '<=', value: '<=' },
{ label: '>=', value: '>=' },
],
)
}
if (typeSelect.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeLink.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeCheck.includes(fieldtype)) {
options.push(...[{ label: 'Equals', value: '==' }])
}
if (['Duration'].includes(fieldtype)) {
options.push(
...[
{ label: 'Like', value: 'like' },
{ label: 'Not Like', value: 'not like' },
{ label: 'In', value: 'in' },
{ label: 'Not In', value: 'not in' },
{ label: 'Is', value: 'is' },
],
)
}
if (typeDate.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Is', value: 'is' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
{ label: 'Between', value: 'between' },
],
)
}
if (typeRating.includes(fieldtype)) {
options.push(
...[
{ label: 'Equals', value: '==' },
{ label: 'Not Equals', value: '!=' },
{ label: 'Is', value: 'is' },
{ label: '>', value: '>' },
{ label: '<', value: '<' },
{ label: '>=', value: '>=' },
{ label: '<=', value: '<=' },
],
)
}
const op = options.find((o) => o.value == condition[1])
condition[1] = op?.value || options[0].value
return options
}
function getDefaultValue(field) {
if (typeSelect.includes(field.fieldtype)) {
return getSelectOptions(field.options)[0]
}
if (typeCheck.includes(field.fieldtype)) {
return 'Yes'
}
if (typeDate.includes(field.fieldtype)) {
return null
}
if (typeRating.includes(field.fieldtype)) {
return 0
}
return ''
}
</script>

View File

@@ -0,0 +1,128 @@
<template>
<div
class="rounded-lg border border-outline-gray-2 p-3 flex flex-col gap-4 w-full"
>
<template v-for="(condition, i) in conditions" :key="condition.field">
<CFCondition
v-if="Array.isArray(condition)"
:condition="condition"
:isChild="props.isChild"
:itemIndex="i"
:level="props.level + 1"
:isGroup="isGroupCondition(condition[0])"
:conjunction="getConjunction()"
:disableAddCondition="props.disableAddCondition"
:doctype="props.doctype"
@remove="removeCondition(condition)"
@unGroupConditions="unGroupConditions(condition)"
@toggleConjunction="toggleConjunction"
@turnIntoGroup="turnIntoGroup(condition)"
/>
</template>
<div v-if="props.isChild" class="flex">
<Dropdown v-slot="{ open }" :options="dropdownOptions">
<Button
:disabled="props.disableAddCondition"
:label="__('Add Condition')"
icon-left="lucide-plus"
:icon-right="open ? 'chevron-up' : 'chevron-down'"
/>
</Dropdown>
</div>
</div>
</template>
<script setup>
import CFCondition from './CFCondition.vue'
import { filterableFields } from './filterableFields'
import { Button, Dropdown } from 'frappe-ui'
import { computed, reactive, watch } from 'vue'
const props = defineProps({
conditions: { type: Array, required: true },
isChild: { type: Boolean, default: false },
level: { type: Number, default: 0 },
disableAddCondition: { type: Boolean, default: false },
doctype: { type: String, required: true },
})
const conditions = reactive(props.conditions)
const getConjunction = () => {
let conjunction = 'and'
conditions.forEach((condition) => {
if (typeof condition == 'string') {
conjunction = condition
}
})
return conjunction
}
const turnIntoGroup = (condition) => {
conditions.splice(conditions.indexOf(condition), 1, [condition])
}
const isGroupCondition = (condition) => {
return Array.isArray(condition)
}
const dropdownOptions = computed(() => {
const options = [
{
label: __('Add Condition'),
onClick: () => {
const conjunction = getConjunction()
conditions.push(conjunction, ['', '', ''])
},
},
]
if (props.level < 3) {
options.push({
label: __('Add Condition Group'),
onClick: () => {
const conjunction = getConjunction()
conditions.push(conjunction, [[]])
},
})
}
return options
})
function removeCondition(condition) {
const conditionIndex = conditions.indexOf(condition)
if (conditionIndex == 0) {
conditions.splice(conditionIndex, 2)
} else {
conditions.splice(conditionIndex - 1, 2)
}
}
function unGroupConditions(condition) {
const conjunction = getConjunction()
const newConditions = condition.map((c) => {
if (typeof c == 'string') {
return conjunction
}
return c
})
const index = conditions.indexOf(condition)
if (index !== -1) {
conditions.splice(index, 1, ...newConditions)
}
}
function toggleConjunction(conjunction) {
for (let i = 0; i < conditions.length; i++) {
if (typeof conditions[i] == 'string') {
conditions[i] = conjunction == 'and' ? 'or' : 'and'
}
}
}
watch(
() => props.doctype,
(doctype) => filterableFields.submit({ doctype }),
{ immediate: true },
)
</script>

View File

@@ -0,0 +1,19 @@
import { createResource } from 'frappe-ui'
export const filterableFields = createResource({
url: 'crm.api.doc.get_filterable_fields',
transform: (data) => {
data = data
.filter((field) => !field.fieldname.startsWith('_'))
// `description` renders as a second line in the dropdown, which has no
// max width, so a long one stretches the whole list.
.map(({ description, ...field }) => {
return {
label: field.label,
value: field.fieldname,
...field,
}
})
return data
},
})

View File

@@ -0,0 +1,196 @@
<template>
<!-- Empty + editable -->
<div
v-if="!value && !disabled"
:class="[containerClasses, 'cursor-pointer']"
@click="showUploader = true"
>
<span
class="lucide-paperclip size-4"
:class="[iconClasses, 'text-ink-gray-5']"
aria-hidden="true"
/>
<span class="whitespace-nowrap text-ink-gray-4">{{
__('Attach file…')
}}</span>
</div>
<!-- Empty + disabled -->
<div v-else-if="!value && disabled" :class="containerClasses">
<span class="text-ink-gray-4">—</span>
</div>
<!-- Has value -->
<div v-else :class="[containerClasses, '!pr-1']">
<span
class="lucide-paperclip size-4"
:class="[iconClasses, 'text-ink-gray-7']"
aria-hidden="true"
/>
<Tooltip class="min-w-0 flex-1">
<template #body>
<div v-if="isImage" class="overflow-hidden rounded shadow-xl">
<img
:src="value"
class="max-h-40 max-w-xs object-contain"
:alt="filename"
/>
</div>
<div
v-else
class="rounded bg-surface-gray-10 px-2 py-1.5 text-xs text-ink-base shadow-xl"
>
{{ filename }}
</div>
</template>
<a
class="block min-w-0 truncate text-ink-gray-8 hover:underline"
:href="value"
target="_blank"
rel="noopener noreferrer"
>
{{ filename }}
</a>
</Tooltip>
<button
v-if="!disabled"
class="ml-auto flex h-5 w-5 shrink-0 items-center justify-center rounded text-ink-gray-4 hover:bg-surface-gray-2 hover:text-ink-gray-7 dark:hover:bg-surface-gray-4"
:title="__('Clear')"
@click.prevent="clearAttachment"
>
<span class="lucide-x h-3 w-3" aria-hidden="true" />
</button>
</div>
<FilesUploader
v-if="showUploader"
v-model="showUploader"
:doctype="doctype"
:docname="docname"
:fieldname="fieldname"
:options="uploaderOptions"
@after="onAfterUpload"
/>
</template>
<script setup>
import { ref, computed, useAttrs } from 'vue'
import { Tooltip } from 'frappe-ui'
import FilesUploader from '@/components/FilesUploader/FilesUploader.vue'
defineOptions({ inheritAttrs: false })
const IMAGE_EXTENSIONS =
/\.(jpe?g|png|gif|webp|svg|avif|bmp|ico|tiff?)(\?.*)?$/i
const props = defineProps({
value: { type: String, default: null },
doctype: { type: String, default: '' },
docname: { type: String, default: '' },
fieldname: { type: String, default: '' },
disabled: { type: Boolean, default: false },
imageOnly: { type: Boolean, default: false },
})
const emit = defineEmits(['change'])
const attrs = useAttrs()
const showUploader = ref(false)
// Mirror frappe-ui TextInput size classes
const sizeClasses = computed(
() =>
({
sm: 'h-7 text-base rounded',
md: 'h-8 text-base rounded',
lg: 'h-10 text-lg rounded-md',
xl: 'h-10 text-2xl rounded-md',
})[attrs.size || 'sm'],
)
// Mirror frappe-ui TextInput padding by size
const paddingClasses = computed(
() =>
({
sm: 'px-2',
md: 'px-2.5',
lg: 'px-3',
xl: 'px-3',
})[attrs.size || 'sm'],
)
// Mirror frappe-ui TextInput variant + disabled classes
const variantClasses = computed(() => {
if (props.disabled) {
return [
'border bg-surface-gray-1 text-ink-gray-5',
(attrs.variant || 'subtle') === 'outline'
? 'border-outline-gray-2'
: 'border-transparent',
].join(' ')
}
return {
subtle:
'border border-[--surface-gray-2] bg-surface-gray-2 hover:border-outline-elevation-2 hover:bg-surface-gray-3',
outline:
'border border-outline-gray-2 bg-surface-base hover:border-outline-gray-3 hover:shadow-sm',
ghost: 'border-0',
}[attrs.variant || 'subtle']
})
const containerClasses = computed(() =>
[
'flex w-full items-center gap-1.5 overflow-hidden transition-colors',
sizeClasses.value,
paddingClasses.value,
variantClasses.value,
attrs.class,
]
.filter(Boolean)
.join(' '),
)
const iconClasses = computed(
() =>
({
sm: 'h-3 w-3 shrink-0',
md: 'h-3.5 w-3.5 shrink-0',
lg: 'h-4 w-4 shrink-0',
xl: 'h-4 w-4 shrink-0',
})[attrs.size || 'sm'],
)
const uploaderOptions = computed(() => {
return {
folder: 'Home/Attachments',
allowMultiple: false,
restrictions: {
maxNumberOfFiles: 1,
...(props.imageOnly ? { allowedFileTypes: ['image/*'] } : {}),
},
}
})
const filename = computed(() => {
if (!props.value) return ''
const clean = props.value.split('?')[0].split('#')[0]
const raw = clean.split('/').pop() || props.value
try {
return decodeURIComponent(raw)
} catch {
return raw
}
})
const isImage = computed(() => IMAGE_EXTENSIONS.test(props.value || ''))
function onAfterUpload(uploadedFiles) {
if (uploadedFiles && uploadedFiles.length) {
emit('change', uploadedFiles[0].file_url)
}
}
function clearAttachment() {
emit('change', null)
}
</script>

View File

@@ -0,0 +1,48 @@
<template>
<Button
:label="__(label)"
:theme="theme"
:variant="variant"
:icon-left="lucide - icon"
:disabled="disabled"
v-bind="$attrs"
@click.stop="emit('click', $event)"
/>
</template>
<script>
export function getButtonTheme(buttonColor) {
const themeMap = {
Primary: 'gray',
Info: 'blue',
Success: 'green',
Warning: 'gray',
Danger: 'red',
}
return themeMap[buttonColor] || 'gray'
}
export function getButtonVariant(buttonColor) {
const variantMap = {
Primary: 'solid',
Info: 'subtle',
Success: 'solid',
Warning: 'subtle',
Danger: 'solid',
}
return variantMap[buttonColor] || 'subtle'
}
</script>
<script setup>
import { Button } from 'frappe-ui'
defineProps({
label: { type: String, required: true },
icon: { type: String, default: null },
theme: { type: String, default: 'gray' },
variant: { type: String, default: 'subtle' },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['click'])
</script>

View File

@@ -0,0 +1,174 @@
<template>
<TextInput
ref="inputRef"
:value="displayValue"
v-bind="attrs"
:placeholder="placeholder"
@focus="handleFocus"
@blur="handleBlur"
@input="handleInput"
@keydown="handleKeydown"
/>
<p
v-if="errorMessage || attrs.description"
class="mt-1.5"
:class="[sizeClass, errorMessage ? 'text-ink-red-6' : 'text-ink-gray-5']"
>
{{ errorMessage || attrs.description }}
</p>
</template>
<script setup>
import { formatDuration } from '@/utils'
import { TextInput } from 'frappe-ui'
import { ref, computed, nextTick, useAttrs } from 'vue'
const props = defineProps({
value: { type: Number, default: null },
placeholder: { type: String, default: '1h 30m 45s' },
longForm: { type: Boolean, default: false },
})
const emit = defineEmits(['change'])
const attrs = useAttrs()
const inputRef = ref(null)
const isFocused = ref(false)
const editValue = ref('')
const errorMessage = ref('')
const isCommitting = ref(false)
const sizeClass = computed(() => {
return { sm: 'text-xs', md: 'text-base' }[attrs.size || 'sm']
})
const displayValue = computed(() => {
if (isFocused.value) return editValue.value
return formatDuration(props.value, props.longForm)
})
function handleFocus() {
isFocused.value = true
editValue.value = formatDuration(props.value, props.longForm) || ''
errorMessage.value = ''
nextTick(() => {
inputRef.value?.el?.select()
})
}
function handleInput(e) {
editValue.value = e.target.value
errorMessage.value = ''
}
function handleBlur() {
if (isCommitting.value) return
commit()
}
function handleKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault()
isCommitting.value = true
commit()
inputRef.value?.el?.blur()
isCommitting.value = false
} else if (e.key === 'Escape') {
revert()
inputRef.value?.el?.blur()
}
}
function commit() {
const raw = editValue.value.trim()
if (raw === '') {
// Allow clearing the field
errorMessage.value = ''
isFocused.value = false
emit('change', null)
return
}
const seconds = parseDuration(raw)
if (seconds === null) {
errorMessage.value = props.longForm
? __(
'Invalid format. Try: 1 hour 30 minutes, 2 hours, 45 seconds, 1:30:45, 90s',
)
: __('Invalid format. Try: 1h 30m 45s, 1:30:45, 90s')
isFocused.value = false
} else {
errorMessage.value = ''
isFocused.value = false
emit('change', seconds)
}
}
function revert() {
errorMessage.value = ''
isFocused.value = false
}
// ---------------------------------------------------------------------------
// Parse: human-readable string → seconds (integer) or null if invalid
//
// Supported formats (case-insensitive, spaces optional, any unit order):
// 1h 30m 45s | 4m 3h 4s | 4sec 3hour 4min | 1h30m45s
// 1:30:45 | 1:30 | :45
// 90 | 90s (bare integer treated as seconds)
// ---------------------------------------------------------------------------
function parseDuration(str) {
const input = str.trim().toLowerCase()
if (!input) return null
// Colon-separated: h:m:s or m:s or :s
const colonRegex = /^(\d+):(\d+):(\d+)$|^(\d+):(\d+)$|^:(\d+)$/
const colonMatch = input.match(colonRegex)
if (colonMatch) {
if (colonMatch[1] !== undefined) {
return (
parseInt(colonMatch[1], 10) * 3600 +
parseInt(colonMatch[2], 10) * 60 +
parseInt(colonMatch[3], 10)
)
} else if (colonMatch[4] !== undefined) {
return parseInt(colonMatch[4], 10) * 60 + parseInt(colonMatch[5], 10)
} else {
return parseInt(colonMatch[6], 10)
}
}
// Bare integer: treated as seconds
if (/^\d+$/.test(input)) return parseInt(input, 10)
// Named units in any order: scan (number)(unit) tokens left-to-right.
// Verify no unexpected text exists between tokens.
const tokenRe = /(\d+)\s*(hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)/g
const tokens = []
let lastEnd = 0
let match
while ((match = tokenRe.exec(input)) !== null) {
if (input.slice(lastEnd, match.index).trim() !== '') return null
tokens.push({ value: parseInt(match[1], 10), unit: match[2][0] })
lastEnd = match.index + match[0].length
}
if (input.slice(lastEnd).trim() !== '') return null
if (tokens.length === 0) return null
// Reject duplicate units (e.g. "1h 2h")
const seen = new Set()
for (const t of tokens) {
if (seen.has(t.unit)) return null
seen.add(t.unit)
}
let total = 0
for (const { value, unit } of tokens) {
if (unit === 'h') total += value * 3600
else if (unit === 'm') total += value * 60
else total += value
}
return total
}
</script>

View File

@@ -0,0 +1,316 @@
<template>
<div>
<div class="flex flex-wrap gap-1">
<Button
v-for="value in values"
ref="emails"
:key="value"
:label="value"
theme="gray"
variant="subtle"
:class="{
'rounded bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4':
variant === 'subtle',
}"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<span
class="lucide-x h-3.5"
aria-hidden="true"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="flex-1">
<ComboboxRoot
:model-value="tempSelection"
:open="showOptions"
:ignore-filter="true"
@update:open="(o) => (showOptions = o)"
@update:modelValue="onSelect"
>
<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-base hover:bg-surface-base'
: '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-elevation-2 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"
>
<span
v-if="showSearchIcon"
class="lucide-search h-4"
aria-hidden="true"
/>
{{ 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-medium">{{ option.label }}</div>
<div class="text-sm text-ink-gray-5">
{{ option.value }}
</div>
</div>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</div>
</div>
<ErrorMessage v-if="error" class="mt-2 pl-2" :message="error" />
<div
v-if="info"
class="whitespace-pre-line text-sm text-ink-blue-6 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) => __('{0} is an Invalid Value', [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({ type: Array, default: () => [] })
// 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 — use the store's `allUsers` computed so we get the full
// list once the background fetch lands, falling back to the smaller
// crm-users-only payload during initial load.
const { users, allUsers } = 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 = allUsers.value || []
list = list.map((u) => ({
label: u.full_name || u.name || u.email,
value: u.email,
}))
if (props.existingEmails?.length) {
list = list.filter((o) => !props.existingEmails.includes(o.value))
}
if (query.value) {
const q = query.value.toLowerCase()
list = list.filter(
(o) =>
o.label?.toLowerCase().includes(q) ||
o.value?.toLowerCase().includes(q),
)
}
return list
}
if (mode === 'contacts') {
const list = filterOptions.value?.data ? [...filterOptions.value.data] : []
if (!list.length && query.value) {
list.push({ label: query.value, value: query.value })
}
return list
}
// Free / manual mode
return query.value ? [{ label: query.value, value: query.value }] : []
})
const showSearchIcon = computed(() => effectiveMode.value !== 'free')
const emptyStateText = computed(() => {
if (effectiveMode.value === 'free') return __(props.emptyPlaceholder)
return options.value.length ? '' : __(props.emptyPlaceholder)
})
function addValue(input) {
if (!input) return
error.value = null
info.value = null
const parts = input
.split(',')
.map((p) => p.trim())
.filter(Boolean)
for (const email of parts) {
if (values.value?.includes(email)) {
info.value = __('Email already exists')
continue
}
if (props.validate && !props.validate(email)) {
error.value = props.errorMessage(email)
query.value = email
break
}
if (!values.value) values.value = [email]
else values.value.push(email)
}
}
function removeValue(value) {
values.value = values.value.filter((v) => v !== value)
}
function removeLastValue() {
if (query.value) return
let emailRef = emails.value[emails.value.length - 1]?.rootRef
if (document.activeElement === emailRef) {
values.value.pop()
nextTick(() => {
if (values.value.length) {
emailRef = emails.value[emails.value.length - 1].rootRef
emailRef?.focus()
} else {
setFocus()
}
})
} else {
emailRef?.focus()
}
}
function setFocus() {
search.value?.focus?.()
}
defineExpose({ setFocus })
function onInput(e) {
query.value = e.target.value
showOptions.value = true
}
function onSelect(val) {
if (!val) return
addValue(val)
if (!error.value) {
query.value = ''
tempSelection.value = null
showOptions.value = false
nextTick(() => setFocus())
}
}
function handleEnter() {
if (query.value) onSelect(query.value)
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<TextInput
ref="inputRef"
:value="displayValue"
v-bind="$attrs"
@focus="handleFocus"
@blur="isFocused = false"
/>
</template>
<script setup>
import { TextInput } from 'frappe-ui'
import { ref, computed, nextTick } from 'vue'
defineOptions({ inheritAttrs: false })
const props = defineProps({
value: { type: [String, Number], default: '' },
formattedValue: { type: [String, Number], default: '' },
})
const isFocused = ref(false)
const inputRef = ref(null)
function handleFocus() {
isFocused.value = true
nextTick(() => {
if (inputRef.value) {
inputRef.value.el?.select()
}
})
}
const displayValue = computed(() => {
return isFocused.value ? props.value : props.formattedValue || props.value
})
</script>

View File

@@ -0,0 +1,420 @@
<template>
<!-- Empty + editable -->
<div
v-if="!value && !disabled"
:class="[containerClasses, 'cursor-pointer']"
@click="openModal"
>
<span
class="lucide-map-pin size-4"
:class="[iconClasses, 'text-ink-gray-5']"
aria-hidden="true"
/>
<span class="whitespace-nowrap text-ink-gray-4">{{
__('Set location…')
}}</span>
</div>
<!-- Empty + disabled -->
<div v-else-if="!value && disabled" :class="containerClasses">
<span class="text-ink-gray-4">—</span>
</div>
<!-- Has value -->
<div
v-else
:class="[containerClasses, '!pr-1', 'cursor-pointer']"
@click="openModal"
>
<span
class="lucide-map-pin size-4"
:class="[iconClasses, 'text-ink-gray-7']"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-ink-gray-8 text-sm">
{{ coordinateSummary }}
</span>
<button
v-if="!disabled"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-ink-gray-4 hover:bg-surface-gray-2 hover:text-ink-gray-7 dark:hover:bg-surface-gray-4"
:title="__('Clear')"
@click.prevent="clearLocation"
>
<span class="lucide-x h-3 w-3" aria-hidden="true" />
</button>
</div>
<!-- Map Dialog -->
<Dialog
v-model:open="showModal"
:title="disabled ? __('View Location') : __('Set Location')"
:size="'4xl'"
>
<template #default>
<div :id="mapId" class="h-[500px] w-full rounded" />
</template>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button
variant="outline"
:label="disabled ? __('Close') : __('Cancel')"
@click="showModal = false"
/>
<Button
v-if="!disabled"
variant="solid"
:label="__('Save')"
@click="saveLocation"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
// Static ?url imports so Vite bundles the images and resolves paths correctly
import leafletIconUrl from 'leaflet/dist/images/marker-icon.png?url'
import leafletIconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png?url'
import leafletShadowUrl from 'leaflet/dist/images/marker-shadow.png?url'
import { useGeolocation } from '@vueuse/core'
import { Dialog, Button } from 'frappe-ui'
import { ref, computed, watch, nextTick, useAttrs, onBeforeUnmount } from 'vue'
defineOptions({ inheritAttrs: false })
const props = defineProps({
value: { type: String, default: null },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['change'])
const attrs = useAttrs()
const { coords: geoCoords } = useGeolocation()
const showModal = ref(false)
const mapId = `geo-map-${Math.random().toString(36).slice(2)}`
// Leaflet instances — populated after dynamic import
let L = null
let mapInstance = null
let editableLayers = null
let drawControl = null
// ─── Computed ──────────────────────────────────────────────────────────────
const coordinateSummary = computed(() => {
if (!props.value) return ''
try {
const geo = JSON.parse(props.value)
// Single Point feature or FeatureCollection with one Point
const features = geo.type === 'FeatureCollection' ? geo.features : [geo]
const points = features.filter((f) => f.geometry?.type === 'Point')
if (features.length === 1 && points.length === 1) {
const [lng, lat] = points[0].geometry.coordinates
const latStr = `${Math.abs(lat).toFixed(5)}°${lat >= 0 ? 'N' : 'S'}`
const lngStr = `${Math.abs(lng).toFixed(5)}°${lng >= 0 ? 'E' : 'W'}`
return `${latStr}, ${lngStr}`
}
const count = features.length
return __(`{0} ${count === 1 ? 'feature' : 'features'}`, [count])
} catch {
return __('Invalid GeoJSON')
}
})
// ─── Size / variant classes (mirrors AttachControl / frappe-ui TextInput) ──
const sizeClasses = computed(
() =>
({
sm: 'h-7 text-base rounded',
md: 'h-8 text-base rounded',
lg: 'h-10 text-lg rounded-md',
xl: 'h-10 text-2xl rounded-md',
})[attrs.size || 'sm'],
)
const paddingClasses = computed(
() =>
({
sm: 'px-2',
md: 'px-2.5',
lg: 'px-3',
xl: 'px-3',
})[attrs.size || 'sm'],
)
const variantClasses = computed(() => {
if (props.disabled) {
return [
'border bg-surface-gray-1 text-ink-gray-5',
(attrs.variant || 'subtle') === 'outline'
? 'border-outline-gray-2'
: 'border-transparent',
].join(' ')
}
return {
subtle:
'border border-[--surface-gray-2] bg-surface-gray-2 hover:border-outline-elevation-2 hover:bg-surface-gray-3',
outline:
'border border-outline-gray-2 bg-surface-base hover:border-outline-gray-3 hover:shadow-sm',
ghost: 'border-0',
}[attrs.variant || 'subtle']
})
const iconClasses = computed(
() =>
({
sm: 'h-3 w-3 shrink-0',
md: 'h-3.5 w-3.5 shrink-0',
lg: 'h-4 w-4 shrink-0',
xl: 'h-4 w-4 shrink-0',
})[attrs.size || 'sm'],
)
const containerClasses = computed(() =>
[
'flex w-full items-center gap-1.5 overflow-hidden transition-colors',
sizeClasses.value,
paddingClasses.value,
variantClasses.value,
attrs.class,
]
.filter(Boolean)
.join(' '),
)
// ─── Map lifecycle ──────────────────────────────────────────────────────────
function openModal() {
showModal.value = true
}
watch(showModal, (visible) => {
if (!visible) {
destroyMap()
return
}
nextTick(() => initMap())
})
async function initMap() {
// Lazy-load Leaflet + leaflet-draw + their CSS on first open
if (!L) {
await import('leaflet/dist/leaflet.css')
await import('leaflet-draw/dist/leaflet.draw.css')
const leafletModule = await import('leaflet')
L = leafletModule.default ?? leafletModule
await import('leaflet-draw')
// Fix Vite marker image paths — delete the built-in resolver first,
// then supply the already-resolved ?url import strings
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: leafletIconUrl,
iconRetinaUrl: leafletIconRetinaUrl,
shadowUrl: leafletShadowUrl,
})
// Patch Circle/CircleMarker toGeoJSON to include radius in properties
// (same patch as Frappe's customize_draw_controls)
const circleToGeoJSON = L.Circle.prototype.toGeoJSON
L.Circle.include({
toGeoJSON() {
const feature = circleToGeoJSON.call(this)
feature.properties = { point_type: 'circle', radius: this.getRadius() }
return feature
},
})
L.CircleMarker.include({
toGeoJSON() {
const feature = circleToGeoJSON.call(this)
feature.properties = {
point_type: 'circlemarker',
radius: this.getRadius(),
}
return feature
},
})
}
// Dialog uses v-if internally — the map div is destroyed on close and
// recreated on open. Always create a fresh instance; destroyMap() on close
// ensures mapInstance is null here.
mapInstance = L.map(mapId)
// ── Tile layers (mirrors Frappe's map_defaults) ─────────────────────────
const streetLayer = L.tileLayer(
'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
{
maxZoom: 19,
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
)
const satelliteLayer = L.tileLayer(
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
{ attribution: '© Esri © OpenStreetMap Contributors' },
)
const labelsLayer = L.tileLayer(
'https://tiles.stadiamaps.com/tiles/stamen_toner_labels/{z}/{x}/{y}{r}.png',
{
attribution:
'© <a href="https://www.stadiamaps.com/">Stadia Maps</a> © <a href="https://www.stamen.com/">Stamen Design</a>',
},
)
const terrainLayer = L.tileLayer(
'https://tiles.stadiamaps.com/tiles/stamen_terrain_lines/{z}/{x}/{y}{r}.png',
{
attribution:
'© <a href="https://www.stadiamaps.com/">Stadia Maps</a> © <a href="https://www.stamen.com/">Stamen Design</a>',
},
)
streetLayer.addTo(mapInstance)
L.control
.layers(
{ Default: streetLayer, Satellite: satelliteLayer },
{ Labels: labelsLayer, Terrain: terrainLayer },
)
.addTo(mapInstance)
// ── Locate control ───────────────────────────────────────────────────────
// Lazy-load leaflet.locatecontrol CSS + plugin
await import('leaflet.locatecontrol/dist/L.Control.Locate.min.css')
const { locate: leafletLocate } = await import('leaflet.locatecontrol')
leafletLocate({ position: 'topright' }).addTo(mapInstance)
editableLayers = new L.FeatureGroup()
editableLayers.addTo(mapInstance)
// Draw controls — only in edit mode
if (!props.disabled) {
drawControl = new L.Control.Draw({
position: 'topleft',
draw: {
polyline: { shapeOptions: { color: '#4f46e5', weight: 4 } },
polygon: {
allowIntersection: false,
shapeOptions: { color: '#4f46e5' },
},
circle: true,
rectangle: { shapeOptions: { clickable: false } },
circlemarker: true,
marker: true,
},
edit: { featureGroup: editableLayers, remove: true },
})
drawControl.addTo(mapInstance)
mapInstance.on('draw:created', (e) => {
editableLayers.addLayer(e.layer)
})
mapInstance.on('draw:deleted', (e) => {
e.layers.eachLayer((l) => editableLayers.removeLayer(l))
})
}
reloadData()
}
function reloadData() {
if (!L || !editableLayers || !mapInstance) return
// Clear existing layers
editableLayers.clearLayers()
// Use device location if available, otherwise fall back to Mumbai
const { latitude, longitude } = geoCoords.value
const defaultCenter =
isFinite(latitude) && isFinite(longitude)
? [latitude, longitude]
: [19.08, 72.8961]
mapInstance.setView(defaultCenter, 13)
if (!props.value) return
try {
const geoData = JSON.parse(props.value)
const dataGroup = new L.FeatureGroup().addLayer(
L.geoJSON(geoData, {
pointToLayer(geoJsonPoint, latlng) {
const pt = geoJsonPoint.properties?.point_type
if (pt === 'circle') {
return L.circle(latlng, {
radius: geoJsonPoint.properties.radius,
})
}
if (pt === 'circlemarker') {
return L.circleMarker(latlng, {
radius: geoJsonPoint.properties.radius,
})
}
return L.marker(latlng)
},
}),
)
addNonGroupLayers(dataGroup, editableLayers)
fitMap()
} catch {
// Invalid JSON — ignore
}
}
function addNonGroupLayers(source, target) {
if (source instanceof L.LayerGroup) {
source.eachLayer((l) => addNonGroupLayers(l, target))
} else {
target.addLayer(source)
}
}
function fitMap() {
mapInstance.invalidateSize()
const bounds = editableLayers.getBounds()
if (bounds.isValid()) {
// fitBounds works for multi-point layers; for a single point the bounds
// have zero area so we zoom manually instead.
const ne = bounds.getNorthEast()
const sw = bounds.getSouthWest()
if (ne.equals(sw)) {
mapInstance.setView(ne, 14)
} else {
mapInstance.fitBounds(bounds, { padding: [50, 50] })
}
}
}
// ─── Actions ────────────────────────────────────────────────────────────────
function saveLocation() {
if (!editableLayers) {
showModal.value = false
return
}
const geoJson = editableLayers.toGeoJSON()
const hasFeatures = geoJson.features?.length > 0
emit('change', hasFeatures ? JSON.stringify(geoJson) : null)
showModal.value = false
}
function clearLocation() {
emit('change', null)
}
// ─── Cleanup ────────────────────────────────────────────────────────────────
function destroyMap() {
if (mapInstance) {
mapInstance.remove()
mapInstance = null
editableLayers = null
drawControl = null
}
}
onBeforeUnmount(() => destroyMap())
</script>

View File

@@ -0,0 +1,850 @@
<template>
<div class="flex flex-col flex-1 text-base">
<div v-if="label" class="mb-1.5 text-sm text-ink-gray-5">
{{ __(label) }}
</div>
<div
v-if="fields?.length"
class="rounded border border-outline-elevation-2"
>
<!-- Header -->
<div
class="grid-header flex items-center rounded-t-[7px] bg-surface-gray-2 text-ink-gray-5 truncate"
>
<div
class="inline-flex items-center justify-center border-r border-outline-gray-2 h-8 p-2 w-12"
>
<Checkbox
class="cursor-pointer duration-300"
:modelValue="allRowsSelected"
@click.stop="toggleSelectAllRows($event.target.checked)"
/>
</div>
<div
class="inline-flex items-center justify-center border-r border-outline-gray-2 py-2 px-1 w-12"
>
{{ __('Number') }}
</div>
<div
class="grid w-full truncate"
:style="{ gridTemplateColumns: gridTemplateColumns }"
>
<div
v-for="field in fields"
:key="field.fieldname"
class="border-r border-outline-gray-2 p-2 truncate"
:class="
['Int', 'Float', 'Currency', 'Percent'].includes(field.fieldtype)
? 'text-right'
: ''
"
:title="field.label"
>
{{ __(field.label) }}
<span
v-if="
field.reqd ||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
"
class="text-ink-red-5"
>*</span
>
</div>
</div>
<div class="flex items-center justify-center w-12">
<Button
:tooltip="__('Edit Grid Fields')"
class="rounded !bg-surface-gray-2 border-0 !text-ink-gray-5"
variant="outline"
icon="lucide-settings"
@click="showGridFieldsEditorModal = true"
/>
</div>
</div>
<!-- Rows -->
<template v-if="rows?.length">
<Draggable
v-model="rows"
class="w-full"
:delay="isTouchScreenDevice() ? 200 : 0"
group="rows"
item-key="name"
@end="reorder"
>
<template #item="{ element: row, index }">
<div
class="grid-row flex cursor-pointer items-center border-b border-outline-elevation-2 bg-surface-modals last:rounded-b last:border-b-0"
@click.stop="
() => {
if (!gridSettings.editable_grid) {
showRowList[index] = true
}
}
"
>
<div
class="grid-row-checkbox inline-flex h-9.5 items-center bg-surface-base justify-center border-r border-outline-elevation-2 p-2 w-12"
>
<Checkbox
class="cursor-pointer duration-300"
:modelValue="selectedRows.has(row.name)"
@click.stop="toggleSelectRow(row)"
/>
</div>
<div
class="flex h-9.5 items-center justify-center bg-surface-base border-r border-outline-elevation-2 py-2 px-1 text-sm text-ink-gray-8 w-12"
>
{{ index + 1 }}
</div>
<div
class="grid w-full h-9.5"
:style="{ gridTemplateColumns: gridTemplateColumns }"
>
<template
v-for="baseField in fields"
:key="baseField.fieldname"
>
<!-- Resolve per-row field overrides -->
<div
v-if="!getRowFieldObj(baseField, row).hidden"
class="border-r border-outline-elevation-2 h-9.5"
>
<template
v-for="field in [getRowFieldObj(baseField, row)]"
:key="field.fieldname + '-inner'"
>
<FormControl
v-if="
field.read_only &&
![
'Int',
'Float',
'Currency',
'Percent',
'Check',
'Duration',
'Rating',
'Button',
'Attach',
'Attach Image',
'HTML',
'Geolocation',
'Text Editor',
].includes(field.fieldtype)
"
v-model="row[field.fieldname]"
type="text"
:placeholder="field.placeholder"
:disabled="true"
/>
<Link
v-else-if="
['Link', 'Dynamic Link'].includes(field.fieldtype)
"
class="text-sm text-ink-gray-8"
:value="row[field.fieldname]"
:doctype="
field.fieldtype == 'Link'
? field.options
: row[field.options]
"
:filters="field.filters"
:disabled="Boolean(field.disabled)"
:onCreate="
(value, close) => field.create(v, field, row, close)
"
@change="(v) => fieldChange(v, field, row)"
/>
<Link
v-else-if="field.fieldtype === 'User'"
class="form-control"
:value="getUser(row[field.fieldname]).full_name"
:doctype="field.options"
:filters="field.filters"
:placeholder="field.placeholder"
:disabled="Boolean(field.disabled)"
:hideMe="true"
@change="(v) => fieldChange(v, field, row)"
>
<template #prefix>
<UserAvatar
class="mr-2"
:user="row[field.fieldname]"
size="sm"
/>
</template>
<template #item-prefix="{ option }">
<UserAvatar
class="mr-2"
:user="option.value"
size="sm"
/>
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer text-ink-gray-9">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
<div
v-else-if="field.fieldtype === 'Check'"
class="flex h-full bg-surface-base justify-center items-center"
>
<Checkbox
v-model="row[field.fieldname]"
class="cursor-pointer duration-300"
:disabled="
!gridSettings.editable_grid ||
Boolean(field.disabled)
"
@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]"
variant="outline"
: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]"
variant="outline"
:format="getFormat('', '', true, true, false)"
input-class="border-none text-sm text-ink-gray-8"
@change="(v) => fieldChange(v, field, row)"
/>
<FormControl
v-else-if="
['Small Text', 'Text', 'Long Text', 'Code'].includes(
field.fieldtype,
)
"
rows="1"
type="textarea"
variant="outline"
:value="row[field.fieldname]"
@change="fieldChange($event.target.value, field, row)"
/>
<FormControl
v-else-if="field.fieldtype === 'Select'"
v-model="row[field.fieldname]"
class="text-sm text-ink-gray-8"
type="select"
variant="outline"
:options="field.options"
:disabled="Boolean(field.disabled)"
@update:modelValue="(e) => fieldChange(e, field, row)"
/>
<Password
v-else-if="field.fieldtype === 'Password'"
variant="outline"
:value="row[field.fieldname]"
:disabled="Boolean(field.disabled)"
@change="fieldChange($event.target.value, field, row)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Int'"
class="[&_input]:text-right"
type="text"
variant="outline"
:value="row[field.fieldname] || '0'"
:disabled="Boolean(field.disabled)"
@change="fieldChange($event.target.value, field, row)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Percent'"
class="[&_input]:text-right"
type="text"
variant="outline"
:value="getFloatWithPrecision(field.fieldname, row)"
:formattedValue="(row[field.fieldname] || '0') + '%'"
:disabled="Boolean(field.disabled)"
@change="
fieldChange(flt($event.target.value), field, row)
"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Float'"
class="[&_input]:text-right"
type="text"
variant="outline"
:value="getFloatWithPrecision(field.fieldname, row)"
:formattedValue="row[field.fieldname]"
:disabled="Boolean(field.disabled)"
@change="
fieldChange(flt($event.target.value), field, row)
"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Currency'"
class="[&_input]:text-right"
type="text"
variant="outline"
:value="getCurrencyWithPrecision(field.fieldname, row)"
:formattedValue="
getFormattedCurrency(field.fieldname, row, parentDoc)
"
:disabled="Boolean(field.disabled)"
@change="
fieldChange(flt($event.target.value), field, row)
"
/>
<DurationInput
v-else-if="field.fieldtype === 'Duration'"
:value="row[field.fieldname]"
variant="outline"
:disabled="Boolean(field.disabled)"
@change="(v) => fieldChange(v, field, row)"
/>
<div
v-else-if="field.fieldtype === 'Rating'"
class="flex h-full w-full items-center overflow-hidden [&_::-webkit-scrollbar]:h-0"
>
<RatingInput
class="flex-nowrap overflow-x-auto px-2"
:value="row[field.fieldname]"
:disabled="Boolean(field.disabled)"
:max="field.options || 5"
@change="(v) => fieldChange(v, field, row)"
/>
</div>
<div
v-else-if="field.fieldtype === 'Button'"
class="flex items-center px-1 h-full"
>
<ButtonControl
class="button-control"
:label="field.label"
:icon="field.icon"
:theme="getButtonTheme(field.button_color)"
:variant="getButtonVariant(field.button_color)"
:disabled="Boolean(field.disabled)"
@click="handleButtonClick(field, row)"
/>
</div>
<div
v-else-if="
['Attach', 'Attach Image'].includes(field.fieldtype)
"
class="flex h-full w-full items-center"
>
<AttachControl
variant="ghost"
class="w-full"
:value="row[field.fieldname]"
:doctype="doctype"
:docname="row.name"
:fieldname="field.fieldname"
:imageOnly="field.fieldtype === 'Attach Image'"
:disabled="Boolean(field.disabled)"
@change="(v) => fieldChange(v, field, row)"
/>
</div>
<div
v-else-if="field.fieldtype === 'HTML'"
class="px-2 py-1 overflow-hidden"
>
<HtmlControl
:html="interpolateTemplate(field.options || '', row)"
/>
</div>
<div
v-else-if="field.fieldtype === 'Geolocation'"
class="flex h-full w-full items-center"
>
<GeolocationControl
variant="ghost"
class="w-full"
:value="row[field.fieldname]"
:disabled="Boolean(field.disabled)"
@change="(v) => fieldChange(v, field, row)"
/>
</div>
<div
v-else-if="field.fieldtype === 'Text Editor'"
class="flex h-full w-full items-center"
>
<TextEditorControl
variant="ghost"
size="sm"
:fixed-menu="false"
:bubble-menu="true"
editorClass="w-full !min-h-[38px] !h-[38px]"
:value="row[field.fieldname]"
:placeholder="field.placeholder"
:disabled="Boolean(field.disabled)"
@change="(v) => fieldChange(v, field, row)"
/>
</div>
<Combobox
v-else-if="field.fieldtype === 'Autocomplete'"
v-model="row[field.fieldname]"
class="combobox"
variant="outline"
:options="getOptions(field.options)"
:placeholder="field.placeholder"
:disabled="Boolean(field.disabled)"
@update:modelValue="(v) => fieldChange(v, field, row)"
/>
<FormControl
v-else
v-model="row[field.fieldname]"
class="text-sm text-ink-gray-8"
type="text"
variant="outline"
:options="field.options"
@change="fieldChange($event.target.value, field, row)"
/>
</template>
</div>
</template>
</div>
<div class="edit-row flex items-center justify-center w-12">
<Button
:tooltip="__('Edit Row')"
class="rounded border-0 !text-ink-gray-7"
variant="outline"
:icon="EditIcon"
@click="showRowList[index] = true"
/>
</div>
<GridRowModal
v-if="showRowList[index]"
v-model="showRowList[index]"
v-model:showGridRowFieldsModal="showGridRowFieldsModal"
:index="index"
:data="row"
:doctype="doctype"
:parentDoctype="parentDoctype"
:parentFieldname="parentFieldname"
/>
</div>
</template>
</Draggable>
</template>
<div
v-else
class="flex flex-col items-center rounded p-5 text-sm text-ink-gray-5"
>
{{ __('No Data') }}
</div>
</div>
<div v-if="fields?.length" class="mt-2 flex flex-row gap-2">
<Button
v-if="showDeleteBtn"
:label="__('Delete')"
variant="solid"
theme="red"
@click="deleteRows"
/>
<Button :label="__('Add Row')" @click="addRow" />
</div>
<GridRowFieldsModal
v-if="showGridRowFieldsModal"
v-model="showGridRowFieldsModal"
:doctype="doctype"
:parentDoctype="parentDoctype"
/>
<GridFieldsEditorModal
v-if="showGridFieldsEditorModal"
v-model="showGridFieldsEditorModal"
:doctype="doctype"
:parentDoctype="parentDoctype"
/>
</div>
</template>
<script setup>
import Password from '@/components/Controls/Password.vue'
import DurationInput from '@/components/Controls/DurationInput.vue'
import RatingInput from '@/components/Controls/RatingInput.vue'
import AttachControl from '@/components/Controls/AttachControl.vue'
import HtmlControl from '@/components/Controls/HtmlControl.vue'
import GeolocationControl from '@/components/Controls/GeolocationControl.vue'
import TextEditorControl from '@/components/Controls/TextEditorControl.vue'
import ButtonControl, {
getButtonTheme,
getButtonVariant,
} from '@/components/Controls/ButtonControl.vue'
import FormattedInput from '@/components/Controls/FormattedInput.vue'
import GridFieldsEditorModal from '@/components/Controls/GridFieldsEditorModal.vue'
import GridRowFieldsModal from '@/components/Controls/GridRowFieldsModal.vue'
import GridRowModal from '@/components/Controls/GridRowModal.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import Link from '@/components/Controls/Link.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import {
getRandom,
getFormat,
isTouchScreenDevice,
interpolateTemplate,
} from '@/utils'
import { flt } from '@/utils/numberFormat.js'
import { usersStore } from '@/stores/users'
import { getMeta } from '@/stores/meta'
import { parseLinkFilters } from '@/utils/fieldTransforms'
import { isFetchedFromLink } from '@/utils/fetchFrom'
import { createDocument } from '@/composables/document'
import {
FormControl,
Checkbox,
TimePicker,
DateTimePicker,
DatePicker,
Tooltip,
dayjs,
Combobox,
} from 'frappe-ui'
import Draggable from 'vuedraggable'
import { ref, reactive, computed, inject, provide } from 'vue'
const props = defineProps({
label: { type: String, default: '' },
doctype: { type: String, required: true },
parentDoctype: { type: String, required: true },
parentFieldname: { type: String, required: true },
overrides: { type: Object, default: () => ({}) },
})
const restrictedFieldTypes = [
'Section Break',
'Column Break',
'Tab Break',
'Table',
'Table MultiSelect',
'Image',
]
const triggerOnChange = inject('triggerOnChange', () => {})
const triggerButton = inject('triggerButton', () => {})
const triggerOnRowAdd = inject('triggerOnRowAdd', () => {})
const triggerOnRowRemove = inject('triggerOnRowRemove', () => {})
const parentFieldPropertyOverrides = inject(
'fieldPropertyOverrides',
computed(() => ({})),
)
const {
getGridViewSettings,
getFields,
getFloatWithPrecision,
getCurrencyWithPrecision,
getFormattedCurrency,
getGridSettings,
} = getMeta(props.doctype)
getMeta(props.parentDoctype)
const { users, getUser } = usersStore()
const rows = defineModel({ type: Array, default: () => [] })
const parentDoc = defineModel('parent', { type: Object, default: () => ({}) })
provide('parentDoc', parentDoc)
provide('fieldPropertyOverrides', parentFieldPropertyOverrides)
provide('parentFieldname', props.parentFieldname)
/**
* Resolve field overrides for a specific row.
* Priority: row-specific (products.qty:row_name) > column-level (products.qty) > base meta
*/
function getRowFieldObj(field, row) {
const ov = parentFieldPropertyOverrides.value || {}
const colKey = `${props.parentFieldname}.${field.fieldname}`
const rowKey = row?.name ? `${colKey}:${row.name}` : null
const colOverrides = ov[colKey]
const rowOverrides = rowKey ? ov[rowKey] : null
let merged = { ...field }
if (colOverrides) Object.assign(merged, colOverrides)
if (rowOverrides) Object.assign(merged, rowOverrides)
merged.disabled = Boolean(merged.read_only || isFetchedFromLink(merged, row))
return merged
}
const showRowList = ref(new Array(rows.value?.length || []).fill(false))
const selectedRows = reactive(new Set())
const showGridFieldsEditorModal = ref(false)
const showGridRowFieldsModal = ref(false)
const gridSettings = computed(() => getGridSettings())
const fields = computed(() => {
let gridViewSettings = getGridViewSettings(props.parentDoctype)
let gridFields = getFields({
restrictNoValueFields: false,
restrictedFieldTypes,
})
if (!gridFields?.length) return []
let processed
if (gridViewSettings.length) {
processed = gridViewSettings.map((gs) =>
getFieldObj(gridFields.find((f) => f.fieldname === gs.fieldname)),
)
} else {
processed =
gridFields?.filter((f) => f.in_list_view).map((f) => getFieldObj(f)) || []
}
// Filter out hidden columns (from script overrides)
return processed.filter((f) => !f.hidden)
})
const allFields = computed(() => {
return getFields()?.map((f) => getFieldObj(f)) || []
})
function getFieldObj(field) {
// Clone to avoid mutating cached meta
field = { ...field }
// Merge script property overrides (dot notation: parentFieldname.childFieldname)
const overrideKey = `${props.parentFieldname}.${field.fieldname}`
const scriptOverrides = parentFieldPropertyOverrides.value?.[overrideKey]
if (scriptOverrides) {
Object.assign(field, scriptOverrides)
}
if (field.fieldtype === 'Link' && field.options !== 'User') {
if (!field.create) {
field.create = (value, field, row, close) => {
const callback = (d) => {
if (d) fieldChange(d.name, field, row)
}
createDocument(field.options, value, close, callback)
}
}
}
if (field.fieldtype === 'Link' && field.options === 'User') {
field.fieldtype = 'User'
field.link_filters = JSON.stringify({
name: ['in', users.data.crmUsers?.map((user) => user.name)],
ignore_user_type: 1,
...(parseLinkFilters(field.link_filters) || {}),
})
}
const fieldObjWithFilters = {
...field,
filters: parseLinkFilters(field.link_filters),
placeholder: field.placeholder || field.label,
}
return {
...fieldObjWithFilters,
...props.overrides.fields?.find((f) => f.fieldname === field.fieldname),
}
}
const gridTemplateColumns = computed(() => {
if (!fields.value?.length) return '1fr'
let gridViewSettings = getGridViewSettings(props.parentDoctype)
return fields.value
.map((f) => {
const gs = gridViewSettings.length
? gridViewSettings.find((g) => g.fieldname === f.fieldname)
: f
return `minmax(0, ${gs?.columns || 2}fr)`
})
.join(' ')
})
const allRowsSelected = computed(() => {
if (!rows.value?.length) return false
return rows.value.length === selectedRows.size
})
const showDeleteBtn = computed(() => selectedRows.size > 0)
const toggleSelectAllRows = (iSelected) => {
if (iSelected) {
rows.value?.forEach((row) => selectedRows.add(row.name))
} else {
selectedRows.clear()
}
}
const toggleSelectRow = (row) => {
if (selectedRows.has(row.name)) {
selectedRows.delete(row.name)
} else {
selectedRows.add(row.name)
}
}
const addRow = () => {
const newRow = {}
allFields.value?.forEach((field) => {
if (field.fieldtype === 'Check') {
newRow[field.fieldname] = false
} else {
newRow[field.fieldname] = ''
}
if (field.default) {
newRow[field.fieldname] = getDefaultValue(field.default, field.fieldtype)
}
})
newRow.name = getRandom(10)
showRowList.value.push(false)
newRow['__islocal'] = true
newRow['idx'] = rows.value.length + 1
newRow['doctype'] = props.doctype
newRow['parentfield'] = props.parentFieldname
newRow['parenttype'] = props.parentDoctype
rows.value.push(newRow)
triggerOnRowAdd(newRow)
}
const deleteRows = () => {
rows.value = rows.value.filter((row) => !selectedRows.has(row.name))
triggerOnRowRemove(selectedRows, rows.value)
showRowList.value.pop()
selectedRows.clear()
}
const reorder = () => {
rows.value.forEach((row, index) => {
row.idx = index + 1
})
}
async function handleButtonClick(field, row) {
if (typeof field.click === 'function') {
await field.click(row)
} else {
await triggerButton(field.fieldname, row)
}
}
function fieldChange(value, field, row) {
value = Array.isArray(value)
? value
: typeof value === 'object' && value !== null && 'value' in value
? value.value
: value
triggerOnChange(field.fieldname, value, row)
}
function getDefaultValue(defaultValue, fieldtype) {
if (['Float', 'Currency', 'Percent'].includes(fieldtype)) {
return flt(defaultValue)
} else if (fieldtype === 'Check') {
if (['1', 'true', 'True'].includes(defaultValue)) {
return true
} else if (['0', 'false', 'False'].includes(defaultValue)) {
return false
}
} else if (fieldtype === 'Int') {
return parseInt(defaultValue)
} else if (defaultValue === 'Today' && fieldtype === 'Date') {
return dayjs().format('YYYY-MM-DD')
} else if (
['Now', 'now'].includes(defaultValue) &&
fieldtype === 'Datetime'
) {
return dayjs().format('YYYY-MM-DD HH:mm:ss')
} else if (['Now', 'now'].includes(defaultValue) && fieldtype === 'Time') {
return dayjs().format('HH:mm:ss')
} else if (fieldtype === 'Date') {
return dayjs(defaultValue).format('YYYY-MM-DD')
} else if (fieldtype === 'Datetime') {
return dayjs(defaultValue).format('YYYY-MM-DD HH:mm:ss')
} else if (fieldtype === 'Time') {
return dayjs(defaultValue).format('HH:mm:ss')
}
return defaultValue
}
const getOptions = (options) => {
if (Array.isArray(options)) {
return options
} else if (typeof options === 'string') {
return options.split('\n').map((option) => {
return { label: __(option), value: option }
})
} else {
return []
}
}
</script>
<style scoped>
/* For Input fields */
:deep(.grid-row input:not([type='checkbox'])),
:deep(.grid-row textarea) {
border: none;
border-radius: 0;
height: 38px;
}
:deep(.grid-row input:focus),
:deep(.grid-row input:hover),
:deep(.grid-row textarea:focus),
:deep(.grid-row textarea:hover) {
box-shadow: none;
}
:deep(.grid-row input:focus-within),
:deep(.grid-row textarea:focus-within) {
border: 1px solid var(--outline-gray-2);
}
/* For select field */
:deep(.grid-row select) {
border: none;
border-radius: 0;
height: 38px;
}
/* For Autocomplete, Link */
:deep(.grid-row button:not(.button-control):not(.rating-star)),
:deep(.grid-row .combobox > div > div) {
border: none;
border-radius: 0;
background-color: var(--surface-base);
height: 38px;
}
:deep(.grid-row:last-child .grid-row-checkbox) {
border-bottom-left-radius: 7px;
}
:deep(.grid-row .edit-row button) {
border-bottom-right-radius: 7px;
}
:deep(.grid-row button:focus),
:deep(.grid-row button:hover) {
box-shadow: none;
background-color: var(--surface-base);
}
:deep(.grid-row button:focus-within:not(.rating-star):not(.button-control)) {
border: 1px solid var(--outline-gray-2);
}
</style>

View File

@@ -0,0 +1,209 @@
<template>
<Dialog v-model:open="show">
<template #title>
<h3
class="flex items-center gap-2 text-3xl-semibold leading-6 text-ink-gray-9"
>
<div>{{ __('Edit Grid Fields Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h3>
</template>
<template #default>
<div class="mt-4">
<div class="text-base text-ink-gray-8 mb-2">
{{ __('Fields Order') }}
</div>
<Draggable
v-if="oldFields?.length"
:list="fields"
group="fields"
item-key="fieldname"
class="flex flex-col gap-1"
>
<template #item="{ element: field }">
<div
class="px-1 py-0.5 bg-surface-gray-2 border border-outline-elevation-2 rounded text-base text-ink-gray-8 flex items-center justify-between gap-2"
>
<div class="flex items-center gap-2">
<DragVerticalIcon class="h-3.5 cursor-grab" />
<div>{{ field.label }}</div>
</div>
<div class="flex items-center gap-2">
<TextInput
v-model="field.columns"
variant="outline"
type="number"
class="w-20"
/>
<Button
variant="ghost"
icon="lucide-x"
@click="removeField(field)"
/>
</div>
</div>
</template>
</Draggable>
<Combobox
v-if="dropdownFields?.length"
:model-value="null"
:options="dropdownFields"
@update:selected-option="(e) => addField(e)"
>
<template #trigger="{ open, setOpen }">
<Button
class="w-full mt-2"
:label="__('Add Field')"
iconLeft="plus"
@click="setOpen(!open)"
/>
</template>
<template #item-label="{ item }">
<div class="flex flex-col gap-1 text-ink-gray-9">
<div>{{ item.label }}</div>
<div class="text-ink-gray-4 text-sm">
{{ `${item.fieldname} - ${item.fieldtype}` }}
</div>
</div>
</template>
</Combobox>
<ErrorMessage v-if="error" class="mt-3" :message="error" />
</div>
</template>
<template #actions>
<div class="flex items-center gap-2 justify-end">
<Button
v-if="dirty"
class="w-full"
:label="__('Reset')"
@click="reset"
/>
<Button
class="w-full"
:label="__('Save')"
variant="solid"
:loading="loading"
:disabled="!dirty"
@click="update"
/>
</div>
</template>
</Dialog>
</template>
<script setup>
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
import { getMeta } from '@/stores/meta'
import Draggable from 'vuedraggable'
import { Combobox, Dialog, ErrorMessage } from 'frappe-ui'
import { ref, computed } from 'vue'
const props = defineProps({
doctype: { type: String, default: '' },
parentDoctype: { type: String, default: '' },
})
const restrictedFieldTypes = [
'Section Break',
'Column Break',
'Tab Break',
'Table',
'Table MultiSelect',
'Image',
]
const { getFields, getGridViewSettings, saveUserSettings } = getMeta(
props.doctype,
)
const show = defineModel({ type: Boolean })
const loading = ref(false)
const error = ref(null)
const dirty = computed(() => {
return JSON.stringify(fields.value) !== JSON.stringify(oldFields.value)
})
const oldFields = computed(() => {
let _fields =
getFields({ restrictNoValueFields: false, restrictedFieldTypes }) || []
if (!_fields.length) return []
let gridViewSettings = getGridViewSettings(props.parentDoctype)
if (gridViewSettings.length) {
return gridViewSettings.map((field) => {
let f = _fields.find((f) => f.fieldname === field.fieldname)
if (f) {
f.columns = field.columns
return fieldObj(f)
}
})
}
return _fields?.filter((field) => field.in_list_view).map((f) => fieldObj(f))
})
const fields = ref(JSON.parse(JSON.stringify(oldFields.value || [])))
const dropdownFields = computed(() => {
const _fields =
getFields({ restrictNoValueFields: false, restrictedFieldTypes }) || []
if (!_fields.length) return []
return _fields
.filter(
(field) => !fields.value.find((f) => f.fieldname === field.fieldname),
)
.map((field) => ({ ...field, value: field.fieldname }))
})
function reset() {
fields.value = JSON.parse(JSON.stringify(oldFields.value || []))
}
function addField(field) {
fields.value.push(fieldObj(field))
}
function removeField(field) {
const index = fields.value.findIndex((f) => f.fieldname === field.fieldname)
fields.value.splice(index, 1)
}
function update() {
loading.value = true
let updateFields = fields.value.map((field) => {
return {
fieldname: field.fieldname,
columns: field.columns,
}
})
if (updateFields.length === 0) {
error.value = __('At least one field is required')
return
}
saveUserSettings(props.parentDoctype, 'GridView', updateFields, () => {
loading.value = false
show.value = false
})
}
function fieldObj(field) {
return {
label: field.label,
fieldname: field.fieldname,
fieldtype: field.fieldtype,
options: field.options,
in_list_view: field.in_list_view,
columns: field.columns || 2,
}
}
</script>

View File

@@ -0,0 +1,129 @@
<template>
<Dialog v-model:open="show" :size="'4xl'">
<template #title>
<h3
class="flex items-center gap-2 text-3xl-semibold leading-6 text-ink-gray-9"
>
<div>{{ __('Edit Grid Row Fields Layout') }}</div>
<Badge
v-if="dirty"
:label="__('Not Saved')"
variant="subtle"
theme="orange"
/>
</h3>
</template>
<template #default>
<div class="flex flex-col gap-3">
<div class="flex justify-between gap-2">
<Button
:label="preview ? __('Hide Preview') : __('Show Preview')"
@click="preview = !preview"
/>
<div class="flex flex-row-reverse gap-2">
<Button
:loading="loading"
:label="__('Save')"
variant="solid"
@click="saveChanges"
/>
<Button :label="__('Reset')" @click="reload" />
</div>
</div>
<div v-if="tabs?.data">
<FieldLayoutEditor
v-if="!preview"
v-model="tabs.data"
:doctype="_doctype"
/>
<FieldLayout v-else :tabs="tabs.data" :data="{}" :preview="true" />
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import FieldLayoutEditor from '@/components/FieldLayoutEditor.vue'
import { useDebounceFn } from '@vueuse/core'
import { useTelemetry } from 'frappe-ui/frappe'
import { Dialog, Badge, call, createResource } from 'frappe-ui'
import { ref, watch, onMounted, nextTick } from 'vue'
const props = defineProps({
doctype: { type: String, default: 'CRM Lead' },
parentDoctype: { type: String, default: '' },
})
const emit = defineEmits(['reload'])
const { capture } = useTelemetry()
const show = defineModel({ type: Boolean })
const _doctype = ref(props.doctype)
const loading = ref(false)
const dirty = ref(false)
const preview = ref(false)
function getParams() {
return {
doctype: _doctype.value,
type: 'Grid Row',
parent_doctype: props.parentDoctype,
}
}
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['GridRowFieldsModal', _doctype.value, props.parentDoctype],
params: getParams(),
onSuccess(data) {
tabs.originalData = JSON.parse(JSON.stringify(data))
},
})
watch(
() => tabs?.data,
() => {
dirty.value =
JSON.stringify(tabs?.data) !== JSON.stringify(tabs?.originalData)
},
{ deep: true },
)
onMounted(() => useDebounceFn(reload, 100)())
function reload() {
nextTick(() => {
tabs.params = getParams()
tabs.reload()
})
}
function saveChanges() {
let _tabs = JSON.parse(JSON.stringify(tabs.data))
_tabs.forEach((tab) => {
if (!tab.sections) return
tab.sections.forEach((section) => {
section.columns.forEach((column) => {
if (!column.fields) return
column.fields = column.fields.map((field) => field.fieldname)
})
})
})
loading.value = true
call(
'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.save_fields_layout',
{
doctype: _doctype.value,
type: 'Grid Row',
layout: JSON.stringify(_tabs),
},
).then(() => {
loading.value = false
show.value = false
capture('data_fields_layout_builder', { doctype: _doctype.value })
emit('reload')
})
}
</script>

View File

@@ -0,0 +1,81 @@
<template>
<Dialog v-model:open="show" :size="'4xl'">
<template #body>
<div class="bg-surface-elevation-2 px-4 pb-6 pt-5 sm:px-6">
<div class="mb-5 flex items-center justify-between">
<div>
<h3 class="text-3xl-semibold leading-6 text-ink-gray-9">
{{ __('Editing Row {0}', [index + 1]) }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button
v-if="isManager()"
:tooltip="__('Edit Fields Layout')"
variant="ghost"
class="w-7"
:icon="EditIcon"
@click="openGridRowFieldsModal"
/>
<Button
icon="lucide-x"
variant="ghost"
class="w-7"
@click="show = false"
/>
</div>
</div>
<div>
<FieldLayout
v-if="tabs.data"
:tabs="tabs.data"
:data="data"
:doctype="doctype"
:isGridRow="true"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import EditIcon from '@/components/Icons/EditIcon.vue'
import FieldLayout from '@/components/FieldLayout/FieldLayout.vue'
import { usersStore } from '@/stores/users'
import { createResource } from 'frappe-ui'
import { nextTick, provide } from 'vue'
const props = defineProps({
index: { type: Number, default: 0 },
data: { type: Object, default: () => ({}) },
doctype: { type: String, default: '' },
parentDoctype: { type: String, default: '' },
parentFieldname: { type: String, default: '' },
})
const { isManager } = usersStore()
provide('parentFieldname', props.parentFieldname)
const show = defineModel({ type: Boolean })
const showGridRowFieldsModal = defineModel('showGridRowFieldsModal', {
type: Boolean,
})
const tabs = createResource({
url: 'crm.fcrm.doctype.crm_fields_layout.crm_fields_layout.get_fields_layout',
cache: ['Grid Row', props.doctype, props.parentDoctype],
params: {
doctype: props.doctype,
type: 'Grid Row',
parent_doctype: props.parentDoctype,
},
auto: true,
})
function openGridRowFieldsModal() {
showGridRowFieldsModal.value = true
nextTick(() => (show.value = false))
}
</script>

View File

@@ -0,0 +1,19 @@
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="html-control text-sm text-ink-gray-8" v-html="sanitizedHtml" />
</template>
<script setup>
import { sanitizeHTML } from '@/utils'
import { computed } from 'vue'
const props = defineProps({
html: { type: String, default: '' },
disabled: { type: Boolean, default: false },
})
const sanitizedHtml = computed(() => {
if (!props.html) return ''
return sanitizeHTML(props.html)
})
</script>

View File

@@ -0,0 +1,37 @@
<template>
<FileUploader
:file-types="image_type"
@success="(file) => emit('upload', file.file_url)"
>
<template #default="{ progress, uploading, openFileSelector }">
<div class="flex items-end space-x-1">
<Button
:iconLeft="uploading ? 'cloud-upload' : ImageUpIcon"
:label="
uploading
? __('Uploading {0}%', [progress])
: image_url
? __('Change')
: __('Upload')
"
@click="openFileSelector"
/>
<Button
v-if="image_url"
:label="__('Remove')"
@click="emit('remove')"
/>
</div>
</template>
</FileUploader>
</template>
<script setup>
import ImageUpIcon from '~icons/lucide/image-up'
import { FileUploader, Button } from 'frappe-ui'
defineProps({
image_url: { type: String, default: '' },
image_type: { type: String, default: 'image/*' },
})
const emit = defineEmits(['upload', 'remove'])
</script>

View File

@@ -0,0 +1,204 @@
<template>
<div class="space-y-1.5 p-[2px] !-m-[2px]">
<label v-if="attrs.label" class="block" :class="labelClasses">
{{ __(attrs.label) }}
</label>
<Autocomplete
ref="autocomplete"
v-model="value"
:options="options.data"
:size="attrs.size || 'sm'"
:variant="attrs.variant"
:placeholder="attrs.placeholder"
:disabled="attrs.disabled"
:placement="attrs.placement"
:filterable="false"
>
<template #target="{ open, togglePopover }">
<slot name="target" v-bind="{ open, togglePopover }" />
</template>
<template #prefix>
<slot name="prefix" />
</template>
<template #item-prefix="{ active, selected, option }">
<slot name="item-prefix" v-bind="{ active, selected, option }" />
</template>
<template #item-label="{ active, selected, option }">
<slot name="item-label" v-bind="{ active, selected, option }">
<div v-if="option.description" class="flex flex-col gap-1">
<div class="flex-1 font-semibold truncate text-ink-gray-7">
{{ option.label }}
</div>
<div class="flex-1 text-sm truncate text-ink-gray-5">
{{ option.description }}
</div>
</div>
<div v-else class="flex-1 truncate text-ink-gray-7">
{{ option.label }}
</div>
</slot>
</template>
<template #footer="{ value: v, close }">
<div v-if="attrs.onCreate">
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Create New')"
iconLeft="plus"
@click="() => attrs.onCreate(v, close)"
/>
</div>
<div>
<Button
variant="ghost"
class="w-full !justify-start"
:label="__('Clear')"
iconLeft="x"
@click="() => clearValue(close)"
/>
</div>
</template>
</Autocomplete>
</div>
</template>
<script setup>
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { isTranslatable } from '@/utils'
import { watchDebounced } from '@vueuse/core'
import { createResource } from 'frappe-ui'
import { useAttrs, computed, ref } from 'vue'
const props = defineProps({
doctype: { type: String, required: true },
filters: { type: [Array, Object, String], default: () => [] },
modelValue: { type: String, default: '' },
hideMe: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change'])
const attrs = useAttrs()
const valuePropPassed = computed(() => 'value' in attrs)
const value = computed({
get: () => {
let v = valuePropPassed.value ? attrs.value : props.modelValue
if (isTranslatable(props.doctype)) return __(v)
return v
},
set: (val) => {
return (
val?.value &&
emit(valuePropPassed.value ? 'change' : 'update:modelValue', val?.value)
)
},
})
const autocomplete = ref(null)
const text = ref('')
watchDebounced(
() => autocomplete.value?.query,
(val) => {
val = val || ''
if (text.value === val) return
text.value = val
reload(val)
},
{ debounce: 300, immediate: true },
)
watchDebounced(
() => props.doctype,
() => reload(''),
{ debounce: 300, immediate: true },
)
watchDebounced(
() => props.filters,
() => {
reload('', true)
},
{ debounce: 300, immediate: true },
)
const options = createResource({
url: 'frappe.desk.search.search_link',
cache: [props.doctype, text.value, props.hideMe, props.filters],
method: 'POST',
params: {
txt: text.value,
doctype: props.doctype,
filters: props.filters,
},
transform: (data) => {
let allData = data.map((option) => {
return {
label: option.label || option.value,
value: option.value,
description: stripHtml(option.description),
}
})
if (!props.hideMe && props.doctype == 'User') {
allData.unshift({
label: '@me',
value: '@me',
})
}
return allData
},
})
function stripHtml(html) {
if (!html) return ''
return html
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
function reload(val, force = false) {
if (!props.doctype) return
if (
!force &&
options.data?.length &&
val === options.params?.txt &&
props.doctype === options.params?.doctype
)
return
options.update({
params: {
txt: val,
doctype: props.doctype,
filters: props.filters,
},
})
options.reload()
}
function clearValue(close) {
emit(valuePropPassed.value ? 'change' : 'update:modelValue', '')
close()
}
const labelClasses = computed(() => {
return [
{
sm: 'text-xs',
md: 'text-base',
}[attrs.size || 'sm'],
'text-ink-gray-5',
]
})
defineExpose({ reload })
</script>

View File

@@ -0,0 +1,56 @@
<template>
<FormControl
:type="show ? 'text' : 'password'"
:value="modelValue || value"
v-bind="$attrs"
@keydown.meta.i.prevent="show = !show"
@keydown.ctrl.i.prevent="show = !show"
>
<template v-if="$slots.prefix" #prefix>
<slot name="prefix" />
</template>
<template #suffix>
<Tooltip>
<template #body>
<div
class="rounded bg-surface-gray-10 py-1.5 px-2 text-xs text-ink-base shadow-xl"
>
<span class="flex items-center gap-1">
{{ show ? __('Hide Password') : __('Show Password') }}
<KeyboardShortcut
bg
ctrl
class="!bg-surface-gray-8 !text-ink-gray-2 px-1"
>
<span class="font-mono leading-none tracking-widest">+I</span>
</KeyboardShortcut>
</span>
</div>
</template>
<div>
<FeatherIcon
v-show="showEye"
:name="show ? 'eye-off' : 'eye'"
class="h-3 cursor-pointer mr-1"
@click="show = !show"
/>
</div>
</Tooltip>
</template>
</FormControl>
</template>
<script setup>
import KeyboardShortcut from '@/components/KeyboardShortcut.vue'
import { FormControl, Tooltip } from 'frappe-ui'
import { ref, computed } from 'vue'
const props = defineProps({
modelValue: { type: [String, Number], default: '' },
value: { type: [String, Number], default: '' },
})
const show = ref(false)
const showEye = computed(() => {
let v = props.modelValue || props.value
return !v?.includes('*')
})
</script>

View File

@@ -0,0 +1,138 @@
<template>
<Tooltip :text="tooltipText" placement="right" :hoverDelay="0">
<div
class="rating-stars inline-flex flex-wrap items-center gap-0.5 transition-opacity"
:class="disabled ? 'opacity-50' : ''"
v-bind="$attrs"
@mouseleave="onMouseLeave"
>
<button
v-for="i in nStars"
:key="i"
type="button"
class="focus:outline-none leading-none rating-star"
:class="disabled ? 'cursor-default' : 'cursor-pointer'"
@mousemove="!disabled && onMouseMove($event, i)"
@click="!disabled && onStarClick($event, i)"
>
<!--
Three visual states during hover:
FILLED (yellow-500) — committed stars that are staying
PREVIEW (yellow-200) — stars that will be added on click
REMOVING(yellow-300) — committed stars that will be removed on click
-->
<svg viewBox="0 0 24 24" width="20" height="20" fill="none">
<path
d="M11.9987 3.00011C11.8207 3.00011 11.6428 3.09261 11.5509 3.27762L9.15562 8.09836C9.08253 8.24546 8.94185 8.34728 8.77927 8.37075L3.42887 9.14298C3.01771 9.20233 2.85405 9.70811 3.1525 9.99707L7.01978 13.7414C7.13858 13.8564 7.19283 14.0228 7.16469 14.1857L6.25116 19.4762C6.18071 19.8842 6.6083 20.1961 6.97531 20.0045L11.7672 17.5022C11.8397 17.4643 11.9192 17.4454 11.9987 17.4454V3.00011Z"
:style="{
fill: getHalfColor(i - 0.5),
stroke: getHalfColor(i - 0.5),
}"
/>
<path
d="M11.9987 3.00011C12.177 3.00011 12.3554 3.09303 12.4471 3.27888L14.8213 8.09112C14.8941 8.23872 15.0349 8.34102 15.1978 8.3647L20.5069 9.13641C20.917 9.19602 21.0807 9.69992 20.7841 9.9892L16.9421 13.7354C16.8243 13.8503 16.7706 14.0157 16.7984 14.1779L17.7053 19.4674C17.7753 19.8759 17.3466 20.1874 16.9798 19.9945L12.2314 17.4973C12.1586 17.459 12.0786 17.4398 11.9987 17.4398V3.00011Z"
:style="{ fill: getHalfColor(i), stroke: getHalfColor(i) }"
/>
</svg>
</button>
</div>
</Tooltip>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Tooltip } from 'frappe-ui'
const props = defineProps({
value: { type: Number, default: 0 },
max: { type: [Number, String], default: 5 },
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['change'])
const nStars = computed(() => Number(props.max) || 5)
const hoveredStarValue = ref(null)
// Saved value in star-units, rounded to nearest 0.5
const savedStarValue = computed(() => {
const raw = (props.value || 0) * nStars.value
return Math.round(raw * 2) / 2
})
// CSS custom properties — values defined in <style> block, dark mode overridden there
const FILLED = 'var(--rating-filled)'
const PREVIEW = 'var(--rating-preview)'
const REMOVING = 'var(--rating-removing)'
const EMPTY = 'var(--rating-empty)'
/**
* Each star is two half-values: (i - 0.5) for left, (i) for right.
* During hover we show three states so the saved rating stays readable:
* halves ≤ min(saved, hovered) → FILLED (committed & staying)
* halves > saved, ≤ hovered → PREVIEW (will be added)
* halves > hovered, ≤ saved → REMOVING (will be cleared)
* halves > max(saved, hovered)→ EMPTY
*/
function getHalfColor(halfValue) {
const saved = savedStarValue.value
const hovered = hoveredStarValue.value
if (hovered === null) {
return halfValue <= saved ? FILLED : EMPTY
}
if (halfValue <= Math.min(saved, hovered)) return FILLED
if (halfValue <= hovered) return PREVIEW
if (halfValue <= saved) return REMOVING
return EMPTY
}
// Tooltip text: in read-only mode shows saved value on hover;
// in interactive mode shows hovered preview value while hovering
const tooltipText = computed(() => {
const display = props.disabled ? savedStarValue.value : hoveredStarValue.value
if (!display) return null
const label = display % 1 === 0 ? String(display) : display.toFixed(1)
return `${label} / ${nStars.value}`
})
function onMouseMove(event, i) {
const rect = event.currentTarget.getBoundingClientRect()
hoveredStarValue.value =
event.clientX - rect.left < rect.width / 2 ? i - 0.5 : i
}
function onMouseLeave() {
hoveredStarValue.value = null
}
function onStarClick(event, i) {
const rect = event.currentTarget.getBoundingClientRect()
const isLeftHalf = event.clientX - rect.left < rect.width / 2
let newStarValue = isLeftHalf ? i - 0.5 : i
// Toggle off when clicking the already-set value
const currentStarValue = Math.round((props.value || 0) * nStars.value * 2) / 2
if (newStarValue === currentStarValue) newStarValue = 0
emit('change', newStarValue / nStars.value)
}
</script>
<style scoped>
/* Light mode */
.rating-stars {
--rating-filled: #eab308; /* yellow-500 */
--rating-preview: #fde68a; /* yellow-200 */
--rating-removing: #fcd34d; /* yellow-300 */
--rating-empty: #d1d5db; /* gray-300 */
}
/* Dark mode — [data-theme="dark"] is how frappe-ui activates dark mode */
[data-theme='dark'] .rating-stars {
--rating-filled: #eab308; /* yellow-500 — same, readable on dark */
--rating-preview: #fde68a; /* yellow-200 — bright preview on dark */
--rating-removing: #fcd34d; /* yellow-300 */
--rating-empty: #4b5563; /* gray-600 — visible on dark bg */
}
</style>

View File

@@ -0,0 +1,149 @@
<template>
<div>
<div
class="group flex flex-wrap gap-1 min-h-20 p-1.5 rounded text-base bg-surface-gray-2 hover:bg-surface-gray-3 focus:border-outline-gray-4 focus:ring-0 focus-visible:ring-2 focus-visible:ring-outline-gray-3 text-ink-gray-8 transition-colors w-full"
>
<Button
v-for="value in parsedValues"
ref="valuesRef"
:key="value"
:label="value"
theme="gray"
variant="subtle"
class="rounded bg-surface-base hover:!bg-surface-gray-1 focus-visible:ring-outline-gray-4"
@keydown.delete.capture.stop="removeLastValue"
>
<template #suffix>
<span
class="lucide-x h-3.5"
aria-hidden="true"
@click.stop="removeValue(value)"
/>
</template>
</Button>
<div class="w-full">
<Link
v-if="linkField"
class="form-control flex-1 truncate cursor-text"
:value="query"
:filters="filters"
:doctype="linkField.options"
:onCreate="create"
:hideMe="true"
@change="(v) => addValue(v)"
>
<template #target="{ togglePopover }">
<button
class="w-full h-7 cursor-text"
@click.stop="togglePopover"
/>
</template>
</Link>
</div>
</div>
<ErrorMessage v-if="error" class="mt-2 pl-2" :message="error" />
</div>
</template>
<script setup>
import Link from '@/components/Controls/Link.vue'
import { createDocument } from '@/composables/document'
import { getMeta } from '@/stores/meta'
import { ref, computed, nextTick } from 'vue'
const props = defineProps({
doctype: { type: String, required: true },
errorMessage: {
type: Function,
default: (value) => `${value} is an Invalid value`,
},
})
const emit = defineEmits(['change'])
const { getFields } = getMeta(props.doctype)
const values = defineModel({ type: Array, default: () => [] })
const valuesRef = ref([])
const error = ref(null)
const query = ref('')
const linkField = ref('')
const filters = computed(() => {
if (!linkField.value) return []
return { name: ['not in', parsedValues.value] }
})
const parsedValues = computed(() => {
getLinkField()
if (!linkField.value) return []
return values.value.map((row) => row[linkField.value.fieldname])
})
const getLinkField = () => {
error.value = ''
if (!linkField.value) {
let fields = getFields()
linkField.value = fields?.find((df) =>
['Link', 'User'].includes(df.fieldtype),
)
if (!linkField.value) {
error.value = __(
'Table MultiSelect requires a Table with at least one Link field',
)
}
}
return linkField.value
}
const addValue = (value) => {
error.value = null
if (values.value.some((row) => row[linkField.value.fieldname] === value)) {
error.value = __('Value already exists')
return
}
if (value) {
values.value.push({ [linkField.value.fieldname]: value })
emit('change', values.value)
if (!error.value) {
query.value = ''
}
}
}
const removeValue = (value) => {
let _value = values.value.filter(
(row) => row[linkField.value.fieldname] !== value,
)
emit('change', _value)
}
const removeLastValue = () => {
if (query.value) return
let valueRef = valuesRef.value[valuesRef.value.length - 1]?.$el
if (document.activeElement === valueRef) {
values.value.pop()
emit('change', values.value)
nextTick(() => {
if (values.value.length) {
valueRef = valuesRef.value[valuesRef.value.length - 1].$el
valueRef?.focus()
}
})
} else {
valueRef?.focus()
}
}
function create(value, close) {
const callback = (d) => {
if (d) addValue(d.name)
}
createDocument(linkField.value.options, value, close, callback)
}
</script>

View File

@@ -0,0 +1,116 @@
<template>
<Editor
v-model="content"
:extensions="extensions"
:placeholder="placeholder"
:editable="!disabled"
:upload-function="(file) => uploadFile(file)"
@change="onContentChange"
@blur="onBlur"
>
<EditorFixedMenu
v-if="fixedMenu && !disabled"
:items="fullToolbar"
class="w-full overflow-x-auto rounded-t-lg border border-outline-gray-2 p-1"
/>
<EditorBubbleMenu v-if="bubbleMenu" :items="bubbleToolbar" />
<EditorContent :class="editorClasses" />
</Editor>
</template>
<script setup>
import {
Editor,
EditorContent,
EditorFixedMenu,
EditorBubbleMenu,
} from 'frappe-ui/editor'
import {
buildEditorExtensions,
fullToolbar,
bubbleToolbar,
uploadFile,
} from '@/components/editor/config'
import { computed, ref, watch } from 'vue'
const props = defineProps({
value: { type: String, default: '' },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
editorClass: { type: String, default: '' },
variant: {
type: String,
default: 'subtle',
validator: (v) => ['outline', 'subtle', 'ghost'].includes(v),
},
size: {
type: String,
default: 'sm',
validator: (v) => ['sm', 'md', 'lg'].includes(v),
},
fixedMenu: { type: Boolean, default: true },
bubbleMenu: { type: Boolean, default: false },
})
const emit = defineEmits(['change'])
const editorClasses = computed(() => {
const defaultClass = 'max-h-[40vh] overflow-y-auto min-w-full'
const variantMap = {
outline: props.disabled
? 'border border-t rounded-lg bg-surface-base'
: 'border border-t-0 rounded-b-lg bg-surface-base',
subtle: props.disabled
? 'border border-t rounded-lg bg-surface-gray-2'
: 'border border-t-0 rounded-b-lg bg-surface-gray-2',
ghost: 'bg-transparent',
}
const variantClass = variantMap[props.variant]
const sizeMap = {
sm: props.disabled
? 'prose-sm min-h-[4rem] p-2'
: 'prose-sm min-h-[8rem] p-2',
md: props.disabled
? 'prose-base min-h-[4rem] p-3'
: 'prose-base min-h-[10rem] p-3',
lg: props.disabled
? 'prose-lg min-h-[4rem] p-4'
: 'prose-lg min-h-[12rem] p-4',
}
const sizeClasess = sizeMap[props.size]
const disableClass = props.disabled ? 'opacity-60' : ''
return [
variantClass,
sizeClasess,
defaultClass,
disableClass,
props.editorClass,
]
})
const extensions = buildEditorExtensions()
const content = ref(props.value ?? '')
const isDirty = ref(false)
watch(
() => props.value,
(val) => {
content.value = val ?? ''
},
)
function onContentChange(val) {
if (val !== (props.value ?? '')) isDirty.value = true
}
function onBlur() {
if (!isDirty.value) return
isDirty.value = false
emit('change', content.value)
}
</script>

View File

@@ -0,0 +1,82 @@
<template>
<slot />
</template>
<script setup>
import { ref } from 'vue'
const hours = ref(0)
const minutes = ref(0)
const seconds = ref(0)
const timer = ref(null)
const updatedTime = ref('0:00')
function startCounter() {
updatedTime.value = getTime()
}
function start() {
timer.value = setInterval(() => startCounter(), 1000)
}
function stop() {
clearInterval(timer.value)
let output = updatedTime.value
hours.value = 0
minutes.value = 0
seconds.value = 0
updatedTime.value = '0:00'
return output
}
function getTime(_seconds = 0) {
if (_seconds) {
if (typeof _seconds === 'string') {
_seconds = parseInt(_seconds)
}
seconds.value = _seconds
if (seconds.value >= 60) {
minutes.value = Math.floor(seconds.value / 60)
seconds.value = seconds.value % 60
} else {
minutes.value = 0
}
if (minutes.value >= 60) {
hours.value = Math.floor(minutes.value / 60)
minutes.value = minutes.value % 60
} else {
hours.value = 0
}
}
if (seconds.value === 59) {
seconds.value = 0
minutes.value = minutes.value + 1
seconds.value--
}
if (minutes.value === 60) {
minutes.value = 0
hours.value = hours.value + 1
}
seconds.value++
let minutesCount = minutes.value
let secondsCount = seconds.value < 10 ? '0' + seconds.value : seconds.value
let hoursCount = hours.value > 0 ? hours.value + ':' : ''
if (hoursCount) {
minutesCount = minutesCount < 10 ? '0' + minutesCount : minutesCount
secondsCount = secondsCount < 10 ? '0' + secondsCount : secondsCount
if (minutesCount === 0) {
minutesCount = '00'
}
}
return hoursCount + minutesCount + ':' + secondsCount
}
defineExpose({ start, stop, getTime, updatedTime })
</script>

View File

@@ -0,0 +1,105 @@
<template>
<template v-if="normalActions.length && !isMobileView">
<Button
v-for="action in normalActions"
:key="action.label"
:label="action.label"
@click="action.onClick(close)"
>
<template v-if="action.icon" #prefix>
<FeatherIcon :name="action.icon" class="h-4 w-4" />
</template>
</Button>
</template>
<Dropdown v-if="groupedActions.length" :options="groupedActions">
<Button icon="lucide-more-horizontal" label="" />
</Dropdown>
<template v-if="groupedWithLabelActions.length && !isMobileView">
<div v-for="g in groupedWithLabelActions" :key="g.label">
<Dropdown v-slot="{ open }" :options="g.action">
<Button
:label="g.label"
:iconRight="open ? 'chevron-up' : 'chevron-down'"
/>
</Dropdown>
</div>
</template>
</template>
<script setup>
import { computed } from 'vue'
import { Dropdown } from 'frappe-ui'
import { isMobileView } from '@/composables/settings'
const props = defineProps({
actions: { type: [Object, Array, undefined], default: () => [] },
close: { type: Function, default: () => {} },
})
const normalActions = computed(() => {
return props.actions.filter((action) => !action.group)
})
const groupedWithLabelActions = computed(() => {
let _actions = []
props.actions
.filter((action) => action.buttonLabel && action.group)
.forEach((action) => {
let groupIndex = _actions.findIndex((a) => a.label === action.buttonLabel)
action.items = action.items.map((item) => {
return {
...item,
onClick: () => item.onClick(props.close),
}
})
if (groupIndex > -1) {
_actions[groupIndex].action.push(action)
} else {
_actions.push({
label: action.buttonLabel,
action: [action],
})
}
})
return _actions
})
const groupedActions = computed(() => {
let _actions = []
let _normalActions = normalActions.value
if (isMobileView.value && _normalActions.length) {
_actions.push({
group: __('Actions'),
hideLabel: true,
items: _normalActions.map((action) => ({
label: action.label,
onClick: () => action.onClick(props.close),
icon: action.icon,
})),
})
}
if (isMobileView.value && groupedWithLabelActions.value.length) {
groupedWithLabelActions.value.map((group) => {
group.action.forEach((action) => _actions.push(action))
})
}
props.actions
.filter((action) => action.group && !action.buttonLabel)
.forEach((action) => {
action.items = action.items.map((item) => {
return {
...item,
onClick: () => item.onClick(props.close),
}
})
_actions.push(action)
})
return _actions
})
</script>

View File

@@ -0,0 +1,141 @@
<template>
<Dialog v-model:open="show" :title="__('Add Chart')" @close="show = false">
<template #default>
<div class="flex flex-col gap-4">
<FormControl
v-model="chartType"
type="select"
:label="__('Chart Type')"
:options="chartTypes"
/>
<FormControl
v-if="currentOptions.length"
v-model="selectedChart[chartType]"
type="select"
:label="currentTypeLabel"
:options="currentOptions"
/>
</div>
</template>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button variant="outline" :label="__('Cancel')" @click="show = false" />
<Button
variant="solid"
:label="__('Add')"
:disabled="!canAddChart"
@click="addChart"
/>
</div>
</template>
</Dialog>
</template>
<script setup lang="ts">
import { getRandom } from '@/utils'
import { chartTypes, chartOptionsByType } from '@/composables/dashboard'
import { createResource, Dialog, FormControl, toast } from 'frappe-ui'
import { ref, reactive, inject, computed, watch } from 'vue'
const show = defineModel({
type: Boolean,
default: false,
})
const items = defineModel('items', {
type: Array,
default: () => [],
})
const fromDate = inject('fromDate', ref(''))
const toDate = inject('toDate', ref(''))
const filters = inject('filters', reactive({ period: '', user: '' }))
const chartType = ref('spacer')
const selectedChart = reactive<Record<string, string>>({})
const currentOptions = computed(
() => chartOptionsByType.value[chartType.value] || [],
)
const currentTypeLabel = computed(
() =>
chartTypes.value.find((option) => option.value === chartType.value)
?.label || '',
)
watch(
currentOptions,
(options) => {
if (!options.length) return
const current = selectedChart[chartType.value]
if (!options.some((option) => option.value === current)) {
selectedChart[chartType.value] = options[0].value
}
},
{ immediate: true },
)
const canAddChart = computed(() => {
if (chartType.value === 'spacer') return true
return Boolean(selectedChart[chartType.value])
})
async function addChart() {
if (!canAddChart.value) return
show.value = false
if (chartType.value == 'spacer') {
items.value.push({
name: 'spacer',
type: 'spacer',
layout: { x: 0, y: 0, w: 4, h: 2, i: 'spacer_' + getRandom(4) },
})
} else {
await getChart(chartType.value)
}
}
async function getChart(type: string) {
const name = selectedChart[type]
if (!name) return
await createResource({
url: 'crm.api.dashboard.get_chart',
params: {
name,
type,
from_date: fromDate.value,
to_date: toDate.value,
user: filters.user,
},
auto: true,
onSuccess: (data = {}) => {
if (data?.error) {
toast.error(data.error)
return
}
let width = 4
let height = 2
if (['axis_chart', 'donut_chart'].includes(type)) {
width = 10
height = 7
}
items.value.push({
name,
type,
layout: {
x: 0,
y: 0,
w: width,
h: height,
i: name + '_' + getRandom(4),
},
data: data,
})
},
})
}
</script>

View File

@@ -0,0 +1,62 @@
<template>
<div class="flex-1 overflow-y-auto p-3">
<GridLayout
v-if="items.length > 0"
class="h-fit w-full"
:class="[editing ? 'mb-[20rem] !select-none' : '']"
:cols="20"
:rowHeight="42"
:disabled="!editing"
:modelValue="items.map((item) => item.layout)"
@update:modelValue="
(newLayout) => {
items.forEach((item, idx) => {
item.layout = newLayout[idx]
})
}
"
>
<template #item="{ index }">
<div class="group relative flex h-full w-full p-2 text-ink-gray-8">
<div
class="flex h-full w-full items-center justify-center"
:class="
editing
? 'pointer-events-none [&>div:first-child]:rounded [&>div:first-child]:group-hover:ring-2 [&>div:first-child]:group-hover:ring-outline-gray-2'
: ''
"
>
<DashboardItem
:index="index"
:item="items[index]"
:editing="editing"
/>
</div>
<div
v-if="editing"
class="flex absolute right-0 top-0 bg-surface-gray-9 rounded cursor-pointer opacity-0 group-hover:opacity-100"
>
<div
class="rounded p-1 hover:bg-surface-gray-8"
@click="items.splice(index, 1)"
>
<span
class="lucide-trash-2 size-3 text-ink-base"
aria-hidden="true"
/>
</div>
</div>
</div>
</template>
</GridLayout>
</div>
</template>
<script setup>
import { GridLayout } from 'frappe-ui'
defineProps({
editing: { type: Boolean, default: false },
})
const items = defineModel({ type: Array, default: () => [] })
</script>

View File

@@ -0,0 +1,45 @@
<template>
<div class="h-full w-full">
<div
v-if="item.type == 'number_chart'"
class="flex h-full w-full rounded shadow overflow-hidden cursor-pointer"
>
<Tooltip :text="__(item.data.tooltip)">
<NumberChart
v-if="item.data"
:key="index"
class="!items-start"
:config="item.data"
/>
</Tooltip>
</div>
<div
v-else-if="item.type == 'spacer'"
class="rounded bg-surface-base h-full overflow-hidden text-ink-gray-5 flex items-center justify-center"
:class="editing ? 'border border-dashed border-outline-gray-2' : ''"
>
{{ editing ? __('Spacer') : '' }}
</div>
<div
v-else-if="item.type == 'axis_chart'"
class="h-full w-full rounded-md bg-surface-base shadow"
>
<AxisChart v-if="item.data" :config="item.data" />
</div>
<div
v-else-if="item.type == 'donut_chart'"
class="h-full w-full rounded-md bg-surface-base shadow overflow-hidden"
>
<DonutChart v-if="item.data" :config="item.data" />
</div>
</div>
</template>
<script setup>
import { AxisChart, DonutChart, NumberChart, Tooltip } from 'frappe-ui'
defineProps({
index: { type: Number, required: true },
item: { type: Object, required: true },
editing: { type: Boolean, default: false },
})
</script>

View File

@@ -0,0 +1,280 @@
<template>
<Dialog v-model:open="show" :size="'xl'">
<template #body>
<div
v-if="!confirmDeleteInfo.show"
class="bg-surface-elevation-2 px-4 pb-6 pt-5 sm:px-6"
>
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-3xl-semibold leading-6 text-ink-gray-9">
{{
linkedDocs?.length == 0
? __('Delete')
: __('Delete or unlink linked documents')
}}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" icon="lucide-x" @click="show = false" />
</div>
</div>
<div>
<div v-if="linkedDocs?.length > 0">
<span class="text-ink-gray-5 text-base">
{{
__(
'Delete or unlink these linked documents before deleting this document',
)
}}
</span>
<LinkedDocsListView
class="mt-4"
:rows="linkedDocs"
:columns="[
{
label: 'Document',
key: 'title',
width: '19rem',
},
{
label: 'Master',
key: 'reference_doctype',
width: '12rem',
},
]"
:linkedDocsResource="linkedDocsResource"
:unlinkLinkedDoc="unlinkLinkedDoc"
@selectionsChanged="
(selections) => viewControls.updateSelections(selections)
"
/>
</div>
<div v-if="linkedDocs?.length == 0" class="text-ink-gray-5 text-base">
{{
__('Are you sure you want to delete {0} - {1}?', [
props.doctype,
props.docname,
])
}}
</div>
</div>
</div>
<div v-if="!confirmDeleteInfo.show" class="px-4 pb-7 pt-0 sm:px-6">
<div class="flex flex-row-reverse gap-2">
<Button
v-if="linkedDocs?.length > 0"
:label="
viewControls?.selections?.length == 0
? __('Delete All')
: __('Delete {0} Item(s)', [viewControls?.selections?.length])
"
theme="red"
variant="solid"
icon-left="trash-2"
@click="confirmDelete()"
/>
<Button
v-if="linkedDocs?.length > 0"
:label="
viewControls?.selections?.length == 0
? __('Unlink All')
: __('Unlink {0} Item(s)', [viewControls?.selections?.length])
"
variant="subtle"
theme="gray"
icon-left="unlock"
@click="confirmUnlink()"
/>
<Button
v-if="linkedDocs?.length == 0"
variant="solid"
icon-left="lucide-trash-2"
:label="__('Delete')"
:loading="isDealCreating"
theme="red"
@click="deleteDoc()"
/>
</div>
</div>
<div
v-if="confirmDeleteInfo.show"
class="bg-surface-elevation-2 px-4 pb-6 pt-5 sm:px-6"
>
<div class="mb-6 flex items-center justify-between">
<div>
<h3 class="text-3xl-semibold leading-6 text-ink-gray-9">
{{ confirmDeleteInfo.title }}
</h3>
</div>
<div class="flex items-center gap-1">
<Button variant="ghost" icon="lucide-x" @click="show = false" />
</div>
</div>
<div class="text-ink-gray-5 text-base">
{{ confirmDeleteInfo.message }}
</div>
<div class="flex justify-end gap-2 mt-6">
<Button variant="ghost" @click="cancel()">
{{ __('Cancel') }}
</Button>
<Button
variant="solid"
:label="confirmDeleteInfo.title"
theme="red"
@click="removeDocLinks()"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import { createResource, call, toast } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, ref } from 'vue'
import {
markDocumentAsDeleted,
unmarkDocumentAsDeleted,
expireDeletionMarker,
} from '@/data/document'
const show = defineModel({ type: Boolean })
const router = useRouter()
const props = defineProps({
name: { type: String, required: true },
doctype: { type: String, required: true },
docname: { type: String, required: true },
title: { type: String, default: null },
reload: { type: Function, default: null },
})
const viewControls = ref({
selections: [],
updateSelections: (selections) => {
viewControls.value.selections = Array.from(selections || [])
},
})
const confirmDeleteInfo = ref({
show: false,
title: '',
})
const linkedDocsResource = createResource({
url: 'crm.api.doc.get_linked_docs_of_document',
params: {
doctype: props.doctype,
docname: props.docname,
},
auto: true,
validate(params) {
if (!params?.doctype || !params?.docname) {
return false
}
},
})
const linkedDocs = computed(() => {
return (
linkedDocsResource.data?.map((doc) => ({
id: doc.reference_docname,
...doc,
})) || []
)
})
const cancel = () => {
confirmDeleteInfo.value.show = false
viewControls.value.updateSelections([])
}
const unlinkLinkedDoc = (doc) => {
let selectedDocs = []
if (viewControls.value.selections.length > 0) {
Array.from(viewControls.value.selections).forEach((selection) => {
const docData = linkedDocs.value.find((d) => d.id == selection)
selectedDocs.push({
doctype: docData.reference_doctype,
docname: docData.reference_docname,
})
})
} else {
selectedDocs = linkedDocs.value.map((doc) => ({
doctype: doc.reference_doctype,
docname: doc.reference_docname,
}))
}
call('crm.api.doc.remove_linked_doc_reference', {
items: selectedDocs,
remove_contact: props.doctype == 'Contact',
delete: doc.delete,
}).then(() => {
linkedDocsResource.reload()
confirmDeleteInfo.value = {
show: false,
title: '',
}
})
}
const confirmDelete = () => {
const items =
viewControls.value.selections.length == 0
? 'all'
: viewControls.value.selections.length
confirmDeleteInfo.value = {
show: true,
title: __('Delete Linked Item'),
message: __('Are you sure you want to delete {0} linked item(s)?', [items]),
delete: true,
}
}
const confirmUnlink = () => {
const items =
viewControls.value.selections.length == 0
? 'all'
: viewControls.value.selections.length
confirmDeleteInfo.value = {
show: true,
title: __('Unlink Linked Item'),
message: __('Are you sure you want to unlink {0} linked item(s)?', [items]),
delete: false,
}
}
const removeDocLinks = () => {
unlinkLinkedDoc({
reference_doctype: props.doctype,
reference_docname: props.docname,
delete: confirmDeleteInfo.value.delete,
})
viewControls.value.updateSelections([])
}
const deleteDoc = async () => {
// Mark before the request starts: the backend's delete_doc fires a
// realtime doc_update event that can reach the still-mounted document
// resource before this awaited call resolves on the frontend.
markDocumentAsDeleted(props.doctype, props.docname)
try {
await call('frappe.client.delete', {
doctype: props.doctype,
name: props.docname,
})
} catch (err) {
unmarkDocumentAsDeleted(props.doctype, props.docname)
throw err
}
expireDeletionMarker(props.doctype, props.docname)
const label = props.title
? `${props.docname} (${props.title})`
: props.docname
toast.success(__('{0} deleted successfully', [label]))
show.value = false
router.push({ name: props.name })
props?.reload?.()
}
</script>

View File

@@ -0,0 +1,348 @@
<template>
<Editor
ref="textEditor"
v-model="content"
:extensions="extensions"
:placeholder="placeholder"
:editable="editable"
:upload-function="(file) => uploadFile(file, doctype, modelValue.name)"
>
<div class="relative w-full">
<div class="flex flex-col gap-3">
<div
v-if="from.length"
class="mx-4 flex items-center gap-2 border-t pt-2.5 h-10"
>
<span class="text-xs text-ink-gray-4">{{ __('FROM') }}:</span>
<FormControl
v-model="fromEmail"
type="select"
variant="ghost"
class="w-full"
:placeholder="__('')"
:options="from"
/>
</div>
<div
class="mx-4 flex items-center gap-2"
:class="from.length ? '' : 'border-t pt-2.5'"
>
<span class="text-xs text-ink-gray-4 mr-2">{{ __('TO') }}:</span>
<EmailMultiSelect
v-model="toEmails"
class="flex-1"
variant="ghost"
:validate="validateEmail"
:fetchContacts="true"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
<div class="flex gap-1.5">
<Button
:label="__('CC')"
variant="ghost"
:class="[
cc
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
: '!text-ink-gray-4',
]"
@click="toggleCC()"
/>
<Button
:label="__('BCC')"
variant="ghost"
:class="[
bcc
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
: '!text-ink-gray-4',
]"
@click="toggleBCC()"
/>
</div>
</div>
<div v-if="cc" class="mx-4 flex items-center gap-2">
<span class="text-xs text-ink-gray-4">{{ __('CC') }}:</span>
<EmailMultiSelect
ref="ccInput"
v-model="ccEmails"
class="flex-1"
variant="ghost"
:fetchContacts="true"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
</div>
<div v-if="bcc" class="mx-4 flex items-center gap-2">
<span class="text-xs text-ink-gray-4">{{ __('BCC') }}:</span>
<EmailMultiSelect
ref="bccInput"
v-model="bccEmails"
class="flex-1"
variant="ghost"
:fetchContacts="true"
:validate="validateEmail"
:error-message="
(value) => __('{0} is an invalid email address', [value])
"
/>
</div>
<div class="mx-4 flex items-center gap-2 pb-2.5">
<span class="text-xs text-ink-gray-4">{{ __('SUBJECT') }}:</span>
<input
v-model="subject"
class="flex-1 border-none text-ink-gray-9 text-base bg-surface-base hover:bg-surface-base focus:border-none focus:!shadow-none focus-visible:!ring-0"
/>
</div>
</div>
<EditorContent
:class="[
'prose-sm max-w-none [&_p.reply-to-content]:hidden',
editable && 'mx-4 max-h-[35vh] overflow-y-auto border-t py-3',
]"
/>
<EditorTableMenu />
<div v-if="editable" class="flex flex-col gap-2">
<div class="flex flex-wrap gap-2 px-4">
<AttachmentItem
v-for="a in attachments"
:key="a.file_url"
:label="a.file_name"
>
<template #suffix>
<span
class="lucide-x h-3.5"
aria-hidden="true"
@click.stop="removeAttachment(a)"
/>
</template>
</AttachmentItem>
</div>
<div
class="flex justify-between gap-2 overflow-hidden border-t px-4 py-2.5"
>
<div class="flex gap-1 items-center overflow-x-auto">
<Button
:tooltip="__('Insert Email Template')"
variant="ghost"
:icon="EmailTemplateIcon"
@click="showEmailTemplateSelectorModal = true"
/>
<FileUploader
:upload-args="{
doctype: doctype,
docname: modelValue.name,
private: true,
}"
@success="(f) => attachments.push(f)"
>
<template #default="{ openFileSelector }">
<Button
:tooltip="__('Attach a File')"
:icon="AttachmentIcon"
variant="ghost"
@click="openFileSelector()"
/>
</template>
</FileUploader>
<EditorFixedMenu :items="fullToolbar" />
<IconPicker
v-slot="{ togglePopover }"
v-model="emoji"
@update:modelValue="() => appendEmoji()"
>
<Button
:tooltip="__('Insert Emoji')"
:icon="SmileIcon"
variant="ghost"
@click="togglePopover()"
/>
</IconPicker>
</div>
<div class="mt-2 flex items-center justify-end space-x-2 sm:mt-0">
<Button v-bind="discardButtonProps || {}" :label="__('Discard')" />
<Button
variant="solid"
v-bind="submitButtonProps || {}"
:label="`${__('Send')} (${submitShortcutLabel})`"
/>
</div>
</div>
</div>
</div>
</Editor>
<EmailTemplateSelectorModal
v-model="showEmailTemplateSelectorModal"
:doctype="doctype"
@apply="applyEmailTemplate"
/>
</template>
<script setup>
import IconPicker from '@/components/IconPicker.vue'
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 EmailMultiSelect from '@/components/Controls/EmailMultiSelect.vue'
import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue'
import {
buildEditorExtensions,
fullToolbar,
uploadFile,
} from '@/components/editor/config'
import { FileUploader, call, FormControl } from 'frappe-ui'
import {
Editor,
EditorContent,
EditorFixedMenu,
EditorTableMenu,
} from 'frappe-ui/editor'
import { useTelemetry } from 'frappe-ui/frappe'
import { useDocument } from '@/data/document'
import { validateEmail, submitShortcutLabel } from '@/utils'
import Paragraph from '@tiptap/extension-paragraph'
import { ref, computed, nextTick, inject, watch } from 'vue'
const props = defineProps({
placeholder: { type: String, default: null },
editable: { type: Boolean, default: true },
doctype: { type: String, default: 'CRM Lead' },
subject: { type: String, default: __('Email From Lead') },
editorProps: { type: Object, default: () => ({}) },
submitButtonProps: { type: Object, default: () => ({}) },
discardButtonProps: { type: Object, default: () => ({}) },
})
const CustomParagraph = Paragraph.extend({
addAttributes() {
return {
class: {
default: null,
renderHTML: (attributes) => {
if (!attributes.class) {
return {}
}
return {
class: `${attributes.class}`,
}
},
},
}
},
})
const modelValue = defineModel({ type: Object })
const attachments = defineModel('attachments', {
type: Array,
default: () => [],
})
const content = defineModel('content', { type: String, default: '' })
const { capture } = useTelemetry()
const { user: sessionUser } = inject('session')
const { document: user } = useDocument('User', sessionUser)
const textEditor = ref(null)
const cc = ref(false)
const bcc = ref(false)
const emoji = ref('')
const subject = ref(props.subject)
const fromEmail = ref('')
const toEmails = ref(modelValue.value.email ? [modelValue.value.email] : [])
const ccEmails = ref([])
const bccEmails = ref([])
const ccInput = ref(null)
const bccInput = ref(null)
const extensions = buildEditorExtensions({
starterKit: { paragraph: false },
extra: [CustomParagraph],
})
const from = computed(() => {
if (!user.doc || !user.doc.user_emails?.length) return []
let emails = user.doc.user_emails.map((e) => {
return {
label: e.email_account + ' <' + e.email_id + '>',
value: e.email_id,
}
})
if (emails.length == 1 && emails[0].email_id === sessionUser) return []
return emails
})
watch(
from,
(fromOptions) => {
if (!fromOptions.find((f) => f.value === fromEmail.value)) {
fromEmail.value = fromOptions.length ? fromOptions[0].value : ''
}
},
{ immediate: true },
)
const editor = computed(() => textEditor.value?.editor)
function removeAttachment(attachment) {
attachments.value = attachments.value.filter((a) => a !== attachment)
}
const showEmailTemplateSelectorModal = ref(false)
async function applyEmailTemplate(template) {
let doc = modelValue.value
let data = await call(
'frappe.email.doctype.email_template.email_template.get_email_template',
{
template_name: template.name,
// fields are the template context, so nesting doc lets {{ doc.field }} work too
doc: { ...doc, doc },
},
)
if (data.subject) {
subject.value = data.subject
}
if (data.message) {
content.value = data.message
}
showEmailTemplateSelectorModal.value = false
capture('email_template_applied', { doctype: props.doctype })
}
function appendEmoji() {
editor.value.commands.insertContent(emoji.value)
editor.value.commands.focus()
emoji.value = ''
capture('emoji_inserted_in_email', { emoji: emoji.value })
}
function toggleCC() {
cc.value = !cc.value
if (cc.value) nextTick(() => ccInput.value.setFocus())
}
function toggleBCC() {
bcc.value = !bcc.value
if (bcc.value) nextTick(() => bccInput.value.setFocus())
}
defineExpose({
editor,
subject,
cc,
bcc,
fromEmail,
toEmails,
ccEmails,
bccEmails,
})
</script>

View File

@@ -0,0 +1,98 @@
<template>
<!-- While running, the spinner replaces the icon (no crawl details). -->
<Button
:label="__('Enrich')"
:loading="running"
:loadingText="__('Enriching')"
:tooltip="running ? __('Enriching…') : __('Enrich from website')"
iconLeft="zap"
@click="enrich"
/>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { Button, call, toast } from 'frappe-ui'
import { useTelemetry } from 'frappe-ui/frappe'
import { globalStore } from '@/stores/global'
import { organizationsStore } from '@/stores/organizations'
const props = defineProps({
doctype: { type: String, required: true },
docname: { type: String, required: true },
website: { type: String, default: '' },
})
const emit = defineEmits(['done'])
const { $socket } = globalStore()
const { organizations } = organizationsStore()
const { capture } = useTelemetry()
const EVENT = 'domain_enrichment_progress'
const running = ref(false)
function isForThisDoc(data) {
return (
data &&
data.reference_doctype === props.doctype &&
data.reference_name === props.docname
)
}
function stop() {
running.value = false
$socket.off(EVENT, onProgress)
}
function onProgress(data) {
if (!isForThisDoc(data)) return
// Intermediate steps are intentionally ignored — the button shows only a
// spinner, never the crawl details. We act only on terminal states.
if (data.status === 'completed') {
stop()
// Re-validate the cached organizations store so list views (whose logos/names
// are read from it) reflect this update even if no list page was open to catch
// the event — otherwise navigating back to a list serves stale cached data.
organizations.reload()
const filled = (data.payload && data.payload.filled_fields) || []
const notes = (data.payload && data.payload.notes) || []
if (filled.length) {
toast.success(__('Enriched. Filled: {0}', [filled.join(', ')]))
} else if (notes.length) {
// Nothing extracted — explain why (blocked / JS-only site).
toast.warning(notes[0])
} else {
toast.success(__('Enrichment complete.'))
}
emit('done') // parent reloads the document + side panel — no manual refresh
} else if (data.status === 'error') {
stop()
toast.error(data.message || __('Enrichment failed.'))
}
}
async function enrich() {
if (!(props.website || '').trim()) {
toast.warning(__('Set a Website on this record before enriching.'))
return
}
capture('enrichment_triggered', { doctype: props.doctype })
running.value = true
// Subscribe before enqueueing so we never miss the completion event.
$socket.on(EVENT, onProgress)
try {
await call('crm.domain_enrichment.api.enrich', {
reference_doctype: props.doctype,
reference_name: props.docname,
})
} catch (error) {
stop()
toast.error(error.messages?.[0] || __('Could not start enrichment.'))
}
}
onBeforeUnmount(() => $socket.off(EVENT, onProgress))
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div
class="grid h-full place-items-center px-4 py-20 text-center text-lg text-ink-gray-5"
>
<div class="flex flex-col justify-between items-center gap-3">
<span
class="lucide-x-octagon h-12 w-12 text-ink-red-6"
aria-hidden="true"
/>
<div class="text-3xl-semibold">{{ errorTitle }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="sanitizeHTML(errorMessage)" />
</div>
</div>
</template>
<script setup>
import { sanitizeHTML } from '@/utils'
defineProps({
errorTitle: { type: String, required: true },
errorMessage: { type: String, required: true },
})
</script>

View File

@@ -0,0 +1,80 @@
<template>
<component
:is="props.as || 'div'"
ref="scrollableDiv"
:style="`maskImage: ${maskStyle}`"
@scroll="updateMaskStyle"
>
<slot></slot>
</component>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
const props = defineProps({
as: { type: String, default: 'div' },
maskLength: { type: Number, default: 30 },
orientation: { type: String, default: 'vertical' },
})
const scrollableDiv = ref(null)
const maskStyle = ref('none')
const side = computed(() =>
props.orientation == 'horizontal' ? 'right' : 'bottom',
)
function updateMaskStyle() {
if (!scrollableDiv.value) return
let scrollWidth = scrollableDiv.value.scrollWidth
let clientWidth = scrollableDiv.value.clientWidth
let scrollHeight = scrollableDiv.value.scrollHeight
let clientHeight = scrollableDiv.value.clientHeight
let scrollTop = scrollableDiv.value.scrollTop
let scrollLeft = scrollableDiv.value.scrollLeft
maskStyle.value = 'none'
// faded on both sides
if (
(side.value == 'right' && scrollWidth > clientWidth) ||
(side.value == 'bottom' && scrollHeight > clientHeight)
) {
maskStyle.value = `linear-gradient(to ${side.value}, transparent, black ${props.maskLength}px, black calc(100% - ${props.maskLength}px), transparent);`
}
// faded on left or top
if (
(side.value == 'right' && scrollLeft - 20 > clientWidth) ||
(side.value == 'bottom' && scrollTop + clientHeight >= scrollHeight)
) {
maskStyle.value = `linear-gradient(to ${side.value}, transparent, black ${props.maskLength}px, black 100%, transparent);`
}
// faded on right or bottom
if (
(side.value == 'right' && scrollLeft == 0) ||
(side.value == 'bottom' && scrollTop == 0)
) {
maskStyle.value = `linear-gradient(to ${side.value}, black calc(100% - ${props.maskLength}px), transparent 100%);`
}
if (
(side.value == 'right' && clientWidth == scrollWidth) ||
(side.value == 'bottom' && clientHeight == scrollHeight)
) {
maskStyle.value = 'none'
}
}
onMounted(() => setTimeout(() => updateMaskStyle(), 300))
</script>
<style scoped>
div {
scrollbar-width: none;
-ms-overflow-style: none;
}
div::-webkit-scrollbar {
display: none;
}
</style>

View File

@@ -0,0 +1,20 @@
<template>
<div class="column flex flex-col gap-4 min-w-0 flex-1">
<div
v-if="column.label && !column.hideLabel"
class="text-ink-gray-9 max-w-fit text-base"
>
{{ column.label }}
</div>
<template v-for="field in column.fields" :key="field.fieldname">
<Field :field="field" :data-name="field.fieldname" />
</template>
</div>
</template>
<script setup>
import Field from '@/components/FieldLayout/Field.vue'
defineProps({
column: { type: Object, required: true },
})
</script>

View File

@@ -0,0 +1,690 @@
<template>
<div v-if="field.visible" class="field">
<div
v-if="
field.fieldtype != 'Check' &&
field.fieldtype != 'Button' &&
field.fieldtype != 'HTML'
"
class="mb-2 text-sm text-ink-gray-5"
>
{{ __(field.label) }}
<span
v-if="
field.reqd ||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
"
class="text-ink-red-5"
>*</span
>
</div>
<FormControl
v-if="
(field.read_only || field.fieldtype === 'Read Only') &&
![
'Int',
'Float',
'Currency',
'Percent',
'Check',
'Duration',
'Rating',
'Button',
'Attach',
'Attach Image',
'HTML',
'Geolocation',
'Text Editor',
].includes(field.fieldtype)
"
v-model="data[field.fieldname]"
type="text"
:placeholder="getPlaceholder(field)"
:disabled="true"
:description="field.description"
/>
<Grid
v-else-if="field.fieldtype === 'Table'"
v-model="data[field.fieldname]"
v-model:parent="data"
:doctype="field.options"
:parentDoctype="doctype"
:parentFieldname="field.fieldname"
/>
<FormControl
v-else-if="field.fieldtype === 'Select'"
v-model="data[field.fieldname]"
type="select"
class="form-control"
:class="field.prefix ? 'prefix' : ''"
:options="field.options"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
:description="field.description"
@update:modelValue="(e) => fieldChange(e, field)"
>
<template v-if="field.prefix" #prefix>
<IndicatorIcon :class="field.prefix" />
</template>
</FormControl>
<div v-else-if="field.fieldtype == 'Check'" class="flex items-center gap-2">
<FormControl
v-model="data[field.fieldname]"
class="form-control"
type="checkbox"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="(e) => fieldChange(e.target.checked, field)"
/>
<label
class="text-sm text-ink-gray-5"
@click="
() => {
if (!Boolean(field.disabled)) {
data[field.fieldname] = !data[field.fieldname]
}
}
"
>
{{ __(field.label) }}
<span v-if="field.mandatory" class="text-ink-red-6">*</span>
</label>
</div>
<div
v-else-if="['Link', 'Dynamic Link'].includes(field.fieldtype)"
class="flex gap-1"
>
<Link
class="form-control flex-1 truncate"
:value="data[field.fieldname]"
:doctype="
field.fieldtype == 'Link' ? field.options : data[field.options]
"
:filters="field.filters"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
:onCreate="field.create"
@change="(v) => fieldChange(v, field)"
/>
<Button
v-if="data[field.fieldname] && field.edit"
class="shrink-0"
:label="__('Edit')"
:iconLeft="EditIcon"
@click="field.edit(data[field.fieldname])"
/>
</div>
<TableMultiselectInput
v-else-if="field.fieldtype === 'Table MultiSelect'"
v-model="data[field.fieldname]"
:doctype="field.options"
@change="(v) => fieldChange(v, field)"
/>
<Link
v-else-if="field.fieldtype === 'User'"
class="form-control"
:value="data[field.fieldname] && getUser(data[field.fieldname]).full_name"
:doctype="field.options"
:filters="field.filters"
:placeholder="getPlaceholder(field)"
:hideMe="true"
@change="(v) => fieldChange(v, field)"
>
<template #prefix>
<UserAvatar
v-if="data[field.fieldname]"
class="mr-2"
:user="data[field.fieldname]"
size="sm"
/>
</template>
<template #item-prefix="{ option }">
<UserAvatar class="mr-2" :user="option.value" size="sm" />
</template>
<template #item-label="{ option }">
<Tooltip :text="option.value">
<div class="cursor-pointer text-ink-gray-9">
{{ getUser(option.value).full_name }}
</div>
</Tooltip>
</template>
</Link>
<Combobox
v-else-if="field.fieldtype === 'Autocomplete'"
v-model="data[field.fieldname]"
:options="getAutocompleteOptions(field)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
@update:modelValue="(v) => fieldChange(v, field, data)"
/>
<TimePicker
v-else-if="field.fieldtype === 'Time'"
:value="data[field.fieldname]"
:format="getFormat('', '', false, true, false)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<DateTimePicker
v-else-if="field.fieldtype === 'Datetime'"
:value="data[field.fieldname]"
:format="getFormat('', '', true, true, false)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<DatePicker
v-else-if="field.fieldtype === 'Date'"
:value="data[field.fieldname]"
:format="getFormat('', '', true, false, false)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
input-class="border-none"
@change="(v) => fieldChange(v, field)"
/>
<FormControl
v-else-if="
['Small Text', 'Text', 'Long Text', 'Code'].includes(field.fieldtype)
"
type="textarea"
:value="data[field.fieldname]"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<Password
v-else-if="field.fieldtype === 'Password'"
:value="data[field.fieldname]"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Int'"
type="text"
:placeholder="getPlaceholder(field)"
:value="data[field.fieldname] || '0'"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Percent'"
type="text"
:value="getFormattedPercent(field.fieldname, data)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Float'"
type="text"
:value="getFormattedFloat(field.fieldname, data)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<FormattedInput
v-else-if="field.fieldtype === 'Currency'"
type="text"
:value="getFormattedCurrency(field.fieldname, data, parentDoc)"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="fieldChange(flt($event.target.value), field)"
/>
<DurationInput
v-else-if="field.fieldtype === 'Duration'"
:value="data[field.fieldname]"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="(v) => fieldChange(v, field)"
/>
<RatingInput
v-else-if="field.fieldtype === 'Rating'"
:value="data[field.fieldname]"
:max="field.options || 5"
:disabled="Boolean(field.disabled)"
@change="(v) => fieldChange(v, field)"
/>
<ButtonControl
v-else-if="field.fieldtype === 'Button'"
:label="field.label"
:icon="field.icon"
:theme="getButtonTheme(field.button_color)"
:variant="getButtonVariant(field.button_color)"
:disabled="Boolean(field.disabled)"
@click="handleButtonClick(field)"
/>
<AttachControl
v-else-if="['Attach', 'Attach Image'].includes(field.fieldtype)"
:value="data[field.fieldname]"
:doctype="doctype"
:docname="data.name"
:fieldname="field.fieldname"
:imageOnly="field.fieldtype === 'Attach Image'"
:disabled="Boolean(field.disabled)"
@change="(v) => fieldChange(v, field)"
/>
<HtmlControl v-else-if="field.fieldtype === 'HTML'" :html="resolvedHtml" />
<TextEditorControl
v-else-if="field.fieldtype === 'Text Editor'"
:value="data[field.fieldname]"
:placeholder="getPlaceholder(field)"
:disabled="Boolean(field.disabled)"
@change="(v) => fieldChange(v, field)"
/>
<GeolocationControl
v-else-if="field.fieldtype === 'Geolocation'"
:value="data[field.fieldname]"
:disabled="Boolean(field.disabled)"
@change="(v) => fieldChange(v, field)"
/>
<FormControl
v-else-if="field.options === 'Phone'"
type="text"
:placeholder="getPlaceholder(field)"
:value="data[field.fieldname]"
:disabled="Boolean(field.disabled)"
:description="field.description"
:error="
Boolean(data[field.fieldname]) && !validatePhone(data[field.fieldname])
? __('Enter a valid phone number')
: undefined
"
@change="fieldChange($event.target.value, field)"
/>
<div v-else class="flex items-center gap-1">
<FormControl
class="flex-1"
type="text"
:placeholder="getPlaceholder(field)"
:value="data[field.fieldname]"
:disabled="Boolean(field.disabled)"
:description="field.description"
@change="fieldChange($event.target.value, field)"
/>
<ArrowUpRightIcon
v-if="isExternalUrl(data[field.fieldname])"
class="h-4 w-4 shrink-0 cursor-pointer text-ink-gray-5 hover:text-ink-gray-8"
@click.stop="openExternalUrl(data[field.fieldname])"
/>
</div>
</div>
</template>
<script setup>
import Password from '@/components/Controls/Password.vue'
import FormattedInput from '@/components/Controls/FormattedInput.vue'
import DurationInput from '@/components/Controls/DurationInput.vue'
import RatingInput from '@/components/Controls/RatingInput.vue'
import AttachControl from '@/components/Controls/AttachControl.vue'
import HtmlControl from '@/components/Controls/HtmlControl.vue'
import TextEditorControl from '@/components/Controls/TextEditorControl.vue'
import GeolocationControl from '@/components/Controls/GeolocationControl.vue'
import ButtonControl, {
getButtonTheme,
getButtonVariant,
} from '@/components/Controls/ButtonControl.vue'
import EditIcon from '@/components/Icons/EditIcon.vue'
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
import UserAvatar from '@/components/UserAvatar.vue'
import TableMultiselectInput from '@/components/Controls/TableMultiselectInput.vue'
import Link from '@/components/Controls/Link.vue'
import Grid from '@/components/Controls/Grid.vue'
import { createDocument } from '@/composables/document'
import {
getFormat,
evaluateDependsOnValue,
isNull,
interpolateTemplate,
validatePhone,
} from '@/utils'
import { flt, formatNumber, formatCurrency } from '@/utils/numberFormat.js'
import { getMeta } from '@/stores/meta'
import {
parseLinkFilters,
applyStateFieldOptions,
} from '@/utils/fieldTransforms'
import { isFetchedFromLink } from '@/utils/fetchFrom'
import { usersStore } from '@/stores/users'
import { useDocument } from '@/data/document'
import {
Combobox,
Tooltip,
DatePicker,
DateTimePicker,
TimePicker,
} from 'frappe-ui'
import { computed, provide, inject, ref } from 'vue'
const props = defineProps({
field: { type: Object, required: true },
})
const data = inject('data')
const doctype = inject('doctype')
const docname = inject('docname', null)
const preview = inject('preview')
const isGridRow = inject('isGridRow')
// Guard getMeta — skip when doctype is empty (inline/standalone mode)
let getFormattedPercent, getFormattedFloat, getFormattedCurrency
if (doctype) {
;({ getFormattedPercent, getFormattedFloat, getFormattedCurrency } =
getMeta(doctype))
} else {
getFormattedPercent = (fn, doc) => formatNumber(doc[fn], '', null) + '%'
getFormattedFloat = (fn, doc) => formatNumber(doc[fn], '', null)
getFormattedCurrency = (fn, doc) =>
formatCurrency(doc[fn], '', window.sysdefaults?.currency || 'USD', null)
}
const { users, getUser } = usersStore()
let triggerOnChange
let triggerButton
let parentDoc
const formDocument = ref(null)
// Standalone mode: context injected from FieldLayout when context prop is set
const standaloneContext = inject('fieldLayoutContext', null)
if (standaloneContext) {
// Standalone mode — no useDocument, no scripting triggers
// Field changes update data directly
triggerOnChange = async (fieldname, value, row) => {
if (row) {
row[fieldname] = value
} else {
data.value[fieldname] = value
}
}
triggerButton = async () => {}
formDocument.value = standaloneContext
// Provide no-op triggers for child Grid components
provide('triggerOnChange', triggerOnChange)
provide('triggerButton', triggerButton)
provide('triggerOnRowAdd', async () => {})
provide('triggerOnRowRemove', async () => {})
provide(
'fieldPropertyOverrides',
computed(() => standaloneContext?.fieldPropertyOverrides || {}),
)
} else if (!isGridRow) {
// Bind to the document by its authoritative name (injected from FieldLayout),
// falling back to data.name. Using data.name alone breaks the first edit of a
// freshly-loaded doc: name is still empty, so changes write to the wrong
// (new-document) cache slot and the first save serializes the pristine doc.
const resolvedName = docname != null ? docname.value : data.value.name
const {
triggerOnChange: trigger,
triggerButton: triggerBtn,
triggerOnRowAdd,
triggerOnRowRemove,
document: doc,
} = useDocument(doctype, resolvedName)
triggerOnChange = trigger
triggerButton = triggerBtn
formDocument.value = doc
provide('triggerOnChange', triggerOnChange)
provide('triggerButton', triggerButton)
provide('triggerOnRowAdd', triggerOnRowAdd)
provide('triggerOnRowRemove', triggerOnRowRemove)
provide(
'fieldPropertyOverrides',
computed(() => formDocument.value?.fieldPropertyOverrides || {}),
)
} else {
triggerOnChange = inject('triggerOnChange', () => {})
triggerButton = inject('triggerButton', () => {})
parentDoc = inject('parentDoc')
}
// For grid rows: inject overrides provided by Grid.vue
const injectedOverrides = inject(
'fieldPropertyOverrides',
computed(() => ({})),
)
const injectedParentFieldname = inject('parentFieldname', '')
/**
* Resolve field property overrides.
* For grid row fields, uses dot notation (parentfield.childfield)
* with per-row support (parentfield.childfield:rowName).
* For normal fields, reads directly from formDocument.
*/
function getFieldOverrides(fieldname) {
if (isGridRow) {
const ov = injectedOverrides.value || {}
const pf = injectedParentFieldname
if (!pf) return undefined
const colKey = `${pf}.${fieldname}`
const rowName = data.value?.name
const rowKey = rowName ? `${colKey}:${rowName}` : null
const colOv = ov[colKey]
const rowOv = rowKey ? ov[rowKey] : null
if (!colOv && !rowOv) return undefined
return { ...(colOv || {}), ...(rowOv || {}) }
}
return formDocument.value?.fieldPropertyOverrides?.[fieldname]
}
const field = computed(() => {
let field = { ...props.field }
// ── Script property overrides ──
const overrides = getFieldOverrides(field.fieldname)
if (overrides) {
Object.assign(field, overrides)
}
// ── Country-driven state dropdown (e.g. India Compliance installed) ──
field = applyStateFieldOptions(
field,
data.value,
doctype,
window.state_options,
)
if (field.fieldtype == 'Select' && typeof field.options === 'string') {
field.options = field.options.split('\n').map((option) => {
return { label: __(option), value: option }
})
if (field.options[0].value !== '' && !field.reqd) {
field.options.unshift({ label: '', value: '' })
}
}
if (field.fieldtype === 'Link' && field.options === 'User') {
field.fieldtype = 'User'
field.link_filters = JSON.stringify({
name: ['in', users.data.crmUsers?.map((user) => user.name)],
ignore_user_type: 1,
...(parseLinkFilters(field.link_filters) || {}),
})
}
if (field.fieldtype === 'Link' && field.options !== 'User') {
if (!field.create) {
field.create = (value, close) => {
const callback = (d) => {
if (d) fieldChange(d.name, field)
}
createDocument(field.options, value, close, callback)
}
}
}
const read_only_via_depends_on = evaluateDependsOnValue(
field.read_only_depends_on,
data.value,
)
// Script overrides for read_only take priority over depends_on
const scriptReadOnly = overrides?.read_only
const effectiveReadOnly =
scriptReadOnly !== undefined
? scriptReadOnly
: field.read_only ||
(field.read_only_depends_on && read_only_via_depends_on)
// Script overrides for depends_on visibility
const scriptHidden = overrides?.hidden
const displayViaDependsOn = evaluateDependsOnValue(
field.depends_on,
data.value,
)
let _field = {
...field,
filters: parseLinkFilters(field.link_filters),
placeholder: field.placeholder || field.label,
display_via_depends_on: displayViaDependsOn,
mandatory_via_depends_on: evaluateDependsOnValue(
field.mandatory_depends_on,
data.value,
),
read_only: effectiveReadOnly,
// separate from read_only because isFieldVisible hides empty read-only fields
disabled: Boolean(
effectiveReadOnly || isFetchedFromLink(field, data.value),
),
}
_field.visible = isFieldVisible(_field, scriptHidden)
return _field
})
function isFieldVisible(field, scriptHidden) {
if (preview.value) return true
// Script override for hidden wins over everything
if (scriptHidden !== undefined) return !scriptHidden
let readOnlyField =
field.read_only || field.fieldtype === 'Read Only' ? true : false
let hideEmptyReadOnlyField =
isNull(data.value[field.fieldname]) &&
Number(window.sysdefaults?.hide_empty_read_only_fields ?? 1)
let showReadOnlyField = readOnlyField && !hideEmptyReadOnlyField
return (
(field.fieldtype == 'Check' || showReadOnlyField || !readOnlyField) &&
(!field.depends_on || field.display_via_depends_on) &&
!field.hidden
)
}
const resolvedHtml = computed(() => {
if (field.value.fieldtype !== 'HTML') return ''
const injected = formDocument.value?.fieldHtmlMap?.[field.value.fieldname]
if (injected !== undefined) return injected
return interpolateTemplate(field.value.options || '', data.value)
})
const getPlaceholder = (field) => {
if (field.placeholder) {
return __(field.placeholder)
}
if (['Select', 'Link'].includes(field.fieldtype)) {
return __('Select {0}', [__(field.label)])
} else {
return __('Enter {0}', [__(field.label)])
}
}
const getOptions = (options) => {
if (Array.isArray(options)) {
return options
} else if (typeof options === 'string') {
return options.split('\n').map((option) => {
return { label: __(option), value: option }
})
} else {
return []
}
}
const getAutocompleteOptions = (field) => {
const options = getOptions(field.options)
return [
...options,
{
type: 'custom',
key: '__custom_value',
label: __('Use custom value'),
slots: {
label: ({ query }) => __('Use "{0}"', [query.trim()]),
},
condition: ({ query }) => {
const q = (query || '').trim()
if (!q) return false
return !options.some((opt) => {
const isObject = opt !== null && typeof opt === 'object'
const value = isObject ? opt.value : opt
const label = isObject ? opt.label : opt
return String(value ?? '') === q || String(label ?? '') === q
})
},
onClick: ({ query }) => {
data.value[field.fieldname] = query.trim()
fieldChange(query.trim(), field)
},
},
]
}
function isExternalUrl(value) {
return typeof value === 'string' && /^https?:\/\//i.test(value.trim())
}
function openExternalUrl(value) {
window.open(value.trim(), '_blank', 'noopener,noreferrer')
}
async function handleButtonClick(field) {
if (typeof field.click === 'function') {
return await field.click(data.value)
} else {
return await triggerButton(field.fieldname)
}
}
async function fieldChange(value, df) {
value = Array.isArray(value)
? value
: typeof value === 'object' && value !== null && 'value' in value
? value.value
: value
if (isGridRow) {
await triggerOnChange(df.fieldname, value, data.value)
} else {
await triggerOnChange(df.fieldname, value)
}
}
</script>
<style scoped>
:deep(.form-control.prefix select) {
padding-left: 2rem;
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<div
class="flex flex-col"
:class="{
'border border-outline-gray-1 rounded-lg': hasTabs,
'border-outline-elevation-2': hasTabs,
}"
>
<Tabs
v-model="selectedTabIndex"
as="div"
:tabs="processedTabs"
:class="[
!hasTabs ? `[&_[role='tablist']]:hidden` : '',
`[&_[role='tablist']::-webkit-scrollbar]:h-0 [&_[role='tab']]:shrink-0 [&_[role='tabpanel']]:overflow-visible !overflow-visible`,
]"
>
<template #tab-panel="{ tab }">
<div class="sections" :class="{ 'my-4 sm:my-5': hasTabs }">
<template v-for="section in tab.sections" :key="section.name">
<Section :section="section" :data-name="section.name" />
</template>
</div>
</template>
</Tabs>
</div>
</template>
<script setup>
import Section from '@/components/FieldLayout/Section.vue'
import { useDocument } from '@/data/document'
import { Tabs } from 'frappe-ui'
import { computed, provide, watch } from 'vue'
const props = defineProps({
tabs: { type: Array, default: () => [] },
data: { type: Object, default: () => ({}) },
doctype: { type: String, default: 'CRM Lead' },
docname: { type: String, default: '' },
isGridRow: { type: Boolean, default: false },
preview: { type: Boolean, default: false },
context: { type: Object, default: null },
})
const tabIndex = defineModel('tabIndex', { type: Number, default: 0 })
const tabName = defineModel('tabName', { type: String, default: '' })
// The authoritative document name. Prefer the explicit docname prop (known
// synchronously by the parent modal) over data.name, which is empty while the
// document is still loading and would bind field changes to the wrong cache.
const resolvedDocname = computed(() => props.docname || props.data?.name || '')
// Get fieldPropertyOverrides for tab/section overrides
let overrides = {}
if (props.context) {
// Standalone mode: use externally managed context, skip useDocument
overrides = computed(() => props.context?.fieldPropertyOverrides || {})
} else if (!props.isGridRow) {
const { document: doc } = useDocument(props.doctype, resolvedDocname.value)
overrides = computed(() => doc?.fieldPropertyOverrides || {})
} else {
overrides = computed(() => ({}))
}
const processedTabs = computed(() => {
const ov = overrides.value
return props.tabs
.map((tab) => {
const tabOverrides = ov[tab.name]
const processedTab = tabOverrides ? { ...tab, ...tabOverrides } : tab
return {
...processedTab,
sections: processedTab.sections.map((section) => {
const sectionOverrides = ov[section.name]
return sectionOverrides
? { ...section, ...sectionOverrides }
: section
}),
}
})
.filter((tab) => !tab.hidden)
})
const hasTabs = computed(() => {
return (
processedTabs.value.length > 1 ||
(processedTabs.value.length == 1 && processedTabs.value[0].label)
)
})
const selectedTabIndex = computed({
get() {
if (tabName.value) {
const namedTabIndex = processedTabs.value.findIndex(
(tab) => tab.name === tabName.value,
)
if (namedTabIndex !== -1) return namedTabIndex
}
return tabIndex.value
},
set(value) {
tabIndex.value = value
tabName.value = processedTabs.value[value]?.name || ''
},
})
watch(
processedTabs,
(tabs) => {
if (!tabs.length) {
tabIndex.value = 0
tabName.value = ''
return
}
if (tabName.value && tabs.some((tab) => tab.name === tabName.value)) {
tabIndex.value = tabs.findIndex((tab) => tab.name === tabName.value)
return
}
if (tabIndex.value >= tabs.length) {
tabIndex.value = tabs.length - 1
}
tabName.value = tabs[tabIndex.value]?.name || ''
},
{ immediate: true },
)
provide(
'data',
computed(() => props.data),
)
provide('hasTabs', hasTabs)
provide('doctype', props.doctype)
provide('docname', resolvedDocname)
provide('preview', props.preview)
provide('isGridRow', props.isGridRow)
provide('fieldLayoutContext', props.context)
</script>
<style scoped>
.section:not(:has(.field)) {
display: none;
}
.section:has(.field):nth-child(1 of .section:has(.field)) {
border-top: none;
margin-top: 0;
padding-top: 0;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div
v-if="!section.hidden"
class="section"
:class="[
section.hideBorder
? 'pt-4'
: 'border-t border-outline-elevation-2 mt-5 pt-5',
]"
>
<CollapsibleSection
class="flex sm:flex-row flex-col gap-4 text-lg-medium"
:class="{ 'px-3 sm:px-5': hasTabs }"
:labelClass="['text-lg font-medium', { 'px-3 sm:px-5': hasTabs }]"
:label="section.label"
:hideLabel="section.hideLabel || !section.label"
:opened="section.opened"
:collapsible="section.collapsible"
collapseIconPosition="right"
>
<template v-for="column in section.columns" :key="column.name">
<Column
:class="{ 'mt-6': section.label && !section.hideLabel }"
:column="column"
:data-name="column.name"
/>
</template>
</CollapsibleSection>
</div>
</template>
<script setup>
import CollapsibleSection from '@/components/CollapsibleSection.vue'
import Column from '@/components/FieldLayout/Column.vue'
import { inject } from 'vue'
defineProps({
section: { type: Object, required: true },
})
const hasTabs = inject('hasTabs')
</script>

View File

@@ -0,0 +1,677 @@
<template>
<div class="flex flex-col gap-5.5">
<div
class="flex items-center justify-between gap-2 text-base bg-surface-gray-2 rounded py-2 px-2.5 overflow-x-auto max-w-full"
>
<Draggable
v-if="tabs.length && tabs[tabIndex].label"
:list="tabs"
item-key="name"
class="flex items-center gap-2 w-full overflow-auto py-1 [&::-webkit-scrollbar]:h-0"
@end="(e) => (tabIndex = e.newIndex)"
>
<template #item="{ element: tab, index: i }">
<div
:ref="
(el) => {
if (el) tabItemRefs[i] = el
}
"
class="flex items-center gap-2 cursor-pointer rounded shrink-0"
:class="[
tabIndex == i
? 'text-ink-gray-9 bg-surface-base shadow-sm'
: 'text-ink-gray-5 hover:text-ink-gray-9 hover:bg-surface-base hover:shadow-sm',
tab.editingLabel ? 'p-1' : 'px-2 py-1',
]"
@click="tabIndex = i"
@dragenter.prevent="isDragging && (tabIndex = i)"
>
<div @dblclick="() => (tab.editingLabel = true)">
<div v-if="!tab.editingLabel" class="flex items-center gap-2">
{{ __(tab.label) || __('Untitled') }}
</div>
<div v-else class="flex gap-1 items-center">
<Input
v-model="tab.label"
@keydown.enter="tab.editingLabel = false"
@blur="tab.editingLabel = false"
@click.stop
/>
<Button
v-if="tab.editingLabel"
icon="lucide-check"
variant="ghost"
@click="tab.editingLabel = false"
/>
</div>
</div>
<Dropdown
v-if="tab.label && tabIndex == i"
:options="getTabOptions(tab)"
class="!h-4"
@click.stop
>
<template #default>
<Button variant="ghost" class="!p-1 !h-4">
<span class="lucide-more-horizontal h-4" aria-hidden="true" />
</Button>
</template>
</Dropdown>
</div>
</template>
</Draggable>
<Button
variant="ghost"
class="!h-6.5 !text-ink-gray-5 hover:!text-ink-gray-9"
:label="__('Add Tab')"
@click="addTab"
>
<template #[slotName]>
<span class="lucide-plus h-4" aria-hidden="true" />
</template>
</Button>
</div>
<div
v-for="(tab, i) in tabs"
v-show="tabIndex == i"
:key="tab.name"
class="min-h-[34rem]"
>
<Draggable
:list="tab.sections"
item-key="name"
:group="
tab.sections.length === 0
? { name: 'sections', put: ['sections', 'columns', 'fields'] }
: 'sections'
"
handle=".section-drag-handle"
:class="
tab.sections.length === 0
? 'rounded border-2 border-dashed border-outline-gray-2 mb-5.5 p-3'
: 'flex flex-col gap-5.5'
"
@start="isDragging = true"
@end="isDragging = false"
@add="(evt) => onFieldDroppedToEmptyTab(tab)"
>
<template #item="{ element: section }">
<div
v-if="Array.isArray(section.columns)"
class="section flex flex-col gap-1.5 p-2.5 bg-surface-gray-2 rounded"
>
<div class="flex items-center justify-between">
<div
class="flex h-7 max-w-fit items-center gap-2 text-base-medium leading-4 text-ink-gray-9"
>
<DragVerticalIcon
class="section-drag-handle h-3.5 cursor-grab shrink-0 text-ink-gray-3"
/>
<div
class="flex items-center gap-2 cursor-pointer"
@dblclick="() => (section.editingLabel = true)"
>
<div
v-if="!section.editingLabel"
class="flex items-center gap-2"
:class="{
'text-ink-gray-3': section.hideLabel || !section.label,
italic: !section.label,
}"
>
{{ __(section.label) || __('No Label') }}
<span
v-if="section.collapsible"
class="lucide-chevron-down h-4 transition-all duration-300 ease-in-out"
aria-hidden="true"
/>
</div>
<div v-else class="flex gap-2 items-center">
<Input
v-model="section.label"
@keydown.enter="section.editingLabel = false"
@blur="section.editingLabel = false"
@click.stop
/>
<Button
v-if="section.editingLabel"
icon="lucide-check"
variant="ghost"
@click="section.editingLabel = false"
/>
</div>
</div>
</div>
<div class="flex items-center gap-1.5">
<span
v-if="
section.columns.reduce((n, c) => n + c.fields.length, 0) > 0
"
class="text-xs text-ink-gray-4 bg-surface-gray-3 rounded px-1.5 py-0.5 leading-none"
>
{{ section.columns.reduce((n, c) => n + c.fields.length, 0) }}
{{
section.columns.reduce((n, c) => n + c.fields.length, 0) ===
1
? __('field')
: __('fields')
}}
</span>
<Dropdown :options="getSectionOptions(i, section, tab)">
<template #default>
<Button variant="ghost">
<span
class="lucide-more-horizontal h-4"
aria-hidden="true"
/>
</Button>
</template>
</Dropdown>
</div>
</div>
<Draggable
class="flex gap-2"
:list="section.columns"
group="columns"
item-key="name"
@start="isDragging = true"
@end="isDragging = false"
>
<template #item="{ element: column }">
<div
class="flex flex-col gap-1.5 flex-1 p-2 border border-dashed border-outline-gray-2 rounded bg-surface-elevation-2 cursor-grab"
>
<Draggable
:list="column.fields"
group="fields"
item-key="fieldname"
class="flex flex-col gap-1.5 flex-1 min-h-8.5"
handle=".field-drag-handle"
@start="isDragging = true"
@end="isDragging = false"
>
<template #item="{ element: field }">
<div
class="field px-2.5 py-2 border border-outline-gray-2 rounded text-base bg-surface-elevation-2 text-ink-gray-8 flex items-center leading-4 justify-between gap-2 cursor-auto"
>
<div class="flex items-center gap-2 truncate">
<DragVerticalIcon
class="field-drag-handle h-3.5 cursor-grab"
/>
<div class="truncate">{{ field.label }}</div>
</div>
<Button
variant="ghost"
class="!size-4 rounded-sm"
icon="lucide-x"
@click="
column.fields.splice(
column.fields.indexOf(field),
1,
)
"
/>
</div>
</template>
</Draggable>
<Combobox
:model-value="null"
:options="fields"
@update:selected-option="(e) => addField(column, e)"
>
<template #trigger="{ open, setOpen }">
<Button
class="w-full !h-8 !bg-surface-elevation-2"
variant="outline"
:label="__('Add Field')"
iconLeft="plus"
@click="setOpen(!open)"
/>
</template>
<template #item-label="{ item }">
<div class="flex flex-col gap-1 text-ink-gray-9">
<div>{{ item.label }}</div>
<div class="text-ink-gray-4 text-sm">
{{ `${item.fieldname} - ${item.fieldtype}` }}
</div>
</div>
</template>
</Combobox>
</div>
</template>
</Draggable>
</div>
</template>
<template #footer>
<div
v-if="tab.sections.length === 0"
class="flex items-center justify-center min-h-20 text-sm text-ink-gray-4 pointer-events-none select-none"
>
{{ __('Drag a section or a field here to get started') }}
</div>
</template>
</Draggable>
<div class="mt-5.5">
<Button
class="w-full h-8"
variant="subtle"
:label="__('Add Section')"
iconLeft="plus"
@click="
tabs[tabIndex].sections.push({
label: '',
name: 'section_' + getRandom(),
opened: true,
columns: [{ name: 'column_' + getRandom(), fields: [] }],
})
"
/>
</div>
</div>
</div>
</template>
<script setup>
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
import Draggable from 'vuedraggable'
import { getRandom } from '@/utils'
import { getMeta } from '@/stores/meta'
import { globalStore } from '@/stores/global'
import { Combobox, Dropdown } from 'frappe-ui'
import { ref, computed, watch, nextTick } from 'vue'
const props = defineProps({
doctype: { type: String, default: 'CRM Lead' },
onlyRequired: { type: Boolean, default: false },
})
const tabs = defineModel({ type: Array, default: () => [] })
const { $dialog } = globalStore()
const tabIndex = ref(0)
const isDragging = ref(false)
const tabItemRefs = ref([])
watch(tabIndex, (idx) => {
nextTick(() => {
tabItemRefs.value[idx]?.scrollIntoView({
behavior: 'smooth',
inline: 'nearest',
block: 'nearest',
})
})
})
const slotName = computed(() => {
if (tabs.value.length == 1 && !tabs.value[0].label) {
return 'prefix'
}
return 'default'
})
const restrictedFieldTypes = [
'Tab Break',
'Section Break',
'Column Break',
'Signature',
]
const { getFields } = getMeta(props.doctype)
const fields = computed(() => {
const _fields =
getFields({ restrictNoValueFields: false, restrictedFieldTypes }) || []
if (!_fields.length) return []
let existingFields = []
tabs.value?.forEach((tab) => {
tab.sections?.forEach((section) => {
section.columns?.forEach((column) => {
existingFields = existingFields.concat(column.fields)
})
})
})
return _fields
.filter((field) => {
return (
!existingFields.find((f) => f.fieldname === field.fieldname) &&
(props.onlyRequired ? field.reqd : true)
)
})
.map((field) => {
return {
label: field.label,
value: field.fieldname,
fieldname: field.fieldname,
fieldtype: field.fieldtype,
}
})
})
function addTab() {
if (tabs.value.length == 1 && !tabs.value[0].label) {
tabs.value[0].label = __('New Tab')
tabIndex.value = 0
nextTick(() => {
tabItemRefs.value[0]?.scrollIntoView({
behavior: 'smooth',
inline: 'nearest',
block: 'nearest',
})
})
return
}
tabs.value.push({
label: __('New Tab'),
name: 'tab_' + getRandom(),
sections: [],
})
tabIndex.value = tabs.value.length ? tabs.value.length - 1 : 0
nextTick(() => {
tabItemRefs.value[tabIndex.value]?.scrollIntoView({
behavior: 'smooth',
inline: 'nearest',
block: 'nearest',
})
})
}
function addField(column, field) {
if (!field) return
column.fields.push(field)
}
function onFieldDroppedToEmptyTab(tab) {
const droppedItem = tab.sections[0]
if (!droppedItem) return
let columns = []
if (Object.hasOwn(droppedItem, 'fields')) {
columns = [droppedItem]
} else if (Object.hasOwn(droppedItem, 'fieldname')) {
columns = [{ name: 'column_' + getRandom(), fields: [droppedItem] }]
}
if (!columns.length) return
tab.sections.splice(0, 1)
tab.sections.push({
label: '',
name: 'section_' + getRandom(),
opened: true,
columns,
})
}
function getTabOptions(tab) {
return [
{
label: __('Edit'),
icon: 'edit',
onClick: () => (tab.editingLabel = true),
},
{
label: __('Remove Tab'),
icon: 'trash-2',
onClick: () => {
if (tabs.value.length == 1) {
tabs.value[0].label = ''
return
}
$dialog({
title: __('Remove Tab'),
message: __(
'Are you sure you want to remove this tab and all its content?',
),
variant: 'danger',
actions: [
{
label: __('Remove'),
variant: 'solid',
theme: 'red',
onClick: (close) => {
tabs.value.splice(tabIndex.value, 1)
tabIndex.value = tabIndex.value ? tabIndex.value - 1 : 0
close()
},
},
],
})
},
},
]
}
function getSectionOptions(i, section, tab) {
let column = section.columns[section.columns.length - 1]
return [
{
group: __('Section'),
items: [
{
label: __('Edit'),
icon: 'edit',
onClick: () => (section.editingLabel = true),
},
{
label: section.collapsible ? __('Uncollapsible') : __('Collapsible'),
icon: section.collapsible ? 'chevron-up' : 'chevron-down',
onClick: () => (section.collapsible = !section.collapsible),
},
{
label: section.hideLabel ? __('Show Label') : __('Hide Label'),
icon: section.hideLabel ? 'eye' : 'eye-off',
onClick: () => (section.hideLabel = !section.hideLabel),
},
{
label: section.hideBorder ? __('Show Border') : __('Hide Border'),
icon: 'minus',
onClick: () => (section.hideBorder = !section.hideBorder),
},
{
label: __('Remove Section'),
icon: 'trash-2',
onClick: () => {
const hasFields = section.columns.some((c) => c.fields.length)
const doRemove = () =>
tab.sections.splice(tab.sections.indexOf(section), 1)
if (hasFields) {
$dialog({
title: __('Remove Section'),
message: __(
'This section contains fields. Are you sure you want to remove it?',
),
variant: 'danger',
actions: [
{
label: __('Remove'),
variant: 'solid',
theme: 'red',
onClick: (close) => {
doRemove()
close()
},
},
],
})
} else {
doRemove()
}
},
condition: () => section.editable !== false,
},
{
label: __('Remove and move columns to {0} section', [
i == 0 ? __('next') : __('previous'),
]),
icon: 'trash-2',
onClick: () => {
let targetSection = tab.sections[i == 0 ? i + 1 : i - 1]
if (i == 0) {
targetSection.columns = section.columns.concat(
targetSection.columns,
)
} else {
targetSection.columns = targetSection.columns.concat(
section.columns,
)
}
tab.sections.splice(tab.sections.indexOf(section), 1)
},
condition: () => section.editable !== false && section.columns.length,
},
{
label: __('Move to Previous Tab'),
icon: 'corner-up-left',
onClick: () => {
let previousTab = tabs.value[tabIndex.value - 1]
previousTab.sections.push(section)
tabs.value[tabIndex.value].sections.splice(
tabs.value[tabIndex.value].sections.indexOf(section),
1,
)
tabIndex.value -= 1
},
condition: () => tabs.value[tabIndex.value - 1],
},
{
label: __('Move to Next Tab'),
icon: 'corner-up-right',
onClick: () => {
let nextTab = tabs.value[tabIndex.value + 1]
nextTab.sections.push(section)
tabs.value[tabIndex.value].sections.splice(
tabs.value[tabIndex.value].sections.indexOf(section),
1,
)
tabIndex.value += 1
},
condition: () => tabs.value[tabIndex.value + 1],
},
],
},
{
group: __('Column'),
items: [
{
label: __('Add Column'),
icon: 'columns',
onClick: () => {
section.columns.push({
label: '',
name: 'column_' + getRandom(),
fields: [],
})
},
condition: () => section.columns.length < 4,
},
{
label: __('Remove Last Column'),
icon: 'trash-2',
onClick: () => {
const doRemove = () => section.columns.pop()
if (column.fields.length) {
$dialog({
title: __('Remove Last Column'),
message: __(
'This column contains fields. Are you sure you want to remove it?',
),
variant: 'danger',
actions: [
{
label: __('Remove'),
variant: 'solid',
theme: 'red',
onClick: (close) => {
doRemove()
close()
},
},
],
})
} else {
doRemove()
}
},
condition: () => section.columns.length > 1,
},
{
label: __('Remove Last Column (move fields to previous)'),
icon: 'trash-2',
onClick: () => {
let previousColumn = section.columns[section.columns.length - 2]
previousColumn.fields = previousColumn.fields.concat(column.fields)
section.columns.pop()
},
condition: () => section.columns.length > 1 && column.fields.length,
},
{
label: __('Move Last Column to Next Section'),
icon: 'corner-up-right',
onClick: () => {
let nextSection = tab.sections[i + 1]
nextSection.columns.push(column)
section.columns.pop()
},
condition: () => tab.sections[i + 1],
},
{
label: __('Move Last Column to Previous Section'),
icon: 'corner-up-left',
onClick: () => {
let previousSection = tab.sections[i - 1]
previousSection.columns.push(column)
section.columns.pop()
},
condition: () => tab.sections[i - 1],
},
{
label: __('Move Last Column to Previous Tab'),
icon: 'corner-up-left',
onClick: () => {
let targetTab = tabs.value[tabIndex.value - 1]
if (!targetTab.sections.length) {
targetTab.sections.push({
label: '',
name: 'section_' + getRandom(),
opened: true,
columns: [],
})
}
targetTab.sections[targetTab.sections.length - 1].columns.push(
column,
)
section.columns.pop()
},
condition: () => tabs.value[tabIndex.value - 1],
},
{
label: __('Move Last Column to Next Tab'),
icon: 'corner-up-right',
onClick: () => {
let targetTab = tabs.value[tabIndex.value + 1]
if (!targetTab.sections.length) {
targetTab.sections.push({
label: '',
name: 'section_' + getRandom(),
opened: true,
columns: [],
})
}
targetTab.sections[targetTab.sections.length - 1].columns.push(
column,
)
section.columns.pop()
},
condition: () => tabs.value[tabIndex.value + 1],
},
],
},
]
}
</script>

View File

@@ -0,0 +1,225 @@
<template>
<Dialog v-model:open="show" :title="__('Attach')" :size="'xl'">
<template #default>
<FilesUploaderArea
ref="filesUploaderArea"
v-model="files"
:doctype="doctype"
:options="options"
/>
</template>
<template #actions>
<div class="flex justify-between">
<div class="flex gap-2">
<Button
v-if="files.length"
variant="subtle"
:label="__('Remove All')"
:disabled="fileUploadStarted"
@click="removeAllFiles"
/>
<Button
v-if="
filesUploaderArea?.showWebLink || filesUploaderArea?.showCamera
"
:label="isMobileView ? __('Back') : __('Back to File Upload')"
iconLeft="arrow-left"
@click="
() => {
filesUploaderArea.showWebLink = false
filesUploaderArea.showCamera = false
filesUploaderArea.webLink = null
filesUploaderArea.cameraImage = null
}
"
/>
<Button
v-if="
filesUploaderArea?.showCamera && !filesUploaderArea?.cameraImage
"
:label="__('Switch Camera')"
@click="() => filesUploaderArea.switchCamera()"
/>
<Button
v-if="filesUploaderArea?.cameraImage"
:label="__('Retake')"
@click="filesUploaderArea.cameraImage = null"
/>
</div>
<div class="flex gap-2">
<Button
v-if="isAllPrivate && files.length"
variant="subtle"
:label="__('Set all as public')"
:disabled="fileUploadStarted"
@click="setAllPublic"
/>
<Button
v-else-if="files.length"
variant="subtle"
:label="__('Set all as private')"
:disabled="fileUploadStarted"
@click="setAllPrivate"
/>
<Button
v-if="!filesUploaderArea?.showCamera"
variant="solid"
:label="__('Attach')"
:loading="fileUploadStarted"
:disabled="disableAttachButton"
@click="attachFiles"
/>
<Button
v-if="
filesUploaderArea?.showCamera && filesUploaderArea?.cameraImage
"
variant="solid"
:label="__('Upload')"
@click="() => filesUploaderArea.uploadViaCamera()"
/>
<Button
v-if="
filesUploaderArea?.showCamera && !filesUploaderArea?.cameraImage
"
variant="solid"
:label="__('Capture')"
@click="() => filesUploaderArea.captureImage()"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import FilesUploaderArea from '@/components/FilesUploader/FilesUploaderArea.vue'
import FilesUploadHandler from './filesUploaderHandler'
import { isMobileView } from '@/composables/settings'
import { toast } from 'frappe-ui'
import { ref, computed } from 'vue'
const props = defineProps({
doctype: { type: String, required: true },
docname: { type: String, required: true },
fieldname: { type: String, default: '' },
options: {
type: Object,
default: () => ({
folder: 'Home/Attachments',
}),
},
})
const emit = defineEmits(['after'])
// 'after' payload: array of uploaded file objects (each has file_url, file_name, name, ...)
const show = defineModel({ type: Boolean })
const filesUploaderArea = ref(null)
const files = ref([])
const isAllPrivate = computed(() => files.value.every((a) => a.private))
function setAllPrivate() {
files.value.forEach((file) => (file.private = true))
}
function setAllPublic() {
files.value.forEach((file) => (file.private = false))
}
function removeAllFiles() {
files.value = []
}
const disableAttachButton = computed(() => {
if (filesUploaderArea.value?.showCamera) {
return !filesUploaderArea.value.cameraImage
}
if (filesUploaderArea.value?.showWebLink) {
return !filesUploaderArea.value.webLink
}
return !files.value.length
})
function attachFiles() {
if (filesUploaderArea.value.showWebLink) {
return uploadViaWebLink()
}
files.value.forEach((file, i) => attachFile(file, i))
}
function uploadViaWebLink() {
let fileUrl = filesUploaderArea.value.webLink
if (!fileUrl) {
toast.error(__('Please enter a valid URL'))
return
}
fileUrl = decodeURI(fileUrl)
show.value = false
return attachFile({
fileUrl,
})
}
const uploader = ref(null)
const fileUploadStarted = ref(false)
const uploadedFiles = ref([])
function attachFile(file, i) {
const args = {
fileObj: file.fileObj || {},
type: file.type,
private: file.private,
fileUrl: file.fileUrl,
folder: props.options.folder,
doctype: props.doctype,
docname: props.docname,
fieldname: props.fieldname,
}
uploader.value = new FilesUploadHandler()
uploader.value.on('start', () => {
file.uploading = true
fileUploadStarted.value = true
})
uploader.value.on('progress', (data) => {
file.uploaded = data.uploaded
file.total = data.total
})
uploader.value.on('error', (error) => {
file.uploading = false
file.errorMessage = error || __('Error Uploading File')
})
uploader.value.on('finish', () => {
file.uploading = false
})
uploader.value
.upload(file, args || {})
.then((response) => {
uploadedFiles.value.push(response)
if (i === files.value.length - 1) {
const uploaded = uploadedFiles.value.slice()
uploadedFiles.value = []
files.value = []
show.value = false
fileUploadStarted.value = false
emit('after', uploaded)
}
})
.catch((error) => {
file.uploading = false
let errorMessage = __('Error Uploading File')
if (error?._server_messages) {
errorMessage = JSON.parse(JSON.parse(error._server_messages)[0]).message
} else if (error?.exc) {
errorMessage = JSON.parse(error.exc)[0].split('\n').slice(-2, -1)[0]
} else if (typeof error === 'string') {
errorMessage = error
}
file.errorMessage = errorMessage
})
}
</script>

View File

@@ -0,0 +1,398 @@
<template>
<div v-if="showWebLink">
<TextInput v-model="webLink" placeholder="https://example.com" />
</div>
<div v-else-if="showCamera">
<video v-show="!cameraImage" ref="video" class="rounded" autoplay></video>
<canvas
v-show="cameraImage"
ref="canvas"
class="rounded"
style="width: -webkit-fill-available"
/>
</div>
<div v-else>
<div
v-show="files.length === 0"
class="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-outline-elevation-2 min-h-64 text-ink-gray-5"
@dragover.prevent="dragover"
@dragleave.prevent="dragleave"
@drop.prevent="dropfiles"
>
<div v-if="!isDragging" class="flex flex-col gap-3">
<div class="text-center text-ink-gray-5">
{{ __('Drag & Drop files here or upload from') }}
</div>
<div
class="grid grid-flow-col justify-center gap-4 text-center text-base"
>
<input
ref="fileInput"
type="file"
class="hidden"
:multiple="allowMultiple"
:accept="(restrictions.allowedFileTypes || []).join(', ')"
@change="onFileInput"
/>
<div>
<Button icon="lucide-monitor" size="md" @click="browseFiles" />
<div class="mt-1">{{ __('Device') }}</div>
</div>
<div v-if="!disableFileBrowser">
<Button
icon="lucide-folder"
size="md"
@click="showFileBrowser = true"
/>
<div class="mt-1">{{ __('Library') }}</div>
</div>
<div v-if="allowWebLink">
<Button icon="lucide-link" size="md" @click="showWebLink = true" />
<div class="mt-1">{{ __('Link') }}</div>
</div>
<div v-if="allowTakePhoto">
<Button icon="lucide-camera" size="md" @click="startCamera" />
<div class="mt-1">{{ __('Camera') }}</div>
</div>
</div>
</div>
<div v-else>
{{ __('Drop files here') }}
</div>
</div>
<div v-show="files.length" class="flex flex-col divide-y">
<div
v-for="file in files"
:key="file.name"
class="flex items-center justify-between gap-2 py-3"
>
<div class="flex items-center gap-4 truncate">
<div
class="size-11 rounded overflow-hidden flex-shrink-0 flex justify-center items-center"
:class="{ border: !file.type?.startsWith('image') }"
>
<img
v-if="file.type?.startsWith('image')"
class="size-full object-cover"
:src="file.src"
:alt="file.name"
/>
<component :is="fileIcon(file.type)" v-else class="size-4" />
</div>
<div class="flex flex-col gap-1 text-sm text-ink-gray-5 truncate">
<div class="text-base text-ink-gray-8 truncate">
{{ file.name }}
</div>
<div class="mb-1">
{{ convertSize(file.fileObj.size) }}
</div>
<FormControl
v-model="file.private"
type="checkbox"
class="[&>label]:text-sm [&>label]:text-ink-gray-5"
:label="__('Private')"
/>
<ErrorMessage
v-if="file.errorMessage"
class="mt-2"
:message="file.errorMessage"
/>
</div>
</div>
<div>
<CircularProgressBar
v-if="file.uploading || file.uploaded == file.total"
:class="{
'text-ink-green-5': file.uploaded == file.total,
}"
:theme="{
primary: '#22C55E',
secondary: 'lightgray',
}"
:step="file.uploaded || 1"
:totalSteps="file.total || 100"
size="xs"
variant="outline"
:showPercentage="file.uploading"
/>
<Button
v-else
variant="ghost"
icon="lucide-trash-2"
@click="removeFile(file.name)"
/>
</div>
</div>
</div>
</div>
</template>
<script setup>
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
import { formatDate, convertSize } from '@/utils'
import {
FormControl,
CircularProgressBar,
createResource,
toast,
} from 'frappe-ui'
import { ref, onMounted, watch, onUnmounted } from 'vue'
const props = defineProps({
doctype: { type: String, required: true },
options: { type: Object, default: () => ({}) },
})
const files = defineModel({ type: Array, default: () => [] })
const fileInput = ref(null)
const isDragging = ref(false)
const showWebLink = ref(false)
const showFileBrowser = ref(false)
const showCamera = ref(false)
const webLink = ref('')
const cameraImage = ref(null)
const allowMultiple = ref(props.options.allowMultiple == false ? false : true)
const disableFileBrowser = ref(props.options.disableFileBrowser || true)
const allowWebLink = ref(props.options.allowWebLink == false ? false : true)
const allowTakePhoto = ref(
props.options.allowTakePhoto || window.navigator.mediaDevices || false,
)
const restrictions = ref(props.options.restrictions || {})
const makeAttachmentsPublic = ref(props.options.makeAttachmentsPublic || false)
onMounted(() => {
createResource({
url: 'crm.api.get_file_uploader_defaults',
params: { doctype: props.doctype },
cache: ['file_uploader_defaults', props.doctype],
auto: true,
transform: (data) => {
const propRestrictions = props.options.restrictions || {}
restrictions.value = {
allowedFileTypes: data.allowed_file_types
? data.allowed_file_types.split('\n').map((ext) => `.${ext}`)
: [],
maxFileSize: data.max_file_size,
maxNumberOfFiles: data.max_number_of_files,
...propRestrictions,
}
makeAttachmentsPublic.value = Boolean(data.make_attachments_public)
},
})
})
function dragover() {
isDragging.value = true
}
function dragleave() {
isDragging.value = false
}
function dropfiles(e) {
isDragging.value = false
addFiles(e.dataTransfer.files)
}
function browseFiles() {
fileInput.value.click()
}
function onFileInput() {
addFiles(fileInput.value.files)
fileInput.value.value = ''
}
const video = ref(null)
const facingMode = ref('environment')
const stream = ref(null)
async function startCamera() {
showCamera.value = true
stream.value = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: facingMode.value,
},
audio: false,
})
video.value.srcObject = stream.value
}
function stopStream() {
stream.value?.getTracks()?.forEach((track) => track.stop())
showCamera.value = false
cameraImage.value = null
}
function switchCamera() {
facingMode.value = facingMode.value === 'environment' ? 'user' : 'environment'
stopStream()
startCamera()
}
const canvas = ref(null)
function captureImage() {
const width = video.value.videoWidth
const height = video.value.videoHeight
canvas.value.width = width
canvas.value.height = height
canvas.value.getContext('2d').drawImage(video.value, 0, 0, width, height)
cameraImage.value = canvas.value.toDataURL('image/png')
}
function uploadViaCamera() {
const nowDatetime = formatDate(new Date(), 'YYYY_MM_DD_HH_mm_ss')
let filename = `capture_${nowDatetime}.png`
urlToFile(cameraImage.value, filename, 'image/png').then((file) => {
addFiles([file])
showCamera.value = false
cameraImage.value = null
})
}
function urlToFile(url, filename, mime_type) {
return fetch(url)
.then((res) => res.arrayBuffer())
.then((buffer) => new File([buffer], filename, { type: mime_type }))
}
function addFiles(fileArray) {
let _files = Array.from(fileArray)
.filter(checkRestrictions)
.map((file, i) => {
let isImage = file.type?.startsWith('image')
let sizeKb = file.size / 1024
return {
index: i,
src: isImage ? URL.createObjectURL(file) : null,
fileObj: file,
cropperFile: file,
cropBoxData: null,
type: file.type,
optimize: sizeKb > 200 && isImage && !file.type?.includes('svg'),
name: file.name,
doc: null,
progress: 0,
total: 0,
failed: false,
requestSucceeded: false,
errorMessage: null,
uploading: false,
private: !makeAttachmentsPublic.value,
}
})
// pop extra files as per FileUploader.restrictions.maxNumberOfFiles
let maxNumberOfFiles = restrictions.value.maxNumberOfFiles
if (maxNumberOfFiles && _files.length > maxNumberOfFiles) {
_files.slice(maxNumberOfFiles).forEach((file) => {
showMaxFilesNumberWarning(file, maxNumberOfFiles)
})
_files = _files.slice(0, maxNumberOfFiles)
}
files.value = files.value.concat(_files)
}
function checkRestrictions(file) {
let { maxFileSize, allowedFileTypes = [] } = restrictions.value
let isCorrectType = true
let validFileSize = true
if (allowedFileTypes && allowedFileTypes.length) {
isCorrectType = allowedFileTypes.some((type) => {
// is this is a mime-type
if (type.includes('/')) {
if (!file.type) return false
return file.type.match(type)
}
// otherwise this is likely an extension
if (type[0] === '.') {
return file.name.toLowerCase().endsWith(type.toLowerCase())
}
return false
})
}
if (maxFileSize && file.size != null) {
validFileSize = file.size < maxFileSize
}
if (!isCorrectType) {
console.warn('File skipped because of invalid file type', file)
toast.warning(
__('File "{0}" was skipped because of invalid file type', [file.name]),
)
}
if (!validFileSize) {
console.warn('File skipped because of invalid file size', file.size, file)
toast.warning(
__('File "{0}" was skipped because size exceeds {1} MB', [
file.name,
maxFileSize / (1024 * 1024),
]),
)
}
return isCorrectType && validFileSize
}
function showMaxFilesNumberWarning(file, maxNumberOfFiles) {
console.warn(
`File skipped because it exceeds the allowed specified limit of ${maxNumberOfFiles} uploads`,
file,
)
let message = __(
'File "{0}" was skipped because only {1} uploads are allowed',
[file.name, maxNumberOfFiles],
)
if (props.doctype) {
message = __(
'File "{0}" was skipped because only {1} uploads are allowed for DocType "{2}"',
[file.name, maxNumberOfFiles, props.doctype],
)
}
toast.warning(message)
}
function removeFile(name) {
files.value = files.value.filter((file) => file.name !== name)
}
function fileIcon(type) {
if (type?.startsWith('audio')) {
return FileAudioIcon
} else if (type?.startsWith('video')) {
return FileVideoIcon
}
return FileTextIcon
}
watch(showCamera, (value) => {
if (!value) stopStream()
})
onUnmounted(() => stopStream())
defineExpose({
showFileBrowser,
showWebLink,
webLink,
showCamera,
cameraImage,
captureImage,
uploadViaCamera,
switchCamera,
})
</script>

View File

@@ -0,0 +1,134 @@
interface UploadOptions {
fileObj?: File
private?: boolean
fileUrl?: string
folder?: string
doctype?: string
docname?: string
fieldname?: string
type?: string
}
type EventListenerOption = 'start' | 'progress' | 'finish' | 'error'
declare global {
interface Window {
csrf_token?: string
}
}
class FilesUploadHandler {
listeners: { [event: string]: ((...args: unknown[]) => void)[] }
failed: boolean
constructor() {
this.listeners = {}
this.failed = false
}
on(event: EventListenerOption, handler: (...args: unknown[]) => void) {
this.listeners[event] = this.listeners[event] || []
this.listeners[event].push(handler)
}
trigger(event: string, data?: unknown) {
const handlers = this.listeners[event] || []
handlers.forEach((handler) => {
handler.call(this, data)
})
}
upload(file: File | null, options: UploadOptions): Promise<unknown> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('loadstart', () => {
this.trigger('start')
})
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
this.trigger('progress', {
uploaded: e.loaded,
total: e.total,
})
}
})
xhr.upload.addEventListener('load', () => {
this.trigger('finish')
})
xhr.addEventListener('error', () => {
this.trigger('error')
reject()
})
xhr.onreadystatechange = () => {
if (xhr.readyState == XMLHttpRequest.DONE) {
let error: unknown
if (xhr.status === 200) {
let r: unknown
try {
r = JSON.parse(xhr.responseText)
} catch {
r = xhr.responseText
}
const out = (r as { message?: unknown })?.message || r
resolve(out)
} else if (xhr.status === 403) {
error = JSON.parse(xhr.responseText)
} else if (xhr.status === 413) {
this.failed = true
error = 'Size exceeds the maximum allowed file size.'
} else {
this.failed = true
try {
error = JSON.parse(xhr.responseText)
} catch {
// pass
}
}
if (error && (error as { exc?: string }).exc) {
console.error(JSON.parse((error as { exc: string }).exc)[0])
}
reject(error)
}
}
xhr.open('POST', '/api/method/upload_file', true)
xhr.setRequestHeader('Accept', 'application/json')
if (window.csrf_token && window.csrf_token !== '{{ csrf_token }}') {
xhr.setRequestHeader('X-Frappe-CSRF-Token', window.csrf_token)
}
const formData = new FormData()
if (options.fileObj && file?.name) {
formData.append('file', options.fileObj, file.name)
}
formData.append('is_private', options.private || false ? '1' : '0')
formData.append('folder', options.folder || 'Home')
if (options.fileUrl) {
formData.append('file_url', options.fileUrl)
}
if (options.doctype) {
formData.append('doctype', options.doctype)
}
if (options.docname) {
formData.append('docname', options.docname)
}
if (options.fieldname) {
formData.append('fieldname', options.fieldname)
}
if (options.type) {
formData.append('type', options.type)
}
xhr.send(formData)
})
}
}
export default FilesUploadHandler

View File

@@ -0,0 +1,759 @@
<template>
<Popover placement="bottom-end">
<template #target="{ togglePopover, close }">
<div class="flex items-center">
<Button
:label="__('Filter')"
:class="filters?.size ? 'rounded-r-none' : ''"
:iconLeft="FilterIcon"
@click="togglePopover"
>
<template v-if="filters?.size" #suffix>
<div
class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-base pt-px text-xs-medium text-ink-gray-8 shadow-sm"
>
{{ filters.size }}
</div>
</template>
</Button>
<Button
v-if="filters?.size"
:tooltip="__('Clear All Filters')"
class="rounded-l-none border-l"
icon="lucide-x"
@click.stop="clearfilter(close)"
/>
</div>
</template>
<template #body="{ close }">
<div
class="my-2 min-w-40 rounded-lg bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div class="min-w-72 p-2 sm:min-w-[400px]">
<template v-if="filters?.size">
<div
v-for="(f, i) in filters"
id="filter-list"
:key="i"
class="mb-4 sm:mb-3"
>
<div v-if="isMobileView" class="flex flex-col gap-2">
<div class="-mb-2 flex w-full items-center justify-between">
<div class="text-base text-ink-gray-5">
{{ i == 0 ? __('Where') : __('And') }}
</div>
<Button
class="flex"
variant="ghost"
icon="lucide-x"
@click="removeFilter(i)"
/>
</div>
<div id="fieldname" class="w-full">
<Combobox
trigger="button"
:model-value="f.field.fieldname"
:options="filterFieldOptions"
:placeholder="__('First Name')"
@update:selected-option="(e) => updateFilter(e, i)"
/>
</div>
<div id="operator">
<Combobox
v-model="f.operator"
trigger="button"
:options="
getOperators(f.field.fieldtype, f.field.fieldname)
"
:placeholder="__('Equals')"
@update:modelValue="() => updateOperator(f)"
/>
</div>
<div id="value" class="w-full">
<component
:is="getValueControl(f)"
v-model="f.value"
:placeholder="placeholder(f)"
@change="(v) => updateValue(v, f)"
/>
</div>
</div>
<div v-else class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<div class="w-13 pl-2 text-end text-base text-ink-gray-5">
{{ i == 0 ? __('Where') : __('And') }}
</div>
<div id="fieldname" class="!min-w-[140px]">
<Combobox
trigger="button"
:model-value="f.field.fieldname"
:options="filterFieldOptions"
:placeholder="__('First Name')"
@update:selected-option="(e) => updateFilter(e, i)"
/>
</div>
<div id="operator">
<Combobox
v-model="f.operator"
trigger="button"
:options="
getOperators(f.field.fieldtype, f.field.fieldname)
"
:placeholder="__('Equals')"
@update:modelValue="() => updateOperator(f)"
/>
</div>
<div id="value" class="!min-w-[140px]">
<component
:is="getValueControl(f)"
v-model="f.value"
:placeholder="placeholder(f)"
@change="(v) => updateValue(v, f)"
/>
</div>
</div>
<Button
class="flex"
variant="ghost"
icon="lucide-x"
@click="removeFilter(i)"
/>
</div>
</div>
</template>
<div
v-else
class="mb-3 flex h-7 items-center px-3 text-sm text-ink-gray-5"
>
{{ __('Empty - Choose a field to filter by') }}
</div>
<div class="flex items-center justify-between gap-2">
<Combobox
:model-value="null"
:options="availableFilters"
:placeholder="__('First Name')"
@update:selected-option="(e) => setfilter(e)"
>
<template #trigger="{ open, setOpen }">
<Button
class="!text-ink-gray-5"
variant="ghost"
:label="__('Add Filter')"
iconLeft="plus"
@click="setOpen(!open)"
/>
</template>
</Combobox>
<Button
v-if="filters?.size"
class="!text-ink-gray-5"
variant="ghost"
:label="__('Clear All Filters')"
@click="clearfilter(close)"
/>
</div>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import FilterIcon from '@/components/Icons/FilterIcon.vue'
import Link from '@/components/Controls/Link.vue'
import DurationInput from '@/components/Controls/DurationInput.vue'
import RatingInput from '@/components/Controls/RatingInput.vue'
import {
Combobox,
FormControl,
createResource,
Popover,
DatePicker,
DateTimePicker,
DateRangePicker,
} from 'frappe-ui'
import { h, computed, onMounted } from 'vue'
import { isMobileView } from '@/composables/settings'
import { getFormat } from '@/utils'
const typeCheck = ['Check']
const typeLink = ['Link', 'Dynamic Link']
const typeNumber = ['Float', 'Int', 'Currency', 'Percent']
const typeSelect = ['Select']
const typeString = ['Data', 'Long Text', 'Small Text', 'Text Editor', 'Text']
const typeDate = ['Date', 'Datetime']
const typeDuration = ['Duration']
const typeRating = ['Rating']
const props = defineProps({
doctype: { type: String, required: true },
default_filters: { type: Object, default: () => {} },
})
const emit = defineEmits(['update'])
const list = defineModel({ type: Object, default: () => ({}) })
const filterableFields = createResource({
url: 'crm.api.doc.get_filterable_fields',
cache: ['filterableFields', props.doctype],
params: { doctype: props.doctype },
})
onMounted(() => {
if (filterableFields.data?.length) return
filterableFields.fetch()
})
const filters = computed(() => {
if (!list.value?.data) return new Set()
let allFilters =
list.value?.params?.filters || list.value.data?.params?.filters
if (
!allFilters ||
Object.keys(allFilters).length === 0 ||
!filterableFields.data
)
return new Set()
// remove default filters
if (props.default_filters) {
allFilters = removeCommonFilters(props.default_filters, allFilters)
}
return convertFilters(filterableFields.data, allFilters)
})
// `name` is labelled "Name" but holds the document ID, which reads as the
// record's full name on these doctypes. Mark it with an ID icon.
const idFieldDoctypes = ['CRM Lead', 'CRM Deal']
const filterFieldOptions = computed(() => {
const fields = filterableFields.data || []
const markIdField = idFieldDoctypes.includes(props.doctype)
return fields.map((field) => {
// Drop the description: it renders as a second line in the dropdown, and
// the popover has no max width, so one long description (CRM Deal's
// exchange rate) stretches the whole list.
const { description, ...option } = field
if (markIdField && option.fieldname === 'name') {
option.slots = {
suffix: () =>
h('span', {
class: 'lucide-id-card size-4 shrink-0 text-ink-gray-5',
title: __('Document ID, not the full name'),
}),
}
}
return option
})
})
const availableFilters = computed(() => {
if (!filterableFields.data) return []
const selectedFieldNames = new Set()
for (const filter of filters.value) {
selectedFieldNames.add(filter.fieldname)
}
return filterFieldOptions.value.filter(
(field) => !selectedFieldNames.has(field.fieldname),
)
})
function removeCommonFilters(commonFilters, allFilters) {
for (const key in commonFilters) {
if (Object.hasOwn(commonFilters, key) && Object.hasOwn(allFilters, key)) {
if (commonFilters[key] === allFilters[key]) {
delete allFilters[key]
}
}
}
return allFilters
}
function convertFilters(data, allFilters) {
let f = []
for (let [key, value] of Object.entries(allFilters)) {
let field = data.find((f) => f.fieldname === key)
if (typeof value !== 'object' || !value) {
value = ['=', value]
if (field?.fieldtype === 'Check') {
value = ['equals', value[1] ? 'Yes' : 'No']
}
}
if (field) {
f.push({
field,
fieldname: key,
operator: oppositeOperatorMap[value[0]],
value: value[1],
})
}
}
return new Set(f)
}
function getOperators(fieldtype, fieldname) {
let options = []
if (typeString.includes(fieldtype)) {
options.push(
...[
{ label: __('Equals'), value: 'equals' },
{ label: __('Not equals'), value: 'not equals' },
{ label: __('Like'), value: 'like' },
{ label: __('Not like'), value: 'not like' },
{ label: __('In'), value: 'in' },
{ label: __('Not in'), value: 'not in' },
{ label: __('Is'), value: 'is' },
],
)
}
if (fieldname === '_assign') {
// TODO: make equals and not equals work
options = [
{ label: __('Like'), value: 'like' },
{ label: __('Not like'), value: 'not like' },
{ label: __('Is'), value: 'is' },
]
}
if (typeNumber.includes(fieldtype)) {
options.push(
...[
{ label: __('Equals'), value: 'equals' },
{ label: __('Not equals'), value: 'not equals' },
{ label: __('Like'), value: 'like' },
{ label: __('Not like'), value: 'not like' },
{ label: __('In'), value: 'in' },
{ label: __('Not in'), value: 'not in' },
{ label: __('Is'), value: 'is' },
{ label: __('<'), value: '<' },
{ label: __('>'), value: '>' },
{ label: __('<='), value: '<=' },
{ label: __('>='), value: '>=' },
],
)
}
if (typeSelect.includes(fieldtype)) {
options.push(
...[
{ label: __('Equals'), value: 'equals' },
{ label: __('Not equals'), value: 'not equals' },
{ label: __('In'), value: 'in' },
{ label: __('Not in'), value: 'not in' },
{ label: __('Is'), value: 'is' },
],
)
}
if (typeLink.includes(fieldtype)) {
options.push(
...[
{ label: __('Equals'), value: 'equals' },
{ label: __('Not equals'), value: 'not equals' },
{ label: __('Like'), value: 'like' },
{ label: __('Not like'), value: 'not like' },
{ label: __('In'), value: 'in' },
{ label: __('Not in'), value: 'not in' },
{ label: __('Is'), value: 'is' },
],
)
}
if (typeCheck.includes(fieldtype)) {
options.push(...[{ label: __('Equals'), value: 'equals' }])
}
if (typeDuration.includes(fieldtype)) {
options.push(
...[
{ label: __('Like'), value: 'like' },
{ label: __('Not like'), value: 'not like' },
{ label: __('In'), value: 'in' },
{ label: __('Not in'), value: 'not in' },
{ label: __('Is'), value: 'is' },
],
)
}
if (typeDate.includes(fieldtype)) {
options.push(
...[
{ label: __('Equals'), value: 'equals' },
{ label: __('Not equals'), value: 'not equals' },
{ label: __('Is'), value: 'is' },
{ label: __('>'), value: '>' },
{ label: __('<'), value: '<' },
{ label: __('>='), value: '>=' },
{ label: __('<='), value: '<=' },
{ label: __('Between'), value: 'between' },
{ label: __('Timespan'), value: 'timespan' },
],
)
}
if (typeRating.includes(fieldtype)) {
options.push(
...[
{ label: __('Equals'), value: 'equals' },
{ label: __('Not equals'), value: 'not equals' },
{ label: __('Greater than'), value: '>' },
{ label: __('Less than'), value: '<' },
{ label: __('Greater than or equal to'), value: '>=' },
{ label: __('Less than or equal to'), value: '<=' },
{ label: __('Is'), value: 'is' },
],
)
}
return options
}
function getValueControl(f) {
const { field, operator } = f
const { fieldtype, options } = field
if (operator == 'is') {
return h(Combobox, {
trigger: 'button',
options: [
{
label: __('Set'),
value: 'set',
},
{
label: __('Not Set'),
value: 'not set',
},
],
modelValue: f.value,
'onUpdate:modelValue': (v) => updateValue(v, f),
})
} else if (operator == 'timespan') {
return h(Combobox, {
trigger: 'button',
options: timespanOptions,
modelValue: f.value,
'onUpdate:modelValue': (v) => updateValue(v, f),
})
} else if (['like', 'not like', 'in', 'not in'].includes(operator)) {
return h(FormControl, { type: 'text' })
} else if (typeSelect.includes(fieldtype) || typeCheck.includes(fieldtype)) {
const _options =
fieldtype == 'Check' ? ['Yes', 'No'] : getSelectOptions(options)
return h(Combobox, {
trigger: 'button',
options: _options.map((o) => ({
label: o,
value: o,
})),
modelValue: f.value,
'onUpdate:modelValue': (v) => updateValue(v, f),
})
} else if (typeLink.includes(fieldtype)) {
if (fieldtype == 'Dynamic Link') {
return h(FormControl, { type: 'text' })
}
return h(Link, { class: 'form-control', doctype: options, value: f.value })
} else if (typeNumber.includes(fieldtype)) {
return h(FormControl, { type: 'number' })
} else if (typeDate.includes(fieldtype) && operator == 'between') {
return h(DateRangePicker, {
value: f.value,
iconLeft: '',
format: getFormat('', '', true, false, false),
})
} else if (typeDuration.includes(fieldtype)) {
return h(DurationInput, { value: f.value })
} else if (typeRating.includes(fieldtype)) {
return h(RatingInput, {
value: f.value,
max: options || 5,
class: '!flex',
})
} else if (typeDate.includes(fieldtype)) {
return h(fieldtype == 'Date' ? DatePicker : DateTimePicker, {
value: f.value,
iconLeft: '',
format:
fieldtype == 'Date'
? getFormat('', '', true, false, false)
: getFormat('', '', true, true, false),
})
} else {
return h(FormControl, { type: 'text' })
}
}
function getDefaultValue(field) {
if (typeSelect.includes(field.fieldtype)) {
return getSelectOptions(field.options)[0]
}
if (typeCheck.includes(field.fieldtype)) {
return 'Yes'
}
if (typeDate.includes(field.fieldtype)) {
return null
}
return ''
}
function getDefaultOperator(fieldtype) {
if (typeSelect.includes(fieldtype)) {
return 'equals'
}
if (typeCheck.includes(fieldtype) || typeNumber.includes(fieldtype)) {
return 'equals'
}
if (typeDate.includes(fieldtype)) {
return 'between'
}
return 'like'
}
function getSelectOptions(options) {
return options.split('\n')
}
function setfilter(data) {
if (!data) return
filters.value.add({
field: {
label: data.label,
fieldname: data.fieldname,
fieldtype: data.fieldtype,
options: data.options,
},
fieldname: data.fieldname,
operator: getDefaultOperator(data.fieldtype),
value: getDefaultValue(data),
})
apply()
}
function updateFilter(data, index) {
if (!data?.fieldname) return
filters.value.delete(Array.from(filters.value)[index])
filters.value.add({
fieldname: data.fieldname,
operator: getDefaultOperator(data.fieldtype),
value: getDefaultValue(data),
field: {
label: data.label,
fieldname: data.fieldname,
fieldtype: data.fieldtype,
options: data.options,
},
})
apply()
}
function removeFilter(index) {
filters.value.delete(Array.from(filters.value)[index])
apply()
}
function clearfilter(close) {
filters.value.clear()
apply()
close()
}
function updateValue(value, filter) {
value = value.target ? value.target.value : value
if (filter.operator === 'between') {
// DateRangePicker emits a [from, to] array; tolerate a legacy "from,to" string too
if (typeof value === 'string') {
value = value.split(',').map((v) => v.trim())
}
filter.value = value
} else {
filter.value = value
}
apply()
}
function updateOperator(filter) {
filter.value = getDefaultValue(filter.field)
if (filter.operator === 'is' || filter.operator === 'is not') {
filter.value = 'set'
}
apply()
}
function apply() {
let _filters = []
filters.value.forEach((f) => {
_filters.push({
fieldname: f.fieldname,
operator: f.operator,
value: f.value,
})
})
emit('update', parseFilters(_filters))
}
function parseFilters(filters) {
const filtersArray = Array.from(filters)
const obj = filtersArray.map(transformIn).reduce((p, c) => {
if (['equals', '='].includes(c.operator)) {
p[c.fieldname] =
c.value == 'Yes' ? true : c.value == 'No' ? false : c.value
} else {
p[c.fieldname] = [operatorMap[c.operator.toLowerCase()], c.value]
}
return p
}, {})
const merged = { ...obj }
return merged
}
function transformIn(f) {
if (f.operator.includes('like') && !f.value.includes('%')) {
f.value = `%${f.value}%`
}
if (['in', 'not in'].includes(f.operator) && typeof f.value === 'string') {
f.value = f.value.split(',').map((v) => v.trim())
}
return f
}
function placeholder(f) {
if (f.operator === 'between') {
return __('01/01/2022 to 01/31/2022')
} else if (f.operator === 'in' || f.operator === 'not in') {
if (typeNumber.includes(f.field.fieldtype)) {
return __('100, 200, 300')
}
return __('John, Jane, Doe')
} else if (f.operator === 'like' || f.operator === 'not like') {
if (typeNumber.includes(f.field.fieldtype)) {
return __('%100%')
}
return __('%John%')
} else if (f.operator === 'is' || f.operator === 'is not') {
return __('Set')
} else if (f.operator === 'timespan') {
return __('Last Week')
} else if (typeNumber.includes(f.field.fieldtype)) {
return __('1000')
} else if (typeDate.includes(f.field.fieldtype)) {
return __('01/01/2022')
} else if (typeCheck.includes(f.field.fieldtype)) {
return __('Yes')
} else if (typeLink.includes(f.field.fieldtype)) {
return __('Select a Value')
} else if (typeSelect.includes(f.field.fieldtype)) {
return __('Select an Option')
} else if (typeString.includes(f.field.fieldtype)) {
return __('John Doe')
}
return __('Enter Value')
}
const operatorMap = {
is: 'is',
'is not': 'is not',
in: 'in',
'not in': 'not in',
equals: '=',
'not equals': '!=',
yes: true,
no: false,
like: 'LIKE',
'not like': 'NOT LIKE',
'>': '>',
'<': '<',
'>=': '>=',
'<=': '<=',
between: 'between',
timespan: 'timespan',
}
const oppositeOperatorMap = {
is: 'is',
'=': 'equals',
'!=': 'not equals',
equals: 'equals',
'is not': 'is not',
true: 'yes',
false: 'no',
LIKE: 'like',
'NOT LIKE': 'not like',
in: 'in',
'not in': 'not in',
'>': '>',
'<': '<',
'>=': '>=',
'<=': '<=',
between: 'between',
timespan: 'timespan',
}
const timespanOptions = [
{
label: __('Last Week'),
value: 'last week',
},
{
label: __('Last Month'),
value: 'last month',
},
{
label: __('Last Quarter'),
value: 'last quarter',
},
{
label: __('Last 6 Months'),
value: 'last 6 months',
},
{
label: __('Last Year'),
value: 'last year',
},
{
label: __('Yesterday'),
value: 'yesterday',
},
{
label: __('Today'),
value: 'today',
},
{
label: __('Tomorrow'),
value: 'tomorrow',
},
{
label: __('This Week'),
value: 'this week',
},
{
label: __('This Month'),
value: 'this month',
},
{
label: __('This Quarter'),
value: 'this quarter',
},
{
label: __('This Year'),
value: 'this year',
},
{
label: __('Next Week'),
value: 'next week',
},
{
label: __('Next Month'),
value: 'next month',
},
{
label: __('Next Quarter'),
value: 'next quarter',
},
{
label: __('Next 6 Months'),
value: 'next 6 months',
},
{
label: __('Next Year'),
value: 'next year',
},
]
</script>

View File

@@ -0,0 +1,74 @@
<template>
<Combobox
:options="options"
:model-value="null"
@update:selected-option="(e) => setGroupBy(e)"
>
<template #trigger="{ open, setOpen }">
<Button
:label="
hideLabel
? groupByValue?.label
: __('Group By: ') + groupByValue?.label
"
:iconLeft="DetailsIcon"
:iconRight="open ? 'chevron-up' : 'chevron-down'"
@click="setOpen(!open)"
/>
</template>
</Combobox>
</template>
<script setup>
import DetailsIcon from '@/components/Icons/DetailsIcon.vue'
import { Combobox, createResource } from 'frappe-ui'
import { ref, computed, onMounted, nextTick, watch } from 'vue'
const props = defineProps({
doctype: { type: String, required: true },
hideLabel: { type: Boolean, default: false },
})
const emit = defineEmits(['update'])
const list = defineModel({ type: Object, default: () => ({}) })
const groupByValue = ref({
label: '',
fieldname: '',
})
const groupByOptions = createResource({
url: 'crm.api.doc.get_group_by_fields',
cache: ['groupByOptions', props.doctype],
params: { doctype: props.doctype },
})
onMounted(() => {
if (groupByOptions.data?.length) return
groupByOptions.fetch()
})
function setGroupBy(data) {
if (!data?.fieldname) return
groupByValue.value = data
nextTick(() => emit('update', data.fieldname))
}
watch(
() => list.value?.data?.group_by_field,
(val) => {
if (val) groupByValue.value = val
},
{ immediate: true },
)
const options = computed(() => {
if (!groupByOptions.data) return []
let data = list.value?.data?.group_by_field
? groupByOptions.data.filter(
(option) => option.fieldname !== groupByValue.value.fieldname,
)
: groupByOptions.data
return data.map((option) => ({ ...option, value: option.fieldname }))
})
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div v-if="isEmoji(icon)" v-bind="$attrs">
{{ icon }}
</div>
<!-- lucide icon from the sprite the IconPicker reads (lucide is a superset of
feather, so legacy names still resolve). No width/height attrs so size
classes like `h-4` control it, matching the old FeatherIcon behaviour. -->
<svg
v-else-if="typeof icon == 'string'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="shrink-0"
v-bind="$attrs"
>
<use :href="`#${icon.replace(/^lucide-/, '')}`" />
</svg>
<component :is="icon" v-else v-bind="$attrs" />
</template>
<script setup>
import { isEmoji } from '@/utils'
defineProps({ icon: { type: [String, Object], required: true } })
</script>

View File

@@ -0,0 +1,115 @@
<template>
<Popover transition="default">
<template #target="{ togglePopover, isOpen }">
<slot v-bind="{ isOpen, togglePopover }">
<span class="text-base"> {{ modelValue || '' }} </span>
</slot>
</template>
<template #body="{ togglePopover }">
<div
v-if="reaction"
class="px-2 py-1 flex items-center justify-center gap-2 rounded-full bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div
v-for="r in reactionEmojis"
:key="r"
class="size-5 cursor-pointer rounded-full bg-surface-transparent text-2xl"
@click="() => (emoji = r) && togglePopover()"
>
<button>
{{ r }}
</button>
</div>
<Button
class="rounded-full"
icon="lucide-plus"
@click.stop="() => (reaction = false)"
/>
</div>
<div v-else class="my-3 max-w-max transform bg-surface-base px-4 sm:px-0">
<div
class="relative max-h-96 pb-3 overflow-y-auto min-w-40 rounded-lg bg-surface-elevation-2 shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
>
<div class="flex gap-2 px-3 pb-1 pt-3">
<div class="flex-1">
<FormControl
v-model="search"
type="text"
placeholder="Search by keyword"
:debounce="300"
/>
</div>
<Button @click="setRandom">Random</Button>
</div>
<div class="w-96"></div>
<div v-for="(emojis, group) in emojiGroups" :key="group" class="px-3">
<div
class="sticky top-0 bg-surface-elevation-2 pb-2 pt-3 text-sm text-ink-gray-7"
>
{{ group }}
</div>
<div class="grid w-96 grid-cols-12 place-items-center">
<button
v-for="_emoji in emojis"
:key="_emoji.description"
class="h-8 w-8 rounded-md p-1 text-3xl hover:bg-surface-gray-2 focus:outline-none focus:ring focus:ring-blue-200"
:title="_emoji.description"
@click="() => (emoji = _emoji.emoji) && togglePopover()"
>
{{ _emoji.emoji }}
</button>
</div>
</div>
</div>
</div>
</template>
</Popover>
</template>
<script setup>
import { Popover } from 'frappe-ui'
import { gemoji } from 'gemoji'
import { ref, computed } from 'vue'
const search = ref('')
const emoji = defineModel({ type: String, default: '' })
const reaction = defineModel('reaction', { type: Boolean })
const reactionEmojis = ref(['👍', '❤️', '😂', '😮', '😢', '🙏'])
const emojiGroups = computed(() => {
let groups = {}
for (let _emoji of gemoji) {
if (search.value) {
let keywords = [_emoji.description, ..._emoji.names, ..._emoji.tags]
.join(' ')
.toLowerCase()
if (!keywords.includes(search.value.toLowerCase())) {
continue
}
}
let group = groups[_emoji.category]
if (!group) {
groups[_emoji.category] = []
group = groups[_emoji.category]
}
group.push(_emoji)
}
if (!Object.keys(groups).length) {
groups['No results'] = []
}
return groups
})
function setRandom() {
let total = gemoji.length
let index = randomInt(0, total - 1)
emoji.value = gemoji[index].emoji
}
function randomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
defineExpose({ setRandom })
</script>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.29021 2.64272C6.68375 0.785763 9.33429 0.785757 9.72783 2.64272L10.3657 5.65238L13.3753 6.29021C15.2323 6.68375 15.2323 9.33429 13.3753 9.72783L10.3657 10.3657L9.72783 13.3753C9.33429 15.2323 6.68375 15.2323 6.29021 13.3753L5.65238 10.3657L2.64272 9.72783C0.785763 9.33429 0.785757 6.68375 2.64272 6.29021L5.65238 5.65238L6.29021 2.64272ZM8.74956 2.85004C8.58 2.04999 7.43804 2.04998 7.26848 2.85004L6.56324 6.17777L6.49584 6.49583L6.17777 6.56324L2.85004 7.26848C2.04999 7.43804 2.04998 8.58 2.85004 8.74956L6.17777 9.4548L6.49584 9.5222L6.56324 9.84027L7.26848 13.168C7.43804 13.9681 8.58 13.9681 8.74956 13.168L9.4548 9.84027L9.5222 9.5222L9.84027 9.4548L13.168 8.74956C13.9681 8.58 13.9681 7.43804 13.168 7.26848L9.84027 6.56324L9.5222 6.49583L9.4548 6.17777L8.74956 2.85004Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.15969 11.0123L4.17279 11.0253C4.17276 11.0253 4.17484 11.0272 4.17897 11.031C4.21695 11.0655 4.42864 11.2579 4.77688 11.5727C5.14213 11.9029 5.62149 12.3355 6.10132 12.7685C6.16929 12.8299 6.23758 12.8915 6.30581 12.9531C7.02952 13.6061 7.74656 14.2532 8.00622 14.4912C8.15155 14.3604 8.43754 14.1068 8.78577 13.7981C9.13708 13.4865 9.55174 13.1189 9.94918 12.7645C10.4342 12.3321 10.9128 11.9027 11.2691 11.5768C11.3745 11.4803 11.4673 11.3947 11.5452 11.3218V11.2964L11.8648 10.9997C12.95 9.99193 13.5109 8.58334 13.5109 7.05226V7.03944L13.5112 7.02663C13.5901 3.95314 11.1555 1.5 8.05226 1.5C4.954 1.5 2.5 3.954 2.5 7.05226C2.5 8.58334 3.06088 9.99193 4.14613 10.9997L4.15969 11.0123ZM7.90678 14.5823C7.91104 14.578 7.91527 14.574 7.91946 14.5701C7.91386 14.5754 7.90961 14.5795 7.90678 14.5823ZM7.39704 15.2894C7.35023 15.2426 6.39086 14.3768 5.43146 13.511C4.47199 12.6451 3.51248 11.7793 3.46568 11.7324C2.15523 10.5156 1.5 8.83073 1.5 7.05226C1.5 3.40172 4.40172 0.5 8.05226 0.5C11.7028 0.5 14.6045 3.40172 14.5109 7.05226C14.5109 8.83073 13.8557 10.5156 12.5452 11.7324C12.5452 11.8024 10.399 13.7046 9.27744 14.6986C8.89921 15.0338 8.63749 15.2658 8.61388 15.2894C8.33307 15.5702 7.77145 15.5702 7.39704 15.2894ZM8.05226 9.92434C6.47034 9.92434 5.18019 8.63419 5.18019 7.05226C5.18019 5.47034 6.47034 4.18019 8.05226 4.18019C9.63419 4.18019 10.9243 5.47034 10.9243 7.05226C10.9243 8.63419 9.63419 9.92434 8.05226 9.92434ZM6.18019 7.05226C6.18019 8.0819 7.02262 8.92434 8.05226 8.92434C9.0819 8.92434 9.92434 8.0819 9.92434 7.05226C9.92434 6.02262 9.0819 5.18019 8.05226 5.18019C7.02262 5.18019 6.18019 6.02262 6.18019 7.05226Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,17 @@
<template>
<svg
fill="none"
height="16"
viewBox="0 0 16 16"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
class=""
clip-rule="evenodd"
d="M13.8496 4.69692L12.0062 6.54029C11.8109 6.73555 11.4944 6.73555 11.2991 6.54028L9.45572 4.69692C9.26046 4.50166 9.26046 4.18508 9.45572 3.98981L11.2991 2.14645C11.4944 1.95118 11.8109 1.95118 12.0062 2.14645L13.8496 3.98981C14.0448 4.18507 14.0448 4.50166 13.8496 4.69692ZM14.5567 3.28271C15.1425 3.86849 15.1425 4.81824 14.5567 5.40403L12.7133 7.24739C12.1275 7.83318 11.1778 7.83318 10.592 7.24739L8.74862 5.40402C8.16283 4.81824 8.16283 3.86849 8.74862 3.28271L10.592 1.43934C11.1778 0.853553 12.1275 0.853554 12.7133 1.43934L14.5567 3.28271ZM5.60691 4.34338C5.60691 5.3394 4.79948 6.14683 3.80346 6.14683C2.80743 6.14683 2 5.3394 2 4.34338C2 3.34736 2.80743 2.53992 3.80346 2.53992C4.79948 2.53992 5.60691 3.34736 5.60691 4.34338ZM6.60691 4.34338C6.60691 5.89168 5.35176 7.14683 3.80346 7.14683C2.25515 7.14683 1 5.89168 1 4.34338C1 2.79507 2.25515 1.53992 3.80346 1.53992C5.35176 1.53992 6.60691 2.79507 6.60691 4.34338ZM12.9565 10.3897H10.3495C10.0734 10.3897 9.84954 10.6136 9.84954 10.8897V13.4966C9.84954 13.7728 10.0734 13.9966 10.3495 13.9966H12.9565C13.2326 13.9966 13.4565 13.7728 13.4565 13.4966V10.8897C13.4565 10.6136 13.2326 10.3897 12.9565 10.3897ZM10.3495 9.38971C9.52112 9.38971 8.84954 10.0613 8.84954 10.8897V13.4966C8.84954 14.325 9.52111 14.9966 10.3495 14.9966H12.9565C13.7849 14.9966 14.4565 14.325 14.4565 13.4966V10.8897C14.4565 10.0613 13.7849 9.38971 12.9565 9.38971H10.3495ZM2.5 10.3897H5.10691C5.38305 10.3897 5.60691 10.6136 5.60691 10.8897V13.4966C5.60691 13.7728 5.38306 13.9966 5.10691 13.9966H2.5C2.22386 13.9966 2 13.7728 2 13.4966V10.8897C2 10.6136 2.22386 10.3897 2.5 10.3897ZM1 10.8897C1 10.0613 1.67157 9.38971 2.5 9.38971H5.10691C5.93534 9.38971 6.60691 10.0613 6.60691 10.8897V13.4966C6.60691 14.325 5.93534 14.9966 5.10691 14.9966H2.5C1.67157 14.9966 1 14.325 1 13.4966V10.8897Z"
fill="currentColor"
fill-rule="evenodd"
></path>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M11.9998 3.5H4.92871C4.65257 3.5 4.42871 3.72386 4.42871 4C4.42871 4.27614 4.65257 4.5 4.92871 4.5H10.7929L3.64645 11.6464C3.45118 11.8417 3.45118 12.1583 3.64645 12.3536C3.84171 12.5488 4.15829 12.5488 4.35355 12.3536L11.4998 5.20733V11.0711C11.4998 11.3472 11.7236 11.5711 11.9998 11.5711C12.2759 11.5711 12.4998 11.3472 12.4998 11.0711V4.0149C12.5037 3.88202 12.455 3.74787 12.3536 3.64645C12.281 3.57388 12.1917 3.52828 12.098 3.50965C12.0813 3.50632 12.0643 3.50383 12.0471 3.50221C12.0336 3.50095 12.02 3.50022 12.0062 3.50004C12.0041 3.50001 12.0019 3.5 11.9998 3.5Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-arrow-down-a-z"
>
<path d="m3 16 4 4 4-4" />
<path d="M7 20V4" />
<path d="M20 8h-5" />
<path d="M15 10V6.5a2.5 2.5 0 0 1 5 0V10" />
<path d="M15 14h5l-5 6h5" />
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M12.5684 2.50774C11.5403 1.49742 9.95026 1.49742 8.92215 2.50774L3.62404 7.71417C2.12532 9.18695 2.12532 11.669 3.62404 13.1418C5.12762 14.6194 7.66861 14.6194 9.17219 13.1418L12.1609 10.2049C12.3578 10.0113 12.6744 10.0141 12.8679 10.211C13.0615 10.408 13.0587 10.7246 12.8618 10.9181L9.8731 13.8551C7.98045 15.715 4.81578 15.715 2.92313 13.8551C1.02562 11.9904 1.02562 8.86558 2.92313 7.00091L8.22124 1.79449C9.63842 0.401838 11.8521 0.401838 13.2693 1.79449C14.6914 3.19191 14.6914 5.38225 13.2693 6.77968L13.2668 6.78213L13.2668 6.78212L8.37876 11.5189C8.37834 11.5193 8.37793 11.5197 8.37752 11.5201C7.51767 12.3638 6.11144 12.3939 5.29119 11.5097C4.43611 10.6596 4.40778 9.26893 5.30922 8.46081L7.33823 6.46692C7.53518 6.27337 7.85175 6.27613 8.04531 6.47309C8.23886 6.67005 8.23609 6.98662 8.03913 7.18017L6.0014 9.18264L5.99203 9.19185L5.98219 9.20055C5.5391 9.59243 5.5104 10.3231 6.0014 10.8056L6.01078 10.8148L6.01967 10.8245C6.42299 11.2649 7.18224 11.2926 7.67785 10.8056L7.68034 10.8032L7.68035 10.8032L12.5684 6.06643C12.5688 6.06604 12.5692 6.06565 12.5696 6.06526C13.5917 5.05969 13.5913 3.51289 12.5684 2.50774Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,17 @@
<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="M8 7C9.65685 7 11 5.65685 11 4C11 2.34315 9.65685 1 8 1C6.34315 1 5 2.34315 5 4C5 5.65685 6.34315 7 8 7ZM7.5969 8C4.50582 8 2 10.5058 2 13.5969C2 14.118 2.37677 14.5628 2.89081 14.6485L4.52397 14.9207C6.82545 15.3042 9.17455 15.3042 11.476 14.9207L13.1092 14.6485C13.6232 14.5628 14 14.118 14 13.5969C14 10.5058 11.4942 8 8.4031 8H7.5969Z"
fill="currentColor"
fill-opacity="0.54"
/>
</svg>
</template>

View File

@@ -0,0 +1,18 @@
<template>
<svg
width="300"
height="300"
viewBox="0 0 300 300"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M214.286 0H85.7143C38.3756 0 0 38.3756 0 85.7143V214.286C0 261.624 38.3756 300 85.7143 300H214.286C261.624 300 300 261.624 300 214.286V85.7143C300 38.3756 261.624 0 214.286 0Z"
fill="#EF0BF5"
/>
<path
d="M64.2141 90.301V111.862H214.339V140.214L160.187 193.146V208.993L139.705 208.885V193.146L85.6605 140.214H64.2141V149.269L118.259 202.202V230.23L181.634 230.769V202.202L235.786 149.269V90.301H64.2141Z"
fill="white"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4.5 1C4.77614 1 5 1.22386 5 1.5V2.5L5 2.50056C5.15613 2.5 5.32244 2.5 5.5 2.5H10.5C10.6776 2.5 10.8439 2.5 11 2.50056C11 2.50037 11 2.50019 11 2.5V1.5C11 1.22386 11.2239 1 11.5 1C11.7761 1 12 1.22386 12 1.5V2.5C12 2.50803 11.9998 2.51602 11.9994 2.52395C12.49 2.55387 12.838 2.62116 13.135 2.77248C13.6054 3.01217 13.9878 3.39462 14.2275 3.86502C14.5 4.3998 14.5 5.09987 14.5 6.5V10.5C14.5 11.9001 14.5 12.6002 14.2275 13.135C13.9878 13.6054 13.6054 13.9878 13.135 14.2275C12.6002 14.5 11.9001 14.5 10.5 14.5H5.5C4.09987 14.5 3.3998 14.5 2.86502 14.2275C2.39462 13.9878 2.01217 13.6054 1.77248 13.135C1.5 12.6002 1.5 11.9001 1.5 10.5V6.5C1.5 5.09987 1.5 4.3998 1.77248 3.86502C2.01217 3.39462 2.39462 3.01217 2.86502 2.77248C3.16202 2.62116 3.51 2.55387 4.00056 2.52395C4.00019 2.51602 4 2.50803 4 2.5V1.5C4 1.22386 4.22386 1 4.5 1ZM5.5 3.5H10.5C11.2166 3.5 11.6938 3.50078 12.0606 3.53074C12.4156 3.55975 12.5781 3.61105 12.681 3.66349C12.9632 3.8073 13.1927 4.03677 13.3365 4.31901C13.389 4.42194 13.4403 4.5844 13.4693 4.93944C13.4916 5.21337 13.4977 5.54901 13.4994 6H2.50061C2.50226 5.54901 2.50836 5.21337 2.53074 4.93944C2.55975 4.5844 2.61105 4.42194 2.66349 4.31901C2.8073 4.03677 3.03677 3.8073 3.31901 3.66349C3.42194 3.61105 3.5844 3.55975 3.93944 3.53074C4.30615 3.50078 4.78343 3.5 5.5 3.5ZM2.5 7V10.5C2.5 11.2166 2.50078 11.6939 2.53074 12.0606C2.55975 12.4156 2.61105 12.5781 2.66349 12.681C2.8073 12.9632 3.03677 13.1927 3.31901 13.3365C3.42194 13.389 3.5844 13.4403 3.93944 13.4693C4.30615 13.4992 4.78343 13.5 5.5 13.5H10.5C11.2166 13.5 11.6938 13.4992 12.0606 13.4693C12.4156 13.4403 12.5781 13.389 12.681 13.3365C12.9632 13.1927 13.1927 12.9632 13.3365 12.681C13.389 12.5781 13.4403 12.4156 13.4693 12.0606C13.4992 11.6939 13.5 11.2166 13.5 10.5V7H2.5Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="20"
height="21"
viewBox="0 0 20 21"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.5 3.53906C8.02786 3.53906 7.58328 3.76135 7.3 4.13906L6.325 5.43906C6.04172 5.81677 5.59714 6.03906 5.125 6.03906H3.25C2.42157 6.03906 1.75 6.71064 1.75 7.53906V16.0391C1.75 16.8675 2.42157 17.5391 3.25 17.5391H16.75C17.5784 17.5391 18.25 16.8675 18.25 16.0391V7.53906C18.25 6.71064 17.5784 6.03906 16.75 6.03906H14.875C14.4029 6.03906 13.9583 5.81677 13.675 5.43906L12.7 4.13906C12.4167 3.76135 11.9721 3.53906 11.5 3.53906H8.5ZM6.5 3.53906C6.97214 2.90955 7.71311 2.53906 8.5 2.53906H11.5C12.2869 2.53906 13.0279 2.90955 13.5 3.53906L14.475 4.83906C14.5694 4.96497 14.7176 5.03906 14.875 5.03906H16.75C18.1307 5.03906 19.25 6.15835 19.25 7.53906V16.0391C19.25 17.4198 18.1307 18.5391 16.75 18.5391H3.25C1.86929 18.5391 0.75 17.4198 0.75 16.0391V7.53906C0.75 6.15835 1.86929 5.03906 3.25 5.03906H5.125C5.28238 5.03906 5.43057 4.96497 5.525 4.83906L6.5 3.53906ZM10 13.9141C11.5188 13.9141 12.75 12.6828 12.75 11.1641C12.75 9.64528 11.5188 8.41406 10 8.41406C8.48122 8.41406 7.25 9.64528 7.25 11.1641C7.25 12.6828 8.48122 13.9141 10 13.9141ZM10 14.9141C12.0711 14.9141 13.75 13.2351 13.75 11.1641C13.75 9.09299 12.0711 7.41406 10 7.41406C7.92893 7.41406 6.25 9.09299 6.25 11.1641C6.25 13.2351 7.92893 14.9141 10 14.9141ZM16.25 8.66406C16.5952 8.66406 16.875 8.38424 16.875 8.03906C16.875 7.69388 16.5952 7.41406 16.25 7.41406C15.9048 7.41406 15.625 7.69388 15.625 8.03906C15.625 8.38424 15.9048 8.66406 16.25 8.66406Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3 2C2.17157 2 1.5 2.67157 1.5 3.5V10.8333C1.5 11.6618 2.17157 12.3333 3 12.3333H8C8.27614 12.3333 8.5 12.5572 8.5 12.8333C8.5 13.1095 8.27614 13.3333 8 13.3333H3C1.61929 13.3333 0.5 12.214 0.5 10.8333V3.5C0.5 2.11929 1.61929 1 3 1H11.6C12.9807 1 14.1 2.11929 14.1 3.5V3.96127C14.1 4.23741 13.8761 4.46127 13.6 4.46127C13.3239 4.46127 13.1 4.23741 13.1 3.96127V3.5C13.1 2.67157 12.4284 2 11.6 2H3ZM11.3003 10.5825V12.891L12.4434 12.3194L12.6693 12.2065L12.8943 12.3213L14.0336 12.9029V10.5824C13.6283 10.806 13.1624 10.9333 12.6668 10.9333C12.1713 10.9333 11.7055 10.8061 11.3003 10.5825ZM15.0336 9.65809V13.7196V14.5362L14.3063 14.1649L12.6646 13.3268L11.0239 14.1472L10.3003 14.509V13.7V9.65848C10.0052 9.21137 9.8335 8.67571 9.8335 8.09994C9.8335 6.53513 11.102 5.2666 12.6668 5.2666C14.2316 5.2666 15.5002 6.53513 15.5002 8.09994C15.5002 8.67554 15.3285 9.21105 15.0336 9.65809ZM10.8335 8.09994C10.8335 7.08741 11.6543 6.2666 12.6668 6.2666C13.6794 6.2666 14.5002 7.08741 14.5002 8.09994C14.5002 9.11246 13.6794 9.93327 12.6668 9.93327C11.6543 9.93327 10.8335 9.11246 10.8335 8.09994ZM3.2998 5.7666C3.2998 5.49046 3.52366 5.2666 3.7998 5.2666H8.46647C8.74261 5.2666 8.96647 5.49046 8.96647 5.7666C8.96647 6.04274 8.74261 6.2666 8.46647 6.2666H3.7998C3.52366 6.2666 3.2998 6.04274 3.2998 5.7666ZM3.7998 8.06665C3.52366 8.06665 3.2998 8.29051 3.2998 8.56665C3.2998 8.84279 3.52366 9.06665 3.7998 9.06665H6.5998C6.87595 9.06665 7.0998 8.84279 7.0998 8.56665C7.0998 8.29051 6.87595 8.06665 6.5998 8.06665H3.7998Z"
fill="#383838"
/>
</svg>
</template>

View File

@@ -0,0 +1,23 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clip-path="url(#clip0_3668_69185)">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M14.25 8C14.25 11.4518 11.4518 14.25 8 14.25C4.54822 14.25 1.75 11.4518 1.75 8C1.75 4.54822 4.54822 1.75 8 1.75C11.4518 1.75 14.25 4.54822 14.25 8ZM15.25 8C15.25 12.0041 12.0041 15.25 8 15.25C3.99594 15.25 0.75 12.0041 0.75 8C0.75 3.99594 3.99594 0.75 8 0.75C12.0041 0.75 15.25 3.99594 15.25 8ZM11.2909 5.98482C11.4666 5.77175 11.4363 5.45663 11.2232 5.28096C11.0101 5.1053 10.695 5.13561 10.5193 5.34868L7.07001 9.53239L5.72845 7.79857C5.55946 7.58018 5.24543 7.54012 5.02703 7.70911C4.80863 7.8781 4.76858 8.19214 4.93756 8.41053L6.66217 10.6394C6.7552 10.7596 6.89788 10.831 7.04988 10.8334C7.20188 10.8357 7.3467 10.7688 7.4434 10.6515L11.2909 5.98482Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_3668_69185">
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-check"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</template>

View File

@@ -0,0 +1,27 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.875 9.06223L3 9.06232"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6.74537 5.31699L3 9.06236L6.74527 12.8076"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M14.1423 4L14.1423 14.125"
stroke="currentColor"
stroke-linecap="round"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.07548 1.50005C8.05087 1.49632 8.02566 1.49438 8 1.49438C7.97434 1.49438 7.94914 1.49632 7.92452 1.50005H3C1.89543 1.50005 1 2.39548 1 3.50005V12.5C1 13.6046 1.89543 14.5 3 14.5H7.92511C7.94954 14.5037 7.97455 14.5056 8 14.5056C8.02545 14.5056 8.05046 14.5037 8.07489 14.5H13C14.1046 14.5 15 13.6046 15 12.5V3.50005C15 2.39548 14.1046 1.50005 13 1.50005H8.07548ZM7.5 2.50005H3C2.44772 2.50005 2 2.94776 2 3.50005V12.5C2 13.0523 2.44772 13.5 3 13.5H7.5L7.5 2.50005ZM8.5 13.5L8.5 2.50005H13C13.5523 2.50005 14 2.94776 14 3.50005V12.5C14 13.0523 13.5523 13.5 13 13.5H8.5Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8 2.5C4.2911 2.5 1.5 4.84419 1.5 7.5C1.5 9.14452 2.54729 10.6518 4.25112 11.5816C4.50048 11.7177 4.758 12.0041 4.7326 12.4082C4.71107 12.7508 4.61659 13.2533 4.47175 13.6657C4.44071 13.7541 4.40789 13.8389 4.37434 13.9195C4.62005 13.8524 4.91964 13.7411 5.26646 13.5579C5.48882 13.4404 5.69385 13.2901 5.9102 13.1207C5.95058 13.089 5.9922 13.056 6.03481 13.0222C6.20816 12.8848 6.39803 12.7342 6.58835 12.6102C6.7875 12.4803 7.01146 12.4447 7.20831 12.4631C7.46767 12.4875 7.73186 12.5 8 12.5C11.7089 12.5 14.5 10.1558 14.5 7.5C14.5 4.84419 11.7089 2.5 8 2.5ZM0.5 7.5C0.5 4.08068 3.97691 1.5 8 1.5C12.0231 1.5 15.5 4.08068 15.5 7.5C15.5 10.9193 12.0231 13.5 8 13.5C7.70158 13.5 7.40709 13.4861 7.11751 13.459C6.97583 13.5528 6.83777 13.6621 6.67569 13.7905C6.62832 13.828 6.5789 13.8671 6.52674 13.908C6.30071 14.085 6.03818 14.2812 5.73354 14.4421C4.73389 14.9702 3.9948 15.0302 3.62977 15.0147C3.39759 15.0048 3.20936 14.8646 3.12194 14.6754C3.03801 14.4938 3.05143 14.2816 3.15736 14.1099C3.24804 13.963 3.40655 13.6808 3.52825 13.3343C3.62961 13.0457 3.70134 12.6878 3.72712 12.4347C1.81521 11.3748 0.5 9.58517 0.5 7.5Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,18 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M9.62476 4.49023C9.62476 5.3877 8.89722 6.11523 7.99976 6.11523C7.10229 6.11523 6.37476 5.3877 6.37476 4.49023C6.37476 3.59277 7.10229 2.86523 7.99976 2.86523C8.89722 2.86523 9.62476 3.59277 9.62476 4.49023ZM10.6248 4.49023C10.6248 5.93998 9.4495 7.11523 7.99976 7.11523C6.55001 7.11523 5.37476 5.93998 5.37476 4.49023C5.37476 3.04049 6.55001 1.86523 7.99976 1.86523C9.4495 1.86523 10.6248 3.04049 10.6248 4.49023ZM3.33049 11.4862C3.67081 9.95482 5.0291 8.86523 6.59788 8.86523H9.40237C10.9711 8.86523 12.3294 9.95482 12.6698 11.4862L12.7625 11.9035C12.96 12.7925 12.2836 13.6359 11.3728 13.6359H4.62741C3.7167 13.6359 3.0402 12.7925 3.23776 11.9035L3.33049 11.4862ZM6.59788 7.86523C4.5604 7.86523 2.7963 9.28035 2.3543 11.2693L2.26158 11.6866C1.92523 13.2001 3.07695 14.6359 4.62741 14.6359H11.3728C12.9233 14.6359 14.075 13.2001 13.7387 11.6866L13.6459 11.2693C13.204 9.28035 11.4398 7.86523 9.40237 7.86523H6.59788Z"
fill="currentColor"
/>
</g>
</svg>
</template>

View File

@@ -0,0 +1,30 @@
<template>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M9.02099 11.5599C10.662 11.5599 11.9923 10.2295 11.9923 8.58852C11.9923 6.9475 10.662 5.61719 9.02099 5.61719C7.37996 5.61719 6.04965 6.9475 6.04965 8.58852C6.04965 10.2295 7.37996 11.5599 9.02099 11.5599Z"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M3.92462 15.2834C4.245 14.2791 4.85628 13.3883 5.68566 12.7269C6.63325 11.9711 7.80938 11.5596 9.02143 11.5596C10.2335 11.5596 11.4096 11.9711 12.3572 12.7269C13.1866 13.3883 13.7979 14.2791 14.1182 15.2834"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect
x="2.25"
y="2.25"
width="13.5"
height="13.5"
rx="2.5"
stroke="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.71639 1.68847C7.20728 1.49315 5.6781 1.84427 4.40534 2.67832C3.50159 3.27056 2.7695 4.07854 2.26943 5.02055C2.13995 5.26445 2.23271 5.56714 2.47662 5.69662C2.72052 5.8261 3.02321 5.73334 3.15269 5.48943C3.57431 4.69521 4.19153 4.01402 4.95345 3.51473C6.02649 2.81155 7.31573 2.51553 8.58803 2.6802C9.86034 2.84487 11.0317 3.45936 11.8904 4.41255C12.5455 5.13978 12.9856 6.02752 13.1713 6.97711L12.0682 6.38995C11.8245 6.2602 11.5217 6.35263 11.3919 6.59639C11.2622 6.84015 11.3546 7.14294 11.5984 7.27269L13.8052 8.44735C14.049 8.5771 14.3518 8.48468 14.4815 8.24091L15.6562 6.03407C15.7859 5.79031 15.6935 5.48752 15.4497 5.35777C15.206 5.22802 14.9032 5.32044 14.7734 5.5642L14.1448 6.74523C13.92 5.63395 13.4012 4.59563 12.6334 3.74325C11.6149 2.61265 10.2255 1.88379 8.71639 1.68847ZM7.51332 14.3501C9.0288 14.4875 10.5434 14.0781 11.7833 13.1959C12.5865 12.6244 13.2401 11.8789 13.7005 11.0233C13.8314 10.7801 13.7404 10.4769 13.4972 10.3461C13.254 10.2152 12.9508 10.3062 12.82 10.5494C12.4317 11.2708 11.8807 11.8993 11.2035 12.3811C10.1582 13.1249 8.88128 13.47 7.6036 13.3542C6.32592 13.2384 5.13186 12.6692 4.23732 11.7496C3.50475 10.9965 3.01266 10.0491 2.81428 9.02749L3.95178 9.68895C4.1905 9.82776 4.49655 9.74677 4.63536 9.50805C4.77417 9.26934 4.69318 8.96329 4.45447 8.82448L2.29329 7.56777C2.05458 7.42895 1.74853 7.50994 1.60972 7.74866L0.353003 9.90983C0.214191 10.1485 0.295179 10.4546 0.533896 10.5934C0.772613 10.7322 1.07866 10.6512 1.21747 10.4125L1.85313 9.31938C2.10072 10.4924 2.67584 11.5786 3.52051 12.4469C4.58154 13.5377 5.99784 14.2128 7.51332 14.3501Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,19 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.25"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-layout-grid"
>
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</svg>
</template>

View File

@@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M7.52007 1.24211C8.01929 0.631274 9.00203 1.10628 8.83348 1.87697L8.03546 5.52575L14.3198 6.41201C14.4986 6.43722 14.6499 6.55687 14.7157 6.72502C14.7814 6.89317 14.7514 7.08371 14.6372 7.22352L8.47994 14.7574C7.98072 15.3683 6.99798 14.8933 7.16653 14.1226L7.96455 10.4738L1.68018 9.58753C1.5014 9.56231 1.3501 9.44267 1.28434 9.27452C1.21859 9.10636 1.2486 8.91582 1.36285 8.77602L7.52007 1.24211ZM7.63341 2.68368L2.69904 8.72132L8.63801 9.55886C8.77578 9.57829 8.89923 9.65425 8.97867 9.76848C9.05812 9.88271 9.08637 10.0249 9.05664 10.1608L8.3666 13.3159L13.301 7.27822L7.362 6.44067C7.22423 6.42124 7.10078 6.34529 7.02134 6.23106C6.9419 6.11683 6.91364 5.97467 6.94337 5.83874L7.63341 2.68368Z"
fill="currentColor"
/>
</svg>
</template>

View File

@@ -0,0 +1,28 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3.08176 7.30769C2.58005 6.18305 2.23673 4.92355 2.07547 3.60766C2.07054 3.53793 2.06535 3.47026 2.06011 3.40458C1.99269 2.56006 2.6315 2 3.47871 2H4.3983C4.85717 2 5.25715 2.31229 5.36844 2.75746L5.51307 3.33596C5.82745 4.59341 5.2987 5.91056 4.20214 6.60162L3.08176 7.30769ZM3.08176 7.30769C3.24962 7.68396 3.43521 8.04513 3.63764 8.38827C4.49814 9.85258 5.70274 11.085 7.14706 11.9786C7.42755 12.1528 7.71775 12.308 8.01716 12.4466C8.09664 12.4834 8.17677 12.5191 8.25753 12.5536M8.25753 12.5536C9.40645 13.0447 10.6848 13.3091 12.0659 13.4781L12.136 13.4867V13.4867C12.963 13.5892 13.5034 12.7424 13.5025 11.9091L13.5016 11.1801C13.5011 10.7318 13.2023 10.3386 12.7705 10.2181L12.2906 10.0842C10.9753 9.71699 9.57793 10.2845 8.89108 11.4648L8.25753 12.5536Z"
stroke="currentColor"
/>
<path
d="M10.8983 2.18433L13.8151 5.10114"
stroke="currentColor"
stroke-width="0.8"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
cx="12.25"
cy="3.75"
r="2.35"
stroke="currentColor"
stroke-width="0.8"
/>
</svg>
</template>

View File

@@ -0,0 +1,20 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-arrow-up-z-a"
>
<path d="m3 8 4-4 4 4" />
<path d="M7 4v16" />
<path d="M15 4h5l-5 6h5" />
<path d="M15 20v-3.5a2.5 2.5 0 0 1 5 0V20" />
<path d="M20 18h-5" />
</svg>
</template>

Some files were not shown because too many files have changed in this diff Show More