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
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:
6
frontend/.gitignore
vendored
Normal file
6
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
components.d.ts
|
||||
4
frontend/.prettierrc.json
Normal file
4
frontend/.prettierrc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
42
frontend/README.md
Normal file
42
frontend/README.md
Normal 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/)
|
||||
56
frontend/eslint.config.mjs
Normal file
56
frontend/eslint.config.mjs
Normal 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
201
frontend/index.html
Normal 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
66
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
frontend/public/favicon.png
Normal file
BIN
frontend/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 440 B |
46
frontend/src/App.vue
Normal file
46
frontend/src/App.vue
Normal 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>
|
||||
869
frontend/src/components/Activities/Activities.vue
Normal file
869
frontend/src/components/Activities/Activities.vue
Normal 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>
|
||||
179
frontend/src/components/Activities/ActivityHeader.vue
Normal file
179
frontend/src/components/Activities/ActivityHeader.vue
Normal 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>
|
||||
133
frontend/src/components/Activities/AllModals.vue
Normal file
133
frontend/src/components/Activities/AllModals.vue
Normal 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>
|
||||
158
frontend/src/components/Activities/AttachmentArea.vue
Normal file
158
frontend/src/components/Activities/AttachmentArea.vue
Normal 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>
|
||||
233
frontend/src/components/Activities/AudioPlayer.vue
Normal file
233
frontend/src/components/Activities/AudioPlayer.vue
Normal 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>
|
||||
123
frontend/src/components/Activities/CallArea.vue
Normal file
123
frontend/src/components/Activities/CallArea.vue
Normal 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>
|
||||
147
frontend/src/components/Activities/CommentArea.vue
Normal file
147
frontend/src/components/Activities/CommentArea.vue
Normal 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>
|
||||
163
frontend/src/components/Activities/DataFields.vue
Normal file
163
frontend/src/components/Activities/DataFields.vue
Normal 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>
|
||||
153
frontend/src/components/Activities/EmailArea.vue
Normal file
153
frontend/src/components/Activities/EmailArea.vue
Normal 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>
|
||||
138
frontend/src/components/Activities/EmailContent.vue
Normal file
138
frontend/src/components/Activities/EmailContent.vue
Normal 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>
|
||||
88
frontend/src/components/Activities/NoteArea.vue
Normal file
88
frontend/src/components/Activities/NoteArea.vue
Normal 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>
|
||||
21
frontend/src/components/Activities/PlaybackSpeedOption.vue
Normal file
21
frontend/src/components/Activities/PlaybackSpeedOption.vue
Normal 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>
|
||||
111
frontend/src/components/Activities/TaskArea.vue
Normal file
111
frontend/src/components/Activities/TaskArea.vue
Normal 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>
|
||||
31
frontend/src/components/Activities/TimelineTimestamp.vue
Normal file
31
frontend/src/components/Activities/TimelineTimestamp.vue
Normal 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>
|
||||
289
frontend/src/components/Activities/WhatsAppArea.vue
Normal file
289
frontend/src/components/Activities/WhatsAppArea.vue
Normal 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>
|
||||
185
frontend/src/components/Activities/WhatsAppBox.vue
Normal file
185
frontend/src/components/Activities/WhatsAppBox.vue
Normal 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>
|
||||
126
frontend/src/components/Activities/emailContent.css
Normal file
126
frontend/src/components/Activities/emailContent.css
Normal 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;
|
||||
}
|
||||
99
frontend/src/components/AssignTo.vue
Normal file
99
frontend/src/components/AssignTo.vue
Normal 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>
|
||||
203
frontend/src/components/AssignToBody.vue
Normal file
203
frontend/src/components/AssignToBody.vue
Normal 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>
|
||||
68
frontend/src/components/AttachmentItem.vue
Normal file
68
frontend/src/components/AttachmentItem.vue
Normal 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>
|
||||
12
frontend/src/components/BrandLogo.vue
Normal file
12
frontend/src/components/BrandLogo.vue
Normal 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>
|
||||
146
frontend/src/components/BulkDeleteLinkedDocModal.vue
Normal file
146
frontend/src/components/BulkDeleteLinkedDocModal.vue
Normal 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>
|
||||
89
frontend/src/components/CollapsibleSection.vue
Normal file
89
frontend/src/components/CollapsibleSection.vue
Normal 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>
|
||||
312
frontend/src/components/ColumnSettings.vue
Normal file
312
frontend/src/components/ColumnSettings.vue
Normal 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>
|
||||
153
frontend/src/components/CommentBox.vue
Normal file
153
frontend/src/components/CommentBox.vue
Normal 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>
|
||||
333
frontend/src/components/CommunicationArea.vue
Normal file
333
frontend/src/components/CommunicationArea.vue
Normal 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>
|
||||
423
frontend/src/components/ConditionsFilter/CFCondition.vue
Normal file
423
frontend/src/components/ConditionsFilter/CFCondition.vue
Normal 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>
|
||||
128
frontend/src/components/ConditionsFilter/CFConditions.vue
Normal file
128
frontend/src/components/ConditionsFilter/CFConditions.vue
Normal 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>
|
||||
19
frontend/src/components/ConditionsFilter/filterableFields.ts
Normal file
19
frontend/src/components/ConditionsFilter/filterableFields.ts
Normal 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
|
||||
},
|
||||
})
|
||||
196
frontend/src/components/Controls/AttachControl.vue
Normal file
196
frontend/src/components/Controls/AttachControl.vue
Normal 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>
|
||||
48
frontend/src/components/Controls/ButtonControl.vue
Normal file
48
frontend/src/components/Controls/ButtonControl.vue
Normal 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>
|
||||
174
frontend/src/components/Controls/DurationInput.vue
Normal file
174
frontend/src/components/Controls/DurationInput.vue
Normal 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>
|
||||
316
frontend/src/components/Controls/EmailMultiSelect.vue
Normal file
316
frontend/src/components/Controls/EmailMultiSelect.vue
Normal 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>
|
||||
37
frontend/src/components/Controls/FormattedInput.vue
Normal file
37
frontend/src/components/Controls/FormattedInput.vue
Normal 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>
|
||||
420
frontend/src/components/Controls/GeolocationControl.vue
Normal file
420
frontend/src/components/Controls/GeolocationControl.vue
Normal 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>
|
||||
850
frontend/src/components/Controls/Grid.vue
Normal file
850
frontend/src/components/Controls/Grid.vue
Normal 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>
|
||||
209
frontend/src/components/Controls/GridFieldsEditorModal.vue
Normal file
209
frontend/src/components/Controls/GridFieldsEditorModal.vue
Normal 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>
|
||||
129
frontend/src/components/Controls/GridRowFieldsModal.vue
Normal file
129
frontend/src/components/Controls/GridRowFieldsModal.vue
Normal 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>
|
||||
81
frontend/src/components/Controls/GridRowModal.vue
Normal file
81
frontend/src/components/Controls/GridRowModal.vue
Normal 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>
|
||||
19
frontend/src/components/Controls/HtmlControl.vue
Normal file
19
frontend/src/components/Controls/HtmlControl.vue
Normal 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>
|
||||
37
frontend/src/components/Controls/ImageUploader.vue
Normal file
37
frontend/src/components/Controls/ImageUploader.vue
Normal 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>
|
||||
204
frontend/src/components/Controls/Link.vue
Normal file
204
frontend/src/components/Controls/Link.vue
Normal 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(/ /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>
|
||||
56
frontend/src/components/Controls/Password.vue
Normal file
56
frontend/src/components/Controls/Password.vue
Normal 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>
|
||||
138
frontend/src/components/Controls/RatingInput.vue
Normal file
138
frontend/src/components/Controls/RatingInput.vue
Normal 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>
|
||||
149
frontend/src/components/Controls/TableMultiselectInput.vue
Normal file
149
frontend/src/components/Controls/TableMultiselectInput.vue
Normal 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>
|
||||
116
frontend/src/components/Controls/TextEditorControl.vue
Normal file
116
frontend/src/components/Controls/TextEditorControl.vue
Normal 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>
|
||||
82
frontend/src/components/CountUpTimer.vue
Normal file
82
frontend/src/components/CountUpTimer.vue
Normal 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>
|
||||
105
frontend/src/components/CustomActions.vue
Normal file
105
frontend/src/components/CustomActions.vue
Normal 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>
|
||||
141
frontend/src/components/Dashboard/AddChartModal.vue
Normal file
141
frontend/src/components/Dashboard/AddChartModal.vue
Normal 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>
|
||||
62
frontend/src/components/Dashboard/DashboardGrid.vue
Normal file
62
frontend/src/components/Dashboard/DashboardGrid.vue
Normal 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>
|
||||
45
frontend/src/components/Dashboard/DashboardItem.vue
Normal file
45
frontend/src/components/Dashboard/DashboardItem.vue
Normal 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>
|
||||
280
frontend/src/components/DeleteLinkedDocModal.vue
Normal file
280
frontend/src/components/DeleteLinkedDocModal.vue
Normal 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>
|
||||
348
frontend/src/components/EmailEditor.vue
Normal file
348
frontend/src/components/EmailEditor.vue
Normal 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>
|
||||
98
frontend/src/components/EnrichFromWebsite.vue
Normal file
98
frontend/src/components/EnrichFromWebsite.vue
Normal 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>
|
||||
23
frontend/src/components/ErrorPage.vue
Normal file
23
frontend/src/components/ErrorPage.vue
Normal 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>
|
||||
80
frontend/src/components/FadedScrollableDiv.vue
Normal file
80
frontend/src/components/FadedScrollableDiv.vue
Normal 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>
|
||||
20
frontend/src/components/FieldLayout/Column.vue
Normal file
20
frontend/src/components/FieldLayout/Column.vue
Normal 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>
|
||||
690
frontend/src/components/FieldLayout/Field.vue
Normal file
690
frontend/src/components/FieldLayout/Field.vue
Normal 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>
|
||||
148
frontend/src/components/FieldLayout/FieldLayout.vue
Normal file
148
frontend/src/components/FieldLayout/FieldLayout.vue
Normal 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>
|
||||
41
frontend/src/components/FieldLayout/Section.vue
Normal file
41
frontend/src/components/FieldLayout/Section.vue
Normal 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>
|
||||
677
frontend/src/components/FieldLayoutEditor.vue
Normal file
677
frontend/src/components/FieldLayoutEditor.vue
Normal 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>
|
||||
225
frontend/src/components/FilesUploader/FilesUploader.vue
Normal file
225
frontend/src/components/FilesUploader/FilesUploader.vue
Normal 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>
|
||||
398
frontend/src/components/FilesUploader/FilesUploaderArea.vue
Normal file
398
frontend/src/components/FilesUploader/FilesUploaderArea.vue
Normal 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>
|
||||
134
frontend/src/components/FilesUploader/filesUploaderHandler.ts
Normal file
134
frontend/src/components/FilesUploader/filesUploaderHandler.ts
Normal 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
|
||||
759
frontend/src/components/Filter.vue
Normal file
759
frontend/src/components/Filter.vue
Normal 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>
|
||||
74
frontend/src/components/GroupBy.vue
Normal file
74
frontend/src/components/GroupBy.vue
Normal 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>
|
||||
27
frontend/src/components/Icon.vue
Normal file
27
frontend/src/components/Icon.vue
Normal 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>
|
||||
115
frontend/src/components/IconPicker.vue
Normal file
115
frontend/src/components/IconPicker.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/ActivityIcon.vue
Normal file
16
frontend/src/components/Icons/ActivityIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
16
frontend/src/components/Icons/AddressIcon.vue
Normal file
16
frontend/src/components/Icons/AddressIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
17
frontend/src/components/Icons/AppsIcon.vue
Normal file
17
frontend/src/components/Icons/AppsIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/ArrowUpRightIcon.vue
Normal file
16
frontend/src/components/Icons/ArrowUpRightIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
20
frontend/src/components/Icons/AscendingIcon.vue
Normal file
20
frontend/src/components/Icons/AscendingIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/AttachmentIcon.vue
Normal file
16
frontend/src/components/Icons/AttachmentIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
17
frontend/src/components/Icons/AvatarIcon.vue
Normal file
17
frontend/src/components/Icons/AvatarIcon.vue
Normal 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>
|
||||
18
frontend/src/components/Icons/CRMLogo.vue
Normal file
18
frontend/src/components/Icons/CRMLogo.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/CalendarIcon.vue
Normal file
16
frontend/src/components/Icons/CalendarIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
16
frontend/src/components/Icons/CameraIcon.vue
Normal file
16
frontend/src/components/Icons/CameraIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/CertificateIcon.vue
Normal file
16
frontend/src/components/Icons/CertificateIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
23
frontend/src/components/Icons/CheckCircleIcon.vue
Normal file
23
frontend/src/components/Icons/CheckCircleIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/CheckIcon.vue
Normal file
16
frontend/src/components/Icons/CheckIcon.vue
Normal 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>
|
||||
27
frontend/src/components/Icons/CollapseSidebar.vue
Normal file
27
frontend/src/components/Icons/CollapseSidebar.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/ColumnsIcon.vue
Normal file
16
frontend/src/components/Icons/ColumnsIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
16
frontend/src/components/Icons/CommentIcon.vue
Normal file
16
frontend/src/components/Icons/CommentIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
18
frontend/src/components/Icons/ContactIcon.vue
Normal file
18
frontend/src/components/Icons/ContactIcon.vue
Normal 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>
|
||||
30
frontend/src/components/Icons/ContactsIcon.vue
Normal file
30
frontend/src/components/Icons/ContactsIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/ConvertIcon.vue
Normal file
16
frontend/src/components/Icons/ConvertIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
19
frontend/src/components/Icons/DashboardIcon.vue
Normal file
19
frontend/src/components/Icons/DashboardIcon.vue
Normal 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>
|
||||
16
frontend/src/components/Icons/DealsIcon.vue
Normal file
16
frontend/src/components/Icons/DealsIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="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>
|
||||
28
frontend/src/components/Icons/DeclinedCallIcon.vue
Normal file
28
frontend/src/components/Icons/DeclinedCallIcon.vue
Normal 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>
|
||||
20
frontend/src/components/Icons/DesendingIcon.vue
Normal file
20
frontend/src/components/Icons/DesendingIcon.vue
Normal 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
Reference in New Issue
Block a user