Merge pull request #479 from frappe/develop
chore: Merge develop to main
4
.github/logo.svg
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 534 B |
BIN
.github/screenshots/CallLog.png
vendored
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
.github/screenshots/CallLogs.png
vendored
|
Before Width: | Height: | Size: 1.2 MiB |
BIN
.github/screenshots/CallUI.png
vendored
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.1 MiB |
BIN
.github/screenshots/DealsList.png
vendored
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
.github/screenshots/EmailTemplate.png
vendored
Normal file
|
After Width: | Height: | Size: 957 KiB |
BIN
.github/screenshots/Emailtemplates.png
vendored
|
Before Width: | Height: | Size: 1.1 MiB |
BIN
.github/screenshots/FrappeCRMHeroImage.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
.github/screenshots/LeadList.png
vendored
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
.github/screenshots/LeadPage.png
vendored
|
Before Width: | Height: | Size: 1023 KiB After Width: | Height: | Size: 970 KiB |
66
.github/workflows/builds.yml
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
name: Build Container Image
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
|
||||
steps:
|
||||
- name: Checkout Entire Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set Branch
|
||||
run: |
|
||||
export APPS_JSON='[{"url": "https://github.com/${{ github.repository }}","branch": "${{ github.ref_name }}"}]'
|
||||
echo "APPS_JSON_BASE64=$(echo $APPS_JSON | base64 -w 0)" >> $GITHUB_ENV
|
||||
echo "FRAPPE_BRANCH=${{ github.ref_type == 'tag' || github.ref_name == 'main' && 'version-15' || 'develop' }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set Image Tag
|
||||
run: |
|
||||
echo "IMAGE_TAG=${{ github.ref_name == 'develop' && 'latest' || 'v15' }}" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: frappe/frappe_docker
|
||||
path: builds
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
push: true
|
||||
context: builds
|
||||
file: builds/images/layered/Containerfile
|
||||
tags: >
|
||||
ghcr.io/${{ github.repository }}:${{ github.ref_name }},
|
||||
ghcr.io/${{ github.repository }}:${{ env.IMAGE_TAG }}
|
||||
build-args: |
|
||||
"FRAPPE_BRANCH=${{ env.FRAPPE_BRANCH }}"
|
||||
"APPS_JSON_BASE64=${{ env.APPS_JSON_BASE64 }}"
|
||||
253
README.md
@ -1,126 +1,193 @@
|
||||
<div align="center">
|
||||
<a href="https://frappe.io/products/crm">
|
||||
<img width="1402" alt="Screenshot 2022-09-18 at 9 16 08 PM" src=".github/screenshots/MainDealPage.png">
|
||||
</a>
|
||||
<div align="center" markdown="1">
|
||||
|
||||
<a href="https://frappe.io/products/crm">
|
||||
<img src=".github/logo.svg" height="80" alt="Frappe CRM Logo">
|
||||
</a>
|
||||
|
||||
<h1>Frappe CRM</h1>
|
||||
|
||||
**Simplify Sales, Amplify Relationships**
|
||||
|
||||

|
||||
|
||||
<div>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/FrappeCRMHeroImage.png">
|
||||
<img width="1402" alt="Frappe CRM Hero Image" src=".github/screenshots/FrappeCRMHeroImage.png">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://img.shields.io/github/issues/frappe/crm">
|
||||
<img alt="issues" src="https://img.shields.io/github/issues/frappe/crm">
|
||||
</a>
|
||||
<a href="https://img.shields.io/github/license/frappe/crm">
|
||||
<img alt="license" src="https://img.shields.io/github/license/frappe/crm">
|
||||
</a>
|
||||
</p>
|
||||
[Live Demo](https://frappecrm-demo.frappe.cloud/api/method/crm.api.demo.login) - [Website](https://frappe.io/crm) - [Documentation](https://docs.frappe.io/crm)
|
||||
|
||||
<details>
|
||||
<summary>Show more screenshots</summary>
|
||||
<img width="1402" alt="Screenshot 2022-09-18 at 9 18 17 PM" src=".github/screenshots/DealsList.png">
|
||||
<img width="1402" alt="Screenshot 2022-09-18 at 11 47 06 PM" src=".github/screenshots/LeadPage.png">
|
||||
<img width="1402" alt="Screenshot 2022-09-18 at 9 18 47 PM" src=".github/screenshots/Emailtemplates.png">
|
||||
<img width="1402" alt="Screenshot 2022-09-18 at 9 18 47 PM" src=".github/screenshots/CallUI.png">
|
||||
<img width="1402" alt="Screenshot 2022-09-18 at 9 18 47 PM" src=".github/screenshots/CallLogs.png">
|
||||
</details>
|
||||
</div>
|
||||
|
||||
## Key Features
|
||||
## Frappe CRM
|
||||
|
||||
- **Views:** Create custom views which is a combination of filters, sort and columns.
|
||||
- **Pinned View:** Pin important leads and deals in the sidebar.
|
||||
- **Public View:** Share views with all users.
|
||||
- **Saved View:** Save views for later use.
|
||||
- **Email Communication:** Send and receive emails directly from the Lead/Deal Page.
|
||||
- **Email Templates:** Create and use email templates for faster communication.
|
||||
- **Comments:** Add comments to leads and deals to keep track of the conversation.
|
||||
- **Notifications:** Get notified when someone mentions you in a comment.
|
||||
- **Service Level Agreement:** Set SLA for leads and deals and get notified when the SLA is breached.
|
||||
- **Assignment Rule:** Automatically assign leads and deals to users based on the criteria.
|
||||
- **Tasks:** Create tasks for leads and deals.
|
||||
- **Notes:** Add notes to leads and deals.
|
||||
- **Call Logs:** See the call logs with call details and recordings.
|
||||
Frappe CRM is a simple, affordable, open-source CRM tool designed for modern sales teams with unlimited users. Frappe CRM is crafted for providing a great user experience, packed with features for core CRM activities helping you build strong customer relationships while keeping things clean and organised.
|
||||
|
||||
## Integrations
|
||||
### Motivation
|
||||
|
||||
The motivation behind building Frappe CRM stems from the need for a simple, customizable, and open-source solution tailored to modern business needs. Many existing CRMs are either too complex, overly generic, or locked behind steep pricing models that hinder accessibility and flexibility. Frappe CRM was designed to bridge this gap, offering a tool that empowers businesses to manage their customer relationships seamlessly while being easy to adapt to specific workflows. Built on the Frappe framework, it prioritizes usability, extensibility, and affordability, making it an ideal choice for growing teams and organizations looking for a CRM that aligns with their unique processes.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **User-Friendly and Flexible:** A simple, intuitive interface that’s easy to navigate and highly customizable, enabling teams to adapt it to their specific processes effortlessly.
|
||||
- **All-in-One Lead/Deal Page:** Consolidate all essential actions and details—like activities, comments, notes, tasks, and more—into a single page for a seamless workflow experience.
|
||||
- **Kanban View:** Manage leads and deals visually with a drag-and-drop Kanban board, offering clarity and efficiency in tracking progress across stages.
|
||||
- **Custom Views:** Design personalized views to organize and display leads and deals using custom filters, sorting, and columns, ensuring quick access to the most relevant information.
|
||||
|
||||
<details>
|
||||
<summary>Screenshots</summary>
|
||||
|
||||
<div>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/LeadList.png">
|
||||
<img width="1402" alt="Lead List" src=".github/screenshots/LeadList.png">
|
||||
</picture>
|
||||
</div>
|
||||
<div>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/LeadPage.png">
|
||||
<img width="1402" alt="Lead Page" src=".github/screenshots/LeadPage.png">
|
||||
</picture>
|
||||
</div>
|
||||
<div>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/EmailTemplate.png">
|
||||
<img width="1402" alt="Email Template" src=".github/screenshots/EmailTemplate.png">
|
||||
</picture>
|
||||
</div>
|
||||
<div>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/CallUI.png">
|
||||
<img width="1402" alt="Call UI" src=".github/screenshots/CallUI.png">
|
||||
</picture>
|
||||
</div>
|
||||
<div>
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/screenshots/CallLog.png">
|
||||
<img width="1402" alt="Call Log" src=".github/screenshots/CallLog.png">
|
||||
</picture>
|
||||
</div>
|
||||
|
||||
</details>
|
||||
|
||||
### Integrations
|
||||
|
||||
- **Twilio:** Integrate Twilio to make and receive calls from the CRM. You can also record calls. It is a built-in integration.
|
||||
- **WhatsApp:** Integrate WhatsApp to send and receive messages from the CRM. [Frappe WhatsApp](https://github.com/shridarpatil/frappe_whatsapp) is used for this integration.
|
||||
- **ERPNext:** Integrate with [ERPNext](https://erpnext.com) to extend the CRM capabilities to include invoicing, accounting, and more.
|
||||
|
||||
## Getting Started
|
||||
### Under the Hood
|
||||
|
||||
- [Frappe Framework](https://github.com/frappe/frappe): A full-stack web application framework.
|
||||
- [Frappe UI](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface.
|
||||
|
||||
## Getting Started (Production)
|
||||
|
||||
### Managed Hosting
|
||||
|
||||
Get started with your personal or business site with a few clicks on [Frappe Cloud](https://frappecloud.com/marketplace/apps/crm).
|
||||
Get started with your personal or business site with a few clicks on Frappe Cloud - our official hosting service.
|
||||
<div>
|
||||
<a href="https://frappecloud.com/crm/signup" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/try-on-fc-white.png">
|
||||
<img src="https://frappe.io/files/try-on-fc-black.png" alt="Try on Frappe Cloud" height="28" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
### Docker (Recommended)
|
||||
### Self Hosting
|
||||
|
||||
The quickest way to set up Frappe CRM and take it for a test ride.
|
||||
Follow these steps to set up Frappe CRM in production:
|
||||
|
||||
Frappe framework is multi-tenant and supports multiple apps by default. This docker compose is just a standalone version with Frappe CRM pre-installed. Just put it behind your desired reverse-proxy if needed, and you're good to go.
|
||||
|
||||
If you wish to use multiple Frappe apps or need multi-tenancy. Take a look at our production ready self-hosted workflow, or join us on Frappe Cloud to get first party support and hassle-free hosting.
|
||||
**Step 1**: Download the easy install script
|
||||
|
||||
```bash
|
||||
wget https://frappe.io/easy-install.py
|
||||
```
|
||||
|
||||
**Step 2**: Run the deployment command
|
||||
|
||||
```bash
|
||||
python3 ./easy-install.py deploy \
|
||||
--project=crm_prod_setup \
|
||||
--email=email.example.com \
|
||||
--image=ghcr.io/frappe/crm \
|
||||
--version=stable \
|
||||
--app=crm \
|
||||
--sitename subdomain.domain.tld
|
||||
```
|
||||
|
||||
Replace the following parameters with your values:
|
||||
|
||||
- `email.example.com`: Your email address
|
||||
- `subdomain.domain.tld`: Your domain name where CRM will be hosted
|
||||
|
||||
The script will set up a production-ready instance of Frappe CRM with all the necessary configurations in about 5 minutes.
|
||||
|
||||
## Getting Started (Development)
|
||||
|
||||
### Local Setup
|
||||
|
||||
1. [Setup Bench](https://docs.frappe.io/framework/user/en/installation).
|
||||
1. In the frappe-bench directory, run `bench start` and keep it running.
|
||||
1. Open a new terminal session and cd into `frappe-bench` directory and run following commands:
|
||||
```sh
|
||||
$ bench get-app crm
|
||||
$ bench new-site sitename.localhost --install-app crm
|
||||
$ bench browse sitename.localhost --user Administrator
|
||||
```
|
||||
1. Access the crm page at `sitename.localhost:8000/crm` in your web browser.
|
||||
|
||||
**For Frontend Development**
|
||||
1. Open a new terminal session and cd into `frappe-bench/apps/crm`, and run the following commands:
|
||||
```
|
||||
yarn install
|
||||
yarn dev
|
||||
```
|
||||
1. Now, you can access the site on vite dev server at `http://sitename.localhost:8080`
|
||||
|
||||
**Note:** You'll find all the code related to Frappe CRM's frontend inside `frappe-bench/apps/crm/frontend`
|
||||
|
||||
### Docker
|
||||
|
||||
You need Docker, docker-compose and git setup on your machine. Refer [Docker documentation](https://docs.docker.com/). After that, follow below steps:
|
||||
|
||||
**Step 1**: Setup folder and download the required files
|
||||
|
||||
mkdir frappe-crm
|
||||
cd frappe-crm
|
||||
|
||||
**Step 2**: Download the required files
|
||||
|
||||
Docker Compose File:
|
||||
|
||||
# Download the docker-compose file
|
||||
wget -O docker-compose.yml https://raw.githubusercontent.com/frappe/crm/develop/docker/docker-compose.yml
|
||||
|
||||
Frappe CRM bench setup script
|
||||
|
||||
# Download the setup script
|
||||
wget -O init.sh https://raw.githubusercontent.com/frappe/crm/develop/docker/init.sh
|
||||
|
||||
**Step 3**: Run the container and daemonize it
|
||||
**Step 2**: Run the container and daemonize it
|
||||
|
||||
docker compose up -d
|
||||
|
||||
**Step 4**: The site [http://crm.localhost](http://crm.localhost) should now be available. The default credentials are:
|
||||
**Step 3**: The site [http://crm.localhost:8000/crm](http://crm.localhost:8000/crm) should now be available. The default credentials are:
|
||||
|
||||
> username: administrator
|
||||
> password: admin
|
||||
- Username: Administrator
|
||||
- Password: admin
|
||||
|
||||
### Self-hosting
|
||||
## Learn and connect
|
||||
|
||||
If you prefer self-hosting, follow the official [Frappe Bench Installation](https://github.com/frappe/bench#installation) instructions.
|
||||
- [Telegram Public Group](https://t.me/frappecrm)
|
||||
- [Discuss Forum](https://discuss.frappe.io/c/frappe-crm)
|
||||
- [Documentation](https://docs.frappe.io/crm)
|
||||
- [YouTube](https://www.youtube.com/channel/UCn3bV5kx77HsVwtnlCeEi_A)
|
||||
|
||||
## Want to Just Try Out or Contribute?
|
||||
|
||||
### Codespaces
|
||||
|
||||
1. Open [this link](https://github.com/codespaces/new?hide_repo_select=true&ref=master&repo=668199241&skip_quickstart=true&machine=standardLinux32gb&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=SoutheastAsia) and click on "Create Codespace".
|
||||
2. Wait for initialization (~15 mins).
|
||||
3. Run `bench start` from the terminal tab.
|
||||
4. Click on the link beside "8000" port under "Ports" tab.
|
||||
5. Log in with "Administrator" as the username and "admin" as the password.
|
||||
6. Go to `<random-id>.github.dev/crm` to access the crm interface.
|
||||
|
||||
### Local Setup
|
||||
|
||||
1. [Install Bench](https://github.com/frappe/bench).
|
||||
2. Install Frappe CRM app:
|
||||
```sh
|
||||
$ bench get-app crm
|
||||
```
|
||||
3. Create a site with the crm app:
|
||||
```sh
|
||||
$ bench --site sitename.localhost install-app crm
|
||||
```
|
||||
4. Open the site in the browser:
|
||||
```sh
|
||||
$ bench browse sitename.localhost --user Administrator
|
||||
```
|
||||
5. Access the crm page at `sitename.localhost:8000/crm` in your web browser.
|
||||
|
||||
## Need help?
|
||||
|
||||
Join our [telegram group](https://t.me/frappecrm) for instant help.
|
||||
|
||||
## Documentation
|
||||
|
||||
Check out the [official documentation](https://docs.frappe.io/crm) for more details.
|
||||
|
||||
## License
|
||||
|
||||
[GNU Affero General Public License v3.0](LICENSE)
|
||||
<br>
|
||||
<br>
|
||||
<div align="center" style="padding-top: 0.75rem;">
|
||||
<a href="https://frappe.io" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/Frappe-white.png">
|
||||
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@ -5,6 +5,7 @@ from frappe import _
|
||||
from bs4 import BeautifulSoup
|
||||
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
||||
|
||||
|
||||
def on_update(self, method):
|
||||
notify_mentions(self)
|
||||
|
||||
@ -24,25 +25,31 @@ def notify_mentions(doc):
|
||||
doctype = doc.reference_doctype
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
name = reference_doc.lead_name or name if doctype == "lead" else reference_doc.organization or reference_doc.lead_name or name
|
||||
name = (
|
||||
reference_doc.lead_name
|
||||
if doctype == "lead"
|
||||
else reference_doc.organization or reference_doc.lead_name
|
||||
)
|
||||
notification_text = f"""
|
||||
<div class="mb-2 leading-5 text-gray-600">
|
||||
<span class="font-medium text-gray-900">{ owner }</span>
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||
<span>{ _('mentioned you in {0}').format(doctype) }</span>
|
||||
<span class="font-medium text-gray-900">{ name }</span>
|
||||
<span class="font-medium text-ink-gray-9">{ name }</span>
|
||||
</div>
|
||||
"""
|
||||
notify_user({
|
||||
"owner": doc.owner,
|
||||
"assigned_to": mention.email,
|
||||
"notification_type": "Mention",
|
||||
"message": doc.content,
|
||||
"notification_text": notification_text,
|
||||
"reference_doctype": "Comment",
|
||||
"reference_docname": doc.name,
|
||||
"redirect_to_doctype": doc.reference_doctype,
|
||||
"redirect_to_docname": doc.reference_name,
|
||||
})
|
||||
notify_user(
|
||||
{
|
||||
"owner": doc.owner,
|
||||
"assigned_to": mention.email,
|
||||
"notification_type": "Mention",
|
||||
"message": doc.content,
|
||||
"notification_text": notification_text,
|
||||
"reference_doctype": "Comment",
|
||||
"reference_docname": doc.name,
|
||||
"redirect_to_doctype": doc.reference_doctype,
|
||||
"redirect_to_docname": doc.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def extract_mentions(html):
|
||||
@ -56,39 +63,42 @@ def extract_mentions(html):
|
||||
)
|
||||
return mentions
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_attachments(name: str, attachments: Iterable[str | dict]) -> None:
|
||||
"""Add attachments to the given Comment
|
||||
"""Add attachments to the given Comment
|
||||
|
||||
:param name: Comment name
|
||||
:param attachments: File names or dicts with keys "fname" and "fcontent"
|
||||
"""
|
||||
# loop through attachments
|
||||
for a in attachments:
|
||||
if isinstance(a, str):
|
||||
attach = frappe.db.get_value("File", {"name": a}, ["file_url", "is_private"], as_dict=1)
|
||||
file_args = {
|
||||
"file_url": attach.file_url,
|
||||
"is_private": attach.is_private,
|
||||
}
|
||||
elif isinstance(a, dict) and "fcontent" in a and "fname" in a:
|
||||
# dict returned by frappe.attach_print()
|
||||
file_args = {
|
||||
"file_name": a["fname"],
|
||||
"content": a["fcontent"],
|
||||
"is_private": 1,
|
||||
}
|
||||
else:
|
||||
continue
|
||||
:param name: Comment name
|
||||
:param attachments: File names or dicts with keys "fname" and "fcontent"
|
||||
"""
|
||||
# loop through attachments
|
||||
for a in attachments:
|
||||
if isinstance(a, str):
|
||||
attach = frappe.db.get_value(
|
||||
"File", {"name": a}, ["file_url", "is_private"], as_dict=1
|
||||
)
|
||||
file_args = {
|
||||
"file_url": attach.file_url,
|
||||
"is_private": attach.is_private,
|
||||
}
|
||||
elif isinstance(a, dict) and "fcontent" in a and "fname" in a:
|
||||
# dict returned by frappe.attach_print()
|
||||
file_args = {
|
||||
"file_name": a["fname"],
|
||||
"content": a["fcontent"],
|
||||
"is_private": 1,
|
||||
}
|
||||
else:
|
||||
continue
|
||||
|
||||
file_args.update(
|
||||
{
|
||||
"attached_to_doctype": "Comment",
|
||||
"attached_to_name": name,
|
||||
"folder": "Home/Attachments",
|
||||
}
|
||||
)
|
||||
file_args.update(
|
||||
{
|
||||
"attached_to_doctype": "Comment",
|
||||
"attached_to_name": name,
|
||||
"folder": "Home/Attachments",
|
||||
}
|
||||
)
|
||||
|
||||
_file = frappe.new_doc("File")
|
||||
_file.update(file_args)
|
||||
_file.save(ignore_permissions=True)
|
||||
_file = frappe.new_doc("File")
|
||||
_file.update(file_args)
|
||||
_file.save(ignore_permissions=True)
|
||||
|
||||
176
crm/api/doc.py
@ -78,12 +78,7 @@ def get_filterable_fields(doctype: str):
|
||||
# append standard fields (getting error when using frappe.model.std_fields)
|
||||
standard_fields = [
|
||||
{"fieldname": "name", "fieldtype": "Link", "label": "ID", "options": doctype},
|
||||
{
|
||||
"fieldname": "owner",
|
||||
"fieldtype": "Link",
|
||||
"label": "Created By",
|
||||
"options": "User"
|
||||
},
|
||||
{"fieldname": "owner", "fieldtype": "Link", "label": "Created By", "options": "User"},
|
||||
{
|
||||
"fieldname": "modified_by",
|
||||
"fieldtype": "Link",
|
||||
@ -98,10 +93,7 @@ def get_filterable_fields(doctype: str):
|
||||
{"fieldname": "modified", "fieldtype": "Datetime", "label": "Last Updated On"},
|
||||
]
|
||||
for field in standard_fields:
|
||||
if (
|
||||
field.get("fieldname") not in restricted_fields and
|
||||
field.get("fieldtype") in allowed_fieldtypes
|
||||
):
|
||||
if field.get("fieldname") not in restricted_fields and field.get("fieldtype") in allowed_fieldtypes:
|
||||
field["name"] = field.get("fieldname")
|
||||
res.append(field)
|
||||
|
||||
@ -128,7 +120,11 @@ def get_group_by_fields(doctype: str):
|
||||
]
|
||||
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldtype not in no_value_fields and field.fieldtype in allowed_fieldtypes]
|
||||
fields = [
|
||||
field
|
||||
for field in fields
|
||||
if field.fieldtype not in no_value_fields and field.fieldtype in allowed_fieldtypes
|
||||
]
|
||||
fields = [
|
||||
{
|
||||
"label": _(field.label),
|
||||
@ -176,6 +172,7 @@ def get_doctype_fields_meta(DocField, doctype, allowed_fieldtypes, restricted_fi
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_quick_filters(doctype: str):
|
||||
meta = frappe.get_meta(doctype)
|
||||
@ -183,23 +180,25 @@ def get_quick_filters(doctype: str):
|
||||
quick_filters = []
|
||||
|
||||
for field in fields:
|
||||
|
||||
if field.fieldtype == "Select":
|
||||
field.options = field.options.split("\n")
|
||||
field.options = [{"label": option, "value": option} for option in field.options]
|
||||
field.options.insert(0, {"label": "", "value": ""})
|
||||
quick_filters.append({
|
||||
"label": _(field.label),
|
||||
"name": field.fieldname,
|
||||
"type": field.fieldtype,
|
||||
"options": field.options,
|
||||
})
|
||||
quick_filters.append(
|
||||
{
|
||||
"label": _(field.label),
|
||||
"name": field.fieldname,
|
||||
"type": field.fieldtype,
|
||||
"options": field.options,
|
||||
}
|
||||
)
|
||||
|
||||
if doctype == "CRM Lead":
|
||||
quick_filters = [filter for filter in quick_filters if filter.get("name") != "converted"]
|
||||
|
||||
return quick_filters
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_data(
|
||||
doctype: str,
|
||||
@ -223,9 +222,9 @@ def get_data(
|
||||
kanban_fields = frappe.parse_json(kanban_fields or "[]")
|
||||
kanban_columns = frappe.parse_json(kanban_columns or "[]")
|
||||
|
||||
custom_view_name = view.get('custom_view_name') if view else None
|
||||
view_type = view.get('view_type') if view else None
|
||||
group_by_field = view.get('group_by_field') if view else None
|
||||
custom_view_name = view.get("custom_view_name") if view else None
|
||||
view_type = view.get("view_type") if view else None
|
||||
group_by_field = view.get("group_by_field") if view else None
|
||||
|
||||
for key in filters:
|
||||
value = filters[key]
|
||||
@ -268,7 +267,7 @@ def get_data(
|
||||
|
||||
default_view_filters = {
|
||||
"dt": doctype,
|
||||
"type": view_type or 'list',
|
||||
"type": view_type or "list",
|
||||
"is_default": 1,
|
||||
"user": frappe.session.user,
|
||||
}
|
||||
@ -295,13 +294,16 @@ def get_data(
|
||||
if group_by_field and group_by_field not in rows:
|
||||
rows.append(group_by_field)
|
||||
|
||||
data = frappe.get_list(
|
||||
doctype,
|
||||
fields=rows,
|
||||
filters=filters,
|
||||
order_by=order_by,
|
||||
page_length=page_length,
|
||||
) or []
|
||||
data = (
|
||||
frappe.get_list(
|
||||
doctype,
|
||||
fields=rows,
|
||||
filters=filters,
|
||||
order_by=order_by,
|
||||
page_length=page_length,
|
||||
)
|
||||
or []
|
||||
)
|
||||
|
||||
if view_type == "kanban":
|
||||
if not rows:
|
||||
@ -336,9 +338,9 @@ def get_data(
|
||||
rows.append(field)
|
||||
|
||||
for kc in kanban_columns:
|
||||
column_filters = { column_field: kc.get('name') }
|
||||
column_filters = {column_field: kc.get("name")}
|
||||
order = kc.get("order")
|
||||
if column_field in filters and filters.get(column_field) != kc.name or kc.get('delete'):
|
||||
if column_field in filters and filters.get(column_field) != kc.name or kc.get("delete"):
|
||||
column_data = []
|
||||
else:
|
||||
column_filters.update(filters.copy())
|
||||
@ -348,7 +350,9 @@ def get_data(
|
||||
page_length = kc.get("page_length")
|
||||
|
||||
if order:
|
||||
column_data = get_records_based_on_order(doctype, rows, column_filters, page_length, order)
|
||||
column_data = get_records_based_on_order(
|
||||
doctype, rows, column_filters, page_length, order
|
||||
)
|
||||
else:
|
||||
column_data = frappe.get_list(
|
||||
doctype,
|
||||
@ -359,9 +363,11 @@ def get_data(
|
||||
)
|
||||
|
||||
new_filters = filters.copy()
|
||||
new_filters.update({ column_field: kc.get('name') })
|
||||
new_filters.update({column_field: kc.get("name")})
|
||||
|
||||
all_count = len(frappe.get_list(doctype, filters=convert_filter_to_tuple(doctype, new_filters)))
|
||||
all_count = len(
|
||||
frappe.get_list(doctype, filters=convert_filter_to_tuple(doctype, new_filters))
|
||||
)
|
||||
|
||||
kc["all_count"] = all_count
|
||||
kc["count"] = len(column_data)
|
||||
@ -371,8 +377,8 @@ def get_data(
|
||||
|
||||
if order:
|
||||
column_data = sorted(
|
||||
column_data, key=lambda x: order.index(x.get("name"))
|
||||
if x.get("name") in order else len(order)
|
||||
column_data,
|
||||
key=lambda x: order.index(x.get("name")) if x.get("name") in order else len(order),
|
||||
)
|
||||
|
||||
data.append({"column": kc, "fields": kanban_fields, "data": column_data})
|
||||
@ -406,8 +412,8 @@ def get_data(
|
||||
]
|
||||
|
||||
for field in std_fields:
|
||||
if field.get('value') not in rows:
|
||||
rows.append(field.get('value'))
|
||||
if field.get("value") not in rows:
|
||||
rows.append(field.get("value"))
|
||||
if field not in fields:
|
||||
field["label"] = _(field["label"])
|
||||
fields.append(field)
|
||||
@ -416,6 +422,7 @@ def get_data(
|
||||
is_default = frappe.db.get_value("CRM View Settings", custom_view_name, "load_default_columns")
|
||||
|
||||
if group_by_field and view_type == "group_by":
|
||||
|
||||
def get_options(type, options):
|
||||
if type == "Select":
|
||||
return [option for option in options.split("\n")]
|
||||
@ -428,7 +435,9 @@ def get_data(
|
||||
|
||||
if order_by and group_by_field in order_by:
|
||||
order_by_fields = order_by.split(",")
|
||||
order_by_fields = [(field.split(" ")[0], field.split(" ")[1]) for field in order_by_fields]
|
||||
order_by_fields = [
|
||||
(field.split(" ")[0], field.split(" ")[1]) for field in order_by_fields
|
||||
]
|
||||
if (group_by_field, "asc") in order_by_fields:
|
||||
options.sort()
|
||||
elif (group_by_field, "desc") in order_by_fields:
|
||||
@ -467,6 +476,7 @@ def get_data(
|
||||
"view_type": view_type,
|
||||
}
|
||||
|
||||
|
||||
def convert_filter_to_tuple(doctype, filters):
|
||||
if isinstance(filters, dict):
|
||||
filters_items = filters.items()
|
||||
@ -504,6 +514,7 @@ def get_records_based_on_order(doctype, rows, filters, page_length, order):
|
||||
|
||||
return records
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
|
||||
not_allowed_fieldtypes = [
|
||||
@ -521,12 +532,7 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
|
||||
|
||||
standard_fields = [
|
||||
{"fieldname": "name", "fieldtype": "Link", "label": "ID", "options": doctype},
|
||||
{
|
||||
"fieldname": "owner",
|
||||
"fieldtype": "Link",
|
||||
"label": "Created By",
|
||||
"options": "User"
|
||||
},
|
||||
{"fieldname": "owner", "fieldtype": "Link", "label": "Created By", "options": "User"},
|
||||
{
|
||||
"fieldname": "modified_by",
|
||||
"fieldtype": "Link",
|
||||
@ -542,7 +548,7 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
|
||||
]
|
||||
|
||||
for field in standard_fields:
|
||||
if not restricted_fieldtypes or field.get('fieldtype') not in restricted_fieldtypes:
|
||||
if not restricted_fieldtypes or field.get("fieldtype") not in restricted_fieldtypes:
|
||||
fields.append(field)
|
||||
|
||||
if as_array:
|
||||
@ -550,10 +556,11 @@ def get_fields_meta(doctype, restricted_fieldtypes=None, as_array=False):
|
||||
|
||||
fields_meta = {}
|
||||
for field in fields:
|
||||
fields_meta[field.get('fieldname')] = field
|
||||
fields_meta[field.get("fieldname")] = field
|
||||
|
||||
return fields_meta
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_sidebar_fields(doctype, name):
|
||||
if not frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": "Side Panel"}):
|
||||
@ -562,7 +569,7 @@ def get_sidebar_fields(doctype, name):
|
||||
|
||||
if not layout:
|
||||
return []
|
||||
|
||||
|
||||
layout = json.loads(layout)
|
||||
|
||||
not_allowed_fieldtypes = [
|
||||
@ -600,6 +607,7 @@ def get_sidebar_fields(doctype, name):
|
||||
|
||||
return layout
|
||||
|
||||
|
||||
def get_field_obj(field):
|
||||
obj = {
|
||||
"label": field.label,
|
||||
@ -641,6 +649,7 @@ def get_type(field):
|
||||
return "read_only"
|
||||
return field.fieldtype.lower()
|
||||
|
||||
|
||||
def get_assigned_users(doctype, name, default_assigned_to=None):
|
||||
assigned_users = frappe.get_all(
|
||||
"ToDo",
|
||||
@ -671,32 +680,55 @@ def get_fields(doctype: str, allow_all_fieldtypes: bool = False):
|
||||
_fields = []
|
||||
|
||||
for field in fields:
|
||||
if (
|
||||
field.fieldtype not in not_allowed_fieldtypes
|
||||
and field.fieldname
|
||||
):
|
||||
_fields.append({
|
||||
"label": field.label,
|
||||
"type": field.fieldtype,
|
||||
"value": field.fieldname,
|
||||
"options": field.options,
|
||||
"mandatory": field.reqd,
|
||||
"read_only": field.read_only,
|
||||
"hidden": field.hidden,
|
||||
"depends_on": field.depends_on,
|
||||
"mandatory_depends_on": field.mandatory_depends_on,
|
||||
"read_only_depends_on": field.read_only_depends_on,
|
||||
"link_filters": field.get("link_filters"),
|
||||
"placeholder": field.get("placeholder"),
|
||||
})
|
||||
if field.fieldtype not in not_allowed_fieldtypes and field.fieldname:
|
||||
_fields.append(
|
||||
{
|
||||
"label": field.label,
|
||||
"type": field.fieldtype,
|
||||
"value": field.fieldname,
|
||||
"options": field.options,
|
||||
"mandatory": field.reqd,
|
||||
"read_only": field.read_only,
|
||||
"hidden": field.hidden,
|
||||
"depends_on": field.depends_on,
|
||||
"mandatory_depends_on": field.mandatory_depends_on,
|
||||
"read_only_depends_on": field.read_only_depends_on,
|
||||
"link_filters": field.get("link_filters"),
|
||||
"placeholder": field.get("placeholder"),
|
||||
}
|
||||
)
|
||||
|
||||
return _fields
|
||||
|
||||
|
||||
def getCounts(d, doctype):
|
||||
d["_email_count"] = frappe.db.count("Communication", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "communication_type": "Communication"}) or 0
|
||||
d["_email_count"] = d["_email_count"] + frappe.db.count("Communication", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "communication_type": "Automated Message"})
|
||||
d["_comment_count"] = frappe.db.count("Comment", filters={"reference_doctype": doctype, "reference_name": d.get("name"), "comment_type": "Comment"})
|
||||
d["_task_count"] = frappe.db.count("CRM Task", filters={"reference_doctype": doctype, "reference_docname": d.get("name")})
|
||||
d["_note_count"] = frappe.db.count("FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")})
|
||||
return d
|
||||
d["_email_count"] = (
|
||||
frappe.db.count(
|
||||
"Communication",
|
||||
filters={
|
||||
"reference_doctype": doctype,
|
||||
"reference_name": d.get("name"),
|
||||
"communication_type": "Communication",
|
||||
},
|
||||
)
|
||||
or 0
|
||||
)
|
||||
d["_email_count"] = d["_email_count"] + frappe.db.count(
|
||||
"Communication",
|
||||
filters={
|
||||
"reference_doctype": doctype,
|
||||
"reference_name": d.get("name"),
|
||||
"communication_type": "Automated Message",
|
||||
},
|
||||
)
|
||||
d["_comment_count"] = frappe.db.count(
|
||||
"Comment",
|
||||
filters={"reference_doctype": doctype, "reference_name": d.get("name"), "comment_type": "Comment"},
|
||||
)
|
||||
d["_task_count"] = frappe.db.count(
|
||||
"CRM Task", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
|
||||
)
|
||||
d["_note_count"] = frappe.db.count(
|
||||
"FCRM Note", filters={"reference_doctype": doctype, "reference_docname": d.get("name")}
|
||||
)
|
||||
return d
|
||||
|
||||
191
crm/api/todo.py
@ -2,102 +2,131 @@ import frappe
|
||||
from frappe import _
|
||||
from crm.fcrm.doctype.crm_notification.crm_notification import notify_user
|
||||
|
||||
def after_insert(doc, method):
|
||||
if doc.reference_type in ["CRM Lead", "CRM Deal"] and doc.reference_name and doc.allocated_to:
|
||||
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
|
||||
lead_owner = frappe.db.get_value(doc.reference_type, doc.reference_name, fieldname)
|
||||
if not lead_owner:
|
||||
frappe.db.set_value(doc.reference_type, doc.reference_name, fieldname, doc.allocated_to)
|
||||
|
||||
if doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_name and doc.allocated_to:
|
||||
notify_assigned_user(doc)
|
||||
def after_insert(doc, method):
|
||||
if (
|
||||
doc.reference_type in ["CRM Lead", "CRM Deal"]
|
||||
and doc.reference_name
|
||||
and doc.allocated_to
|
||||
):
|
||||
fieldname = "lead_owner" if doc.reference_type == "CRM Lead" else "deal_owner"
|
||||
lead_owner = frappe.db.get_value(
|
||||
doc.reference_type, doc.reference_name, fieldname
|
||||
)
|
||||
if not lead_owner:
|
||||
frappe.db.set_value(
|
||||
doc.reference_type, doc.reference_name, fieldname, doc.allocated_to
|
||||
)
|
||||
|
||||
if (
|
||||
doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
||||
and doc.reference_name
|
||||
and doc.allocated_to
|
||||
):
|
||||
notify_assigned_user(doc)
|
||||
|
||||
|
||||
def on_update(doc, method):
|
||||
if doc.has_value_changed("status") and doc.status == "Cancelled" and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"] and doc.reference_name and doc.allocated_to:
|
||||
notify_assigned_user(doc, is_cancelled=True)
|
||||
if (
|
||||
doc.has_value_changed("status")
|
||||
and doc.status == "Cancelled"
|
||||
and doc.reference_type in ["CRM Lead", "CRM Deal", "CRM Task"]
|
||||
and doc.reference_name
|
||||
and doc.allocated_to
|
||||
):
|
||||
notify_assigned_user(doc, is_cancelled=True)
|
||||
|
||||
|
||||
def notify_assigned_user(doc, is_cancelled=False):
|
||||
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
|
||||
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
|
||||
_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||
owner = frappe.get_cached_value("User", frappe.session.user, "full_name")
|
||||
notification_text = get_notification_text(owner, doc, _doc, is_cancelled)
|
||||
|
||||
message = _("Your assignment on {0} {1} has been removed by {2}").format(
|
||||
doc.reference_type,
|
||||
doc.reference_name,
|
||||
owner
|
||||
) if is_cancelled else _("{0} assigned a {1} {2} to you").format(
|
||||
owner,
|
||||
doc.reference_type,
|
||||
doc.reference_name
|
||||
)
|
||||
message = (
|
||||
_("Your assignment on {0} {1} has been removed by {2}").format(
|
||||
doc.reference_type, doc.reference_name, owner
|
||||
)
|
||||
if is_cancelled
|
||||
else _("{0} assigned a {1} {2} to you").format(
|
||||
owner, doc.reference_type, doc.reference_name
|
||||
)
|
||||
)
|
||||
|
||||
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
|
||||
redirect_to_doctype, redirect_to_name = get_redirect_to_doc(doc)
|
||||
|
||||
notify_user(
|
||||
{
|
||||
"owner": frappe.session.user,
|
||||
"assigned_to": doc.allocated_to,
|
||||
"notification_type": "Assignment",
|
||||
"message": message,
|
||||
"notification_text": notification_text,
|
||||
"reference_doctype": doc.reference_type,
|
||||
"reference_docname": doc.reference_name,
|
||||
"redirect_to_doctype": redirect_to_doctype,
|
||||
"redirect_to_docname": redirect_to_name,
|
||||
}
|
||||
)
|
||||
|
||||
notify_user({
|
||||
"owner": frappe.session.user,
|
||||
"assigned_to": doc.allocated_to,
|
||||
"notification_type": "Assignment",
|
||||
"message": message,
|
||||
"notification_text": notification_text,
|
||||
"reference_doctype": doc.reference_type,
|
||||
"reference_docname": doc.reference_name,
|
||||
"redirect_to_doctype": redirect_to_doctype,
|
||||
"redirect_to_docname": redirect_to_name,
|
||||
})
|
||||
|
||||
def get_notification_text(owner, doc, reference_doc, is_cancelled=False):
|
||||
name = doc.reference_name
|
||||
doctype = doc.reference_type
|
||||
name = doc.reference_name
|
||||
doctype = doc.reference_type
|
||||
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
|
||||
if doctype in ["lead", "deal"]:
|
||||
name = reference_doc.lead_name or name if doctype == "lead" else reference_doc.organization or reference_doc.lead_name or name
|
||||
if doctype in ["lead", "deal"]:
|
||||
name = (
|
||||
reference_doc.lead_name or name
|
||||
if doctype == "lead"
|
||||
else reference_doc.organization or reference_doc.lead_name or name
|
||||
)
|
||||
|
||||
if is_cancelled:
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-gray-600">
|
||||
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
|
||||
doctype,
|
||||
f'<span class="font-medium text-gray-900">{ name }</span>',
|
||||
f'<span class="font-medium text-gray-900">{ owner }</span>'
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
if is_cancelled:
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span>{ _('Your assignment on {0} {1} has been removed by {2}').format(
|
||||
doctype,
|
||||
f'<span class="font-medium text-ink-gray-9">{ name }</span>',
|
||||
f'<span class="font-medium text-ink-gray-9">{ owner }</span>'
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-gray-600">
|
||||
<span class="font-medium text-gray-900">{ owner }</span>
|
||||
<span>{ _('assigned a {0} {1} to you').format(
|
||||
doctype,
|
||||
f'<span class="font-medium text-gray-900">{ name }</span>'
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||
<span>{ _('assigned a {0} {1} to you').format(
|
||||
doctype,
|
||||
f'<span class="font-medium text-ink-gray-9">{ name }</span>'
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
if doctype == "task":
|
||||
if is_cancelled:
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
|
||||
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>',
|
||||
f'<span class="font-medium text-ink-gray-9">{ owner }</span>'
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span class="font-medium text-ink-gray-9">{ owner }</span>
|
||||
<span>{ _('assigned a new task {0} to you').format(
|
||||
f'<span class="font-medium text-ink-gray-9">{ reference_doc.title }</span>'
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
if doctype == "task":
|
||||
if is_cancelled:
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-gray-600">
|
||||
<span>{ _('Your assignment on task {0} has been removed by {1}').format(
|
||||
f'<span class="font-medium text-gray-900">{ reference_doc.title }</span>',
|
||||
f'<span class="font-medium text-gray-900">{ owner }</span>'
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
return f"""
|
||||
<div class="mb-2 leading-5 text-gray-600">
|
||||
<span class="font-medium text-gray-900">{ owner }</span>
|
||||
<span>{ _('assigned a new task {0} to you').format(
|
||||
f'<span class="font-medium text-gray-900">{ reference_doc.title }</span>'
|
||||
) }</span>
|
||||
</div>
|
||||
"""
|
||||
|
||||
def get_redirect_to_doc(doc):
|
||||
if doc.reference_type == "CRM Task":
|
||||
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||
return reference_doc.reference_doctype, reference_doc.reference_docname
|
||||
if doc.reference_type == "CRM Task":
|
||||
reference_doc = frappe.get_doc(doc.reference_type, doc.reference_name)
|
||||
return reference_doc.reference_doctype, reference_doc.reference_docname
|
||||
|
||||
return doc.reference_type, doc.reference_name
|
||||
return doc.reference_type, doc.reference_name
|
||||
|
||||
@ -30,25 +30,27 @@ def notify_agent(doc):
|
||||
if doctype.startswith("CRM "):
|
||||
doctype = doctype[4:].lower()
|
||||
notification_text = f"""
|
||||
<div class="mb-2 leading-5 text-gray-600">
|
||||
<span class="font-medium text-gray-900">{ _('You') }</span>
|
||||
<div class="mb-2 leading-5 text-ink-gray-5">
|
||||
<span class="font-medium text-ink-gray-9">{ _('You') }</span>
|
||||
<span>{ _('received a whatsapp message in {0}').format(doctype) }</span>
|
||||
<span class="font-medium text-gray-900">{ doc.reference_name }</span>
|
||||
<span class="font-medium text-ink-gray-9">{ doc.reference_name }</span>
|
||||
</div>
|
||||
"""
|
||||
assigned_users = get_assigned_users(doc.reference_doctype, doc.reference_name)
|
||||
for user in assigned_users:
|
||||
notify_user({
|
||||
"owner": doc.owner,
|
||||
"assigned_to": user,
|
||||
"notification_type": "WhatsApp",
|
||||
"message": doc.message,
|
||||
"notification_text": notification_text,
|
||||
"reference_doctype": "WhatsApp Message",
|
||||
"reference_docname": doc.name,
|
||||
"redirect_to_doctype": doc.reference_doctype,
|
||||
"redirect_to_docname": doc.reference_name,
|
||||
})
|
||||
notify_user(
|
||||
{
|
||||
"owner": doc.owner,
|
||||
"assigned_to": user,
|
||||
"notification_type": "WhatsApp",
|
||||
"message": doc.message,
|
||||
"notification_text": notification_text,
|
||||
"reference_doctype": "WhatsApp Message",
|
||||
"reference_docname": doc.name,
|
||||
"redirect_to_doctype": doc.reference_doctype,
|
||||
"redirect_to_docname": doc.reference_name,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_lead_or_deal_from_number(number):
|
||||
@ -92,6 +94,7 @@ def is_whatsapp_enabled():
|
||||
return False
|
||||
return frappe.get_cached_value("WhatsApp Settings", "WhatsApp Settings", "enabled")
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def is_whatsapp_installed():
|
||||
if not frappe.db.exists("DocType", "WhatsApp Settings"):
|
||||
@ -105,8 +108,8 @@ def get_whatsapp_messages(reference_doctype, reference_name):
|
||||
return []
|
||||
messages = []
|
||||
|
||||
if reference_doctype == 'CRM Deal':
|
||||
lead = frappe.db.get_value(reference_doctype, reference_name, 'lead')
|
||||
if reference_doctype == "CRM Deal":
|
||||
lead = frappe.db.get_value(reference_doctype, reference_name, "lead")
|
||||
if lead:
|
||||
messages = frappe.get_all(
|
||||
"WhatsApp Message",
|
||||
|
||||
@ -1,38 +1,19 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from crm.api.doc import get_fields_meta, get_assigned_users
|
||||
from crm.api.doc import get_assigned_users, get_fields_meta
|
||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_deal(name):
|
||||
Deal = frappe.qb.DocType("CRM Deal")
|
||||
deal = frappe.get_doc("CRM Deal", name).as_dict()
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Deal)
|
||||
.select("*")
|
||||
.where(Deal.name == name)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
deal = query.run(as_dict=True)
|
||||
if not len(deal):
|
||||
frappe.throw(_("Deal not found"), frappe.DoesNotExistError)
|
||||
deal = deal.pop()
|
||||
|
||||
|
||||
deal["contacts"] = frappe.get_all(
|
||||
"CRM Contacts",
|
||||
filters={"parenttype": "CRM Deal", "parent": deal.name},
|
||||
fields=["contact", "is_primary"],
|
||||
)
|
||||
|
||||
deal["doctype"] = "CRM Deal"
|
||||
deal["fields_meta"] = get_fields_meta("CRM Deal")
|
||||
deal["_form_script"] = get_form_script('CRM Deal')
|
||||
deal["fields_meta"] = get_fields_meta("CRM Deal")
|
||||
deal["_form_script"] = get_form_script("CRM Deal")
|
||||
deal["_assign"] = get_assigned_users("CRM Deal", deal.name, deal.owner)
|
||||
return deal
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_deal_contacts(name):
|
||||
contacts = frappe.get_all(
|
||||
@ -44,16 +25,19 @@ def get_deal_contacts(name):
|
||||
for contact in contacts:
|
||||
is_primary = contact.is_primary
|
||||
contact = frappe.get_doc("Contact", contact.contact).as_dict()
|
||||
|
||||
def get_primary_email(contact):
|
||||
for email in contact.email_ids:
|
||||
if email.is_primary:
|
||||
return email.email_id
|
||||
return contact.email_ids[0].email_id if contact.email_ids else ""
|
||||
|
||||
def get_primary_mobile_no(contact):
|
||||
for phone in contact.phone_nos:
|
||||
if phone.is_primary:
|
||||
return phone.phone
|
||||
return contact.phone_nos[0].phone if contact.phone_nos else ""
|
||||
|
||||
_contact = {
|
||||
"name": contact.name,
|
||||
"image": contact.image,
|
||||
@ -63,4 +47,4 @@ def get_deal_contacts(name):
|
||||
"is_primary": is_primary,
|
||||
}
|
||||
deal_contacts.append(_contact)
|
||||
return deal_contacts
|
||||
return deal_contacts
|
||||
|
||||
@ -73,7 +73,7 @@
|
||||
"fetch_from": ".annual_revenue",
|
||||
"fieldname": "annual_revenue",
|
||||
"fieldtype": "Currency",
|
||||
"label": "Amount",
|
||||
"label": "Annual Revenue",
|
||||
"options": "currency"
|
||||
},
|
||||
{
|
||||
@ -338,7 +338,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-09-17 18:34:15.873610",
|
||||
"modified": "2024-12-11 14:31:41.058895",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Deal",
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
"in_list_view": 1,
|
||||
"in_standard_filter": 1,
|
||||
"label": "Type",
|
||||
"options": "Quick Entry\nSide Panel"
|
||||
"options": "Quick Entry\nSide Panel\nData Fields"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ttpm",
|
||||
@ -46,7 +46,7 @@
|
||||
],
|
||||
"index_web_pages_for_search": 1,
|
||||
"links": [],
|
||||
"modified": "2024-06-13 15:10:01.612851",
|
||||
"modified": "2024-12-05 13:29:37.021412",
|
||||
"modified_by": "Administrator",
|
||||
"module": "FCRM",
|
||||
"name": "CRM Fields Layout",
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
@ -10,46 +11,54 @@ from frappe.model.document import Document
|
||||
class CRMFieldsLayout(Document):
|
||||
pass
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_fields_layout(doctype: str, type: str):
|
||||
sections = []
|
||||
tabs = []
|
||||
if frappe.db.exists("CRM Fields Layout", {"dt": doctype, "type": type}):
|
||||
layout = frappe.get_doc("CRM Fields Layout", {"dt": doctype, "type": type})
|
||||
else:
|
||||
return []
|
||||
|
||||
if layout.layout:
|
||||
sections = json.loads(layout.layout)
|
||||
tabs = json.loads(layout.layout)
|
||||
|
||||
has_tabs = tabs[0].get("sections") if tabs and tabs[0] else False
|
||||
|
||||
if not has_tabs:
|
||||
tabs = [{"no_tabs": True, "sections": tabs}]
|
||||
|
||||
allowed_fields = []
|
||||
for section in sections:
|
||||
if not section.get("fields"):
|
||||
continue
|
||||
allowed_fields.extend(section.get("fields"))
|
||||
for tab in tabs:
|
||||
for section in tab.get("sections"):
|
||||
if not section.get("fields"):
|
||||
continue
|
||||
allowed_fields.extend(section.get("fields"))
|
||||
|
||||
fields = frappe.get_meta(doctype).fields
|
||||
fields = [field for field in fields if field.fieldname in allowed_fields]
|
||||
|
||||
for section in sections:
|
||||
for field in section.get("fields") if section.get("fields") else []:
|
||||
field = next((f for f in fields if f.fieldname == field), None)
|
||||
if field:
|
||||
if field.fieldtype == "Select" and field.options:
|
||||
field.options = field.options.split("\n")
|
||||
field.options = [{"label": _(option), "value": option} for option in field.options]
|
||||
field.options.insert(0, {"label": "", "value": ""})
|
||||
field = {
|
||||
"label": _(field.label),
|
||||
"name": field.fieldname,
|
||||
"type": field.fieldtype,
|
||||
"options": field.options,
|
||||
"mandatory": field.reqd,
|
||||
"placeholder": field.get("placeholder"),
|
||||
"filters": field.get("link_filters")
|
||||
}
|
||||
section["fields"][section.get("fields").index(field["name"])] = field
|
||||
for tab in tabs:
|
||||
for section in tab.get("sections"):
|
||||
for field in section.get("fields") if section.get("fields") else []:
|
||||
field = next((f for f in fields if f.fieldname == field), None)
|
||||
if field:
|
||||
if field.fieldtype == "Select" and field.options:
|
||||
field.options = field.options.split("\n")
|
||||
field.options = [{"label": _(option), "value": option} for option in field.options]
|
||||
field.options.insert(0, {"label": "", "value": ""})
|
||||
field = {
|
||||
"label": _(field.label),
|
||||
"name": field.fieldname,
|
||||
"type": field.fieldtype,
|
||||
"options": field.options,
|
||||
"mandatory": field.reqd,
|
||||
"placeholder": field.get("placeholder"),
|
||||
"filters": field.get("link_filters"),
|
||||
}
|
||||
section["fields"][section.get("fields").index(field["name"])] = field
|
||||
|
||||
return sections or []
|
||||
return tabs or []
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
@ -59,11 +68,13 @@ def save_fields_layout(doctype: str, type: str, layout: str):
|
||||
else:
|
||||
doc = frappe.new_doc("CRM Fields Layout")
|
||||
|
||||
doc.update({
|
||||
"dt": doctype,
|
||||
"type": type,
|
||||
"layout": layout,
|
||||
})
|
||||
doc.update(
|
||||
{
|
||||
"dt": doctype,
|
||||
"type": type,
|
||||
"layout": layout,
|
||||
}
|
||||
)
|
||||
doc.save(ignore_permissions=True)
|
||||
|
||||
return doc.layout
|
||||
|
||||
@ -1,22 +1,14 @@
|
||||
import frappe
|
||||
from frappe import _
|
||||
|
||||
from crm.api.doc import get_fields_meta, get_assigned_users
|
||||
from crm.api.doc import get_assigned_users, get_fields_meta
|
||||
from crm.fcrm.doctype.crm_form_script.crm_form_script import get_form_script
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def get_lead(name):
|
||||
Lead = frappe.qb.DocType("CRM Lead")
|
||||
lead = frappe.get_doc("CRM Lead", name).as_dict()
|
||||
|
||||
query = frappe.qb.from_(Lead).select("*").where(Lead.name == name).limit(1)
|
||||
|
||||
lead = query.run(as_dict=True)
|
||||
if not len(lead):
|
||||
frappe.throw(_("Lead not found"), frappe.DoesNotExistError)
|
||||
lead = lead.pop()
|
||||
|
||||
lead["doctype"] = "CRM Lead"
|
||||
lead["fields_meta"] = get_fields_meta("CRM Lead")
|
||||
lead["_form_script"] = get_form_script('CRM Lead')
|
||||
lead["_form_script"] = get_form_script("CRM Lead")
|
||||
lead["_assign"] = get_assigned_users("CRM Lead", lead.name, lead.owner)
|
||||
return lead
|
||||
|
||||
119
crm/install.py
@ -1,14 +1,14 @@
|
||||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See license.txt
|
||||
from __future__ import unicode_literals
|
||||
import click
|
||||
import frappe
|
||||
|
||||
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
|
||||
|
||||
|
||||
def before_install():
|
||||
pass
|
||||
|
||||
|
||||
def after_install(force=False):
|
||||
add_default_lead_statuses()
|
||||
add_default_deal_statuses()
|
||||
@ -20,6 +20,7 @@ def after_install(force=False):
|
||||
add_default_lead_sources()
|
||||
frappe.db.commit()
|
||||
|
||||
|
||||
def add_default_lead_statuses():
|
||||
statuses = {
|
||||
"New": {
|
||||
@ -58,6 +59,7 @@ def add_default_lead_statuses():
|
||||
doc.position = statuses[status]["position"]
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_default_deal_statuses():
|
||||
statuses = {
|
||||
"Qualification": {
|
||||
@ -100,6 +102,7 @@ def add_default_deal_statuses():
|
||||
doc.position = statuses[status]["position"]
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_default_communication_statuses():
|
||||
statuses = ["Open", "Replied"]
|
||||
|
||||
@ -111,46 +114,58 @@ def add_default_communication_statuses():
|
||||
doc.status = status
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_default_fields_layout(force=False):
|
||||
quick_entry_layouts = {
|
||||
"CRM Lead-Quick Entry": {
|
||||
"doctype": "CRM Lead",
|
||||
"layout": '[{"label":"Person","fields":["salutation","first_name","last_name","email","mobile_no", "gender"],"hideLabel":true},{"label":"Organization","fields":["organization","website","no_of_employees","territory","annual_revenue","industry"],"hideLabel":true,"hideBorder":false},{"label":"Other","columns":2,"fields":["status","lead_owner"],"hideLabel":true,"hideBorder":false}]'
|
||||
"layout": '[{"label":"Person","fields":["salutation","first_name","last_name","email","mobile_no", "gender"],"hideLabel":true},{"label":"Organization","fields":["organization","website","no_of_employees","territory","annual_revenue","industry"],"hideLabel":true,"hideBorder":false},{"label":"Other","columns":2,"fields":["status","lead_owner"],"hideLabel":true,"hideBorder":false}]',
|
||||
},
|
||||
"CRM Deal-Quick Entry": {
|
||||
"doctype": "CRM Deal",
|
||||
"layout": '[{"label": "Select Organization", "fields": ["organization"], "hideLabel": true, "editable": true}, {"label": "Organization Details", "fields": ["organization_name", "website", "no_of_employees", "territory", "annual_revenue", "industry"], "hideLabel": true, "editable": true}, {"label": "Select Contact", "fields": ["contact"], "hideLabel": true, "editable": true}, {"label": "Contact Details", "fields": ["salutation", "first_name", "last_name", "email", "mobile_no", "gender"], "hideLabel": true, "editable": true}, {"label": "Other", "columns": 2, "fields": ["status", "deal_owner"], "hideLabel": true}]'
|
||||
"layout": '[{"label": "Select Organization", "fields": ["organization"], "hideLabel": true, "editable": true}, {"label": "Organization Details", "fields": ["organization_name", "website", "no_of_employees", "territory", "annual_revenue", "industry"], "hideLabel": true, "editable": true}, {"label": "Select Contact", "fields": ["contact"], "hideLabel": true, "editable": true}, {"label": "Contact Details", "fields": ["salutation", "first_name", "last_name", "email", "mobile_no", "gender"], "hideLabel": true, "editable": true}, {"label": "Other", "columns": 2, "fields": ["status", "deal_owner"], "hideLabel": true}]',
|
||||
},
|
||||
"Contact-Quick Entry": {
|
||||
"doctype": "Contact",
|
||||
"layout": '[{"label":"Salutation","columns":1,"fields":["salutation"],"hideLabel":true},{"label":"Full Name","columns":2,"hideBorder":true,"fields":["first_name","last_name"],"hideLabel":true},{"label":"Email","columns":1,"hideBorder":true,"fields":["email_id"],"hideLabel":true},{"label":"Mobile No. & Gender","columns":2,"hideBorder":true,"fields":["mobile_no","gender"],"hideLabel":true},{"label":"Organization","columns":1,"hideBorder":true,"fields":["company_name"],"hideLabel":true},{"label":"Designation","columns":1,"hideBorder":true,"fields":["designation"],"hideLabel":true},{"label":"Address","columns":1,"hideBorder":true,"fields":["address"],"hideLabel":true}]'
|
||||
"layout": '[{"label":"Salutation","columns":1,"fields":["salutation"],"hideLabel":true},{"label":"Full Name","columns":2,"hideBorder":true,"fields":["first_name","last_name"],"hideLabel":true},{"label":"Email","columns":1,"hideBorder":true,"fields":["email_id"],"hideLabel":true},{"label":"Mobile No. & Gender","columns":2,"hideBorder":true,"fields":["mobile_no","gender"],"hideLabel":true},{"label":"Organization","columns":1,"hideBorder":true,"fields":["company_name"],"hideLabel":true},{"label":"Designation","columns":1,"hideBorder":true,"fields":["designation"],"hideLabel":true},{"label":"Address","columns":1,"hideBorder":true,"fields":["address"],"hideLabel":true}]',
|
||||
},
|
||||
"CRM Organization-Quick Entry": {
|
||||
"doctype": "CRM Organization",
|
||||
"layout": '[{"label":"Organization Name","columns":1,"fields":["organization_name"],"hideLabel":true},{"label":"Website & Revenue","columns":2,"hideBorder":true,"fields":["website","annual_revenue"],"hideLabel":true},{"label":"Territory","columns":1,"hideBorder":true,"fields":["territory"],"hideLabel":true},{"label":"No of Employees & Industry","columns":2,"hideBorder":true,"fields":["no_of_employees","industry"],"hideLabel":true},{"label":"Address","columns":1,"hideBorder":true,"fields":["address"],"hideLabel":true}]'
|
||||
"layout": '[{"label":"Organization Name","columns":1,"fields":["organization_name"],"hideLabel":true},{"label":"Website & Revenue","columns":2,"hideBorder":true,"fields":["website","annual_revenue"],"hideLabel":true},{"label":"Territory","columns":1,"hideBorder":true,"fields":["territory"],"hideLabel":true},{"label":"No of Employees & Industry","columns":2,"hideBorder":true,"fields":["no_of_employees","industry"],"hideLabel":true},{"label":"Address","columns":1,"hideBorder":true,"fields":["address"],"hideLabel":true}]',
|
||||
},
|
||||
"Address-Quick Entry": {
|
||||
"doctype": "Address",
|
||||
"layout": '[{"label":"Address","columns":1,"fields":["address_title","address_type","address_line1","address_line2","city","state","country","pincode"],"hideLabel":true}]'
|
||||
"layout": '[{"label":"Address","columns":1,"fields":["address_title","address_type","address_line1","address_line2","city","state","country","pincode"],"hideLabel":true}]',
|
||||
},
|
||||
}
|
||||
|
||||
sidebar_fields_layouts = {
|
||||
"CRM Lead-Side Panel": {
|
||||
"doctype": "CRM Lead",
|
||||
"layout": '[{"label": "Details", "name": "details", "opened": true, "fields": ["organization", "website", "territory", "industry", "job_title", "source", "lead_owner"]}, {"label": "Person", "name": "person_tab", "opened": true, "fields": ["salutation", "first_name", "last_name", "email", "mobile_no"]}]'
|
||||
"layout": '[{"label": "Details", "name": "details", "opened": true, "fields": ["organization", "website", "territory", "industry", "job_title", "source", "lead_owner"]}, {"label": "Person", "name": "person_tab", "opened": true, "fields": ["salutation", "first_name", "last_name", "email", "mobile_no"]}]',
|
||||
},
|
||||
"CRM Deal-Side Panel": {
|
||||
"doctype": "CRM Deal",
|
||||
"layout": '[{"label":"Contacts","name":"contacts_section","opened":true,"editable":false,"contacts":[]},{"label":"Organization Details","name":"organization_tab","opened":true,"fields":["organization","website","territory","annual_revenue","close_date","probability","next_step","deal_owner"]}]'
|
||||
"layout": '[{"label":"Contacts","name":"contacts_section","opened":true,"editable":false,"contacts":[]},{"label":"Organization Details","name":"organization_tab","opened":true,"fields":["organization","website","territory","annual_revenue","close_date","probability","next_step","deal_owner"]}]',
|
||||
},
|
||||
"Contact-Side Panel": {
|
||||
"doctype": "Contact",
|
||||
"layout": '[{"label":"Details","name":"details","opened":true,"fields":["salutation","first_name","last_name","email_id","mobile_no","gender","company_name","designation","address"]}]'
|
||||
"layout": '[{"label":"Details","name":"details","opened":true,"fields":["salutation","first_name","last_name","email_id","mobile_no","gender","company_name","designation","address"]}]',
|
||||
},
|
||||
"CRM Organization-Side Panel": {
|
||||
"doctype": "CRM Organization",
|
||||
"layout": '[{"label":"Details","name":"details","opened":true,"fields":["organization_name","website","territory","industry","no_of_employees","address"]}]'
|
||||
"layout": '[{"label":"Details","name":"details","opened":true,"fields":["organization_name","website","territory","industry","no_of_employees","address"]}]',
|
||||
},
|
||||
}
|
||||
|
||||
data_fields_layouts = {
|
||||
"CRM Lead-Data Fields": {
|
||||
"doctype": "CRM Lead",
|
||||
"layout": '[{"no_tabs":true,"sections":[{"label": "Details", "name": "details", "opened": true, "fields": ["organization", "website", "territory", "industry", "job_title", "source", "lead_owner"]}, {"label": "Person", "name": "person_tab", "opened": true, "fields": ["salutation", "first_name", "last_name", "email", "mobile_no"]}]}]',
|
||||
},
|
||||
"CRM Deal-Data Fields": {
|
||||
"doctype": "CRM Deal",
|
||||
"layout": '[{"no_tabs":true,"sections":[{"label":"Organization Details","name":"organization_tab","opened":true,"fields":["organization","website","territory","annual_revenue","close_date","probability","next_step","deal_owner"]}]}]',
|
||||
},
|
||||
}
|
||||
|
||||
@ -180,6 +195,20 @@ def add_default_fields_layout(force=False):
|
||||
doc.layout = sidebar_fields_layouts[layout]["layout"]
|
||||
doc.insert()
|
||||
|
||||
for layout in data_fields_layouts:
|
||||
if frappe.db.exists("CRM Fields Layout", layout):
|
||||
if force:
|
||||
frappe.delete_doc("CRM Fields Layout", layout)
|
||||
else:
|
||||
continue
|
||||
|
||||
doc = frappe.new_doc("CRM Fields Layout")
|
||||
doc.type = "Data Fields"
|
||||
doc.dt = data_fields_layouts[layout]["doctype"]
|
||||
doc.layout = data_fields_layouts[layout]["layout"]
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_property_setter():
|
||||
if not frappe.db.exists("Property Setter", {"name": "Contact-main-search_fields"}):
|
||||
doc = frappe.new_doc("Property Setter")
|
||||
@ -190,6 +219,7 @@ def add_property_setter():
|
||||
doc.value = "email_id"
|
||||
doc.insert()
|
||||
|
||||
|
||||
def add_email_template_custom_fields():
|
||||
if not frappe.get_meta("Email Template").has_field("enabled"):
|
||||
click.secho("* Installing Custom Fields in Email Template")
|
||||
@ -219,7 +249,59 @@ def add_email_template_custom_fields():
|
||||
|
||||
|
||||
def add_default_industries():
|
||||
industries = ["Accounting", "Advertising", "Aerospace", "Agriculture", "Airline", "Apparel & Accessories", "Automotive", "Banking", "Biotechnology", "Broadcasting", "Brokerage", "Chemical", "Computer", "Consulting", "Consumer Products", "Cosmetics", "Defense", "Department Stores", "Education", "Electronics", "Energy", "Entertainment & Leisure, Executive Search", "Financial Services", "Food", "Beverage & Tobacco", "Grocery", "Health Care", "Internet Publishing", "Investment Banking", "Legal", "Manufacturing", "Motion Picture & Video", "Music", "Newspaper Publishers", "Online Auctions", "Pension Funds", "Pharmaceuticals", "Private Equity", "Publishing", "Real Estate", "Retail & Wholesale", "Securities & Commodity Exchanges", "Service", "Soap & Detergent", "Software", "Sports", "Technology", "Telecommunications", "Television", "Transportation", "Venture Capital"]
|
||||
industries = [
|
||||
"Accounting",
|
||||
"Advertising",
|
||||
"Aerospace",
|
||||
"Agriculture",
|
||||
"Airline",
|
||||
"Apparel & Accessories",
|
||||
"Automotive",
|
||||
"Banking",
|
||||
"Biotechnology",
|
||||
"Broadcasting",
|
||||
"Brokerage",
|
||||
"Chemical",
|
||||
"Computer",
|
||||
"Consulting",
|
||||
"Consumer Products",
|
||||
"Cosmetics",
|
||||
"Defense",
|
||||
"Department Stores",
|
||||
"Education",
|
||||
"Electronics",
|
||||
"Energy",
|
||||
"Entertainment & Leisure, Executive Search",
|
||||
"Financial Services",
|
||||
"Food",
|
||||
"Beverage & Tobacco",
|
||||
"Grocery",
|
||||
"Health Care",
|
||||
"Internet Publishing",
|
||||
"Investment Banking",
|
||||
"Legal",
|
||||
"Manufacturing",
|
||||
"Motion Picture & Video",
|
||||
"Music",
|
||||
"Newspaper Publishers",
|
||||
"Online Auctions",
|
||||
"Pension Funds",
|
||||
"Pharmaceuticals",
|
||||
"Private Equity",
|
||||
"Publishing",
|
||||
"Real Estate",
|
||||
"Retail & Wholesale",
|
||||
"Securities & Commodity Exchanges",
|
||||
"Service",
|
||||
"Soap & Detergent",
|
||||
"Software",
|
||||
"Sports",
|
||||
"Technology",
|
||||
"Telecommunications",
|
||||
"Television",
|
||||
"Transportation",
|
||||
"Venture Capital",
|
||||
]
|
||||
|
||||
for industry in industries:
|
||||
if frappe.db.exists("CRM Industry", industry):
|
||||
@ -231,7 +313,18 @@ def add_default_industries():
|
||||
|
||||
|
||||
def add_default_lead_sources():
|
||||
lead_sources = ["Existing Customer", "Reference", "Advertisement", "Cold Calling", "Exhibition", "Supplier Reference", "Mass Mailing", "Customer's Vendor", "Campaign", "Walk In"]
|
||||
lead_sources = [
|
||||
"Existing Customer",
|
||||
"Reference",
|
||||
"Advertisement",
|
||||
"Cold Calling",
|
||||
"Exhibition",
|
||||
"Supplier Reference",
|
||||
"Mass Mailing",
|
||||
"Customer's Vendor",
|
||||
"Campaign",
|
||||
"Walk In",
|
||||
]
|
||||
|
||||
for source in lead_sources:
|
||||
if frappe.db.exists("CRM Lead Source", source):
|
||||
|
||||
@ -130,14 +130,14 @@ def update_call_status_info(**kwargs):
|
||||
|
||||
def get_datetime_from_timestamp(timestamp):
|
||||
from datetime import datetime
|
||||
from pytz import timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
if not timestamp: return None
|
||||
|
||||
datetime_utc_tz_str = timestamp.strftime('%Y-%m-%d %H:%M:%S%z')
|
||||
datetime_utc_tz = datetime.strptime(datetime_utc_tz_str, '%Y-%m-%d %H:%M:%S%z')
|
||||
system_timezone = frappe.utils.get_system_timezone()
|
||||
converted_datetime = datetime_utc_tz.astimezone(timezone(system_timezone))
|
||||
converted_datetime = datetime_utc_tz.astimezone(ZoneInfo(system_timezone))
|
||||
return frappe.utils.format_datetime(converted_datetime, 'yyyy-MM-dd HH:mm:ss')
|
||||
|
||||
@frappe.whitelist()
|
||||
@ -179,4 +179,4 @@ def get_lead_or_deal_from_number(call):
|
||||
if not doc:
|
||||
doc = find_record(doctype, number)
|
||||
|
||||
return doc, doctype
|
||||
return doc, doctype
|
||||
|
||||
@ -6,6 +6,6 @@ crm.patches.v1_0.move_crm_note_data_to_fcrm_note
|
||||
[post_model_sync]
|
||||
# Patches added in this section will be executed after doctypes are migrated
|
||||
crm.patches.v1_0.create_email_template_custom_fields
|
||||
crm.patches.v1_0.create_default_fields_layout #31/10/2024
|
||||
crm.patches.v1_0.create_default_fields_layout #10/12/2024
|
||||
crm.patches.v1_0.create_default_sidebar_fields_layout
|
||||
crm.patches.v1_0.update_deal_quick_entry_layout
|
||||
@ -3,7 +3,14 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from frappe.utils import cint, get_system_timezone
|
||||
from frappe.utils.telemetry import capture
|
||||
from frappe.locale import (
|
||||
get_date_format,
|
||||
get_first_day_of_the_week,
|
||||
get_number_format,
|
||||
get_time_format,
|
||||
)
|
||||
|
||||
no_cache = 1
|
||||
|
||||
@ -32,6 +39,20 @@ def get_boot():
|
||||
"site_name": frappe.local.site,
|
||||
"read_only_mode": frappe.flags.read_only,
|
||||
"csrf_token": frappe.sessions.get_csrf_token(),
|
||||
"setup_complete": cint(frappe.get_system_settings("setup_complete")),
|
||||
"sysdefaults": {
|
||||
"float_precision": cint(frappe.get_system_settings("float_precision"))
|
||||
or 3,
|
||||
"date_format": get_date_format(),
|
||||
"time_format": get_time_format(),
|
||||
"first_day_of_the_week": get_first_day_of_the_week(),
|
||||
"number_format": get_number_format().string,
|
||||
},
|
||||
"timezone": {
|
||||
"system": get_system_timezone(),
|
||||
"user": frappe.db.get_value("User", frappe.session.user, "time_zone")
|
||||
or get_system_timezone(),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit b2dbd41936905aa46b18d3c22e5d09a7b08a9b98
|
||||
Subproject commit 5a4f3c8d4f12efba37b9a83a51a59b53fa758be0
|
||||
@ -14,7 +14,7 @@
|
||||
"@vueuse/core": "^10.3.0",
|
||||
"@vueuse/integrations": "^10.3.0",
|
||||
"feather-icons": "^4.28.0",
|
||||
"frappe-ui": "^0.1.71",
|
||||
"frappe-ui": "^0.1.91",
|
||||
"gemoji": "^8.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
@ -22,7 +22,6 @@
|
||||
"socket.io-client": "^4.7.2",
|
||||
"sortablejs": "^1.15.0",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"vite": "^4.4.9",
|
||||
"vue": "^3.4.12",
|
||||
"vue-router": "^4.2.2",
|
||||
"vuedraggable": "^4.1.0"
|
||||
|
||||
@ -9,14 +9,14 @@
|
||||
<script setup>
|
||||
import { Dialogs } from '@/utils/dialogs'
|
||||
import { sessionStore as session } from '@/stores/session'
|
||||
import { Toasts } from 'frappe-ui'
|
||||
import { Toasts, setConfig } from 'frappe-ui'
|
||||
import { computed, defineAsyncComponent } from 'vue'
|
||||
|
||||
const MobileLayout = defineAsyncComponent(() =>
|
||||
import('./components/Layouts/MobileLayout.vue')
|
||||
const MobileLayout = defineAsyncComponent(
|
||||
() => import('./components/Layouts/MobileLayout.vue'),
|
||||
)
|
||||
const DesktopLayout = defineAsyncComponent(() =>
|
||||
import('./components/Layouts/DesktopLayout.vue')
|
||||
const DesktopLayout = defineAsyncComponent(
|
||||
() => import('./components/Layouts/DesktopLayout.vue'),
|
||||
)
|
||||
const Layout = computed(() => {
|
||||
if (window.innerWidth < 640) {
|
||||
@ -25,4 +25,7 @@ const Layout = computed(() => {
|
||||
return DesktopLayout
|
||||
}
|
||||
})
|
||||
|
||||
setConfig('systemTimezone', window.timezone?.system || null)
|
||||
setConfig('localTimezone', window.timezone?.user || null)
|
||||
</script>
|
||||
|
||||
@ -1,152 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Light.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Black.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
@ -16,7 +16,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="all_activities?.loading"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-4"
|
||||
>
|
||||
<LoadingIndicator class="h-6 w-6" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
@ -50,13 +50,13 @@
|
||||
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-2 px-3 sm:gap-4 sm:px-10"
|
||||
>
|
||||
<div
|
||||
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-gray-200"
|
||||
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-outline-gray-modals"
|
||||
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
|
||||
>
|
||||
<div
|
||||
class="z-10 flex h-8 w-7 items-center justify-center bg-white"
|
||||
class="z-10 flex h-8 w-7 items-center justify-center bg-surface-white"
|
||||
>
|
||||
<CommentIcon class="text-gray-800" />
|
||||
<CommentIcon class="text-ink-gray-8" />
|
||||
</div>
|
||||
</div>
|
||||
<CommentArea class="mb-4" :activity="comment" />
|
||||
@ -72,15 +72,15 @@
|
||||
class="activity grid grid-cols-[30px_minmax(auto,_1fr)] gap-4 px-3 sm:px-10"
|
||||
>
|
||||
<div
|
||||
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-gray-200"
|
||||
class="relative flex justify-center after:absolute after:left-[50%] after:top-0 after:-z-10 after:border-l after:border-outline-gray-modals"
|
||||
:class="i != activities.length - 1 ? 'after:h-full' : 'after:h-4'"
|
||||
>
|
||||
<div
|
||||
class="z-10 flex h-8 w-7 items-center justify-center bg-white text-gray-800"
|
||||
class="z-10 flex h-8 w-7 items-center justify-center bg-surface-white text-ink-gray-8"
|
||||
>
|
||||
<MissedCallIcon
|
||||
v-if="call.status == 'No Answer'"
|
||||
class="text-red-600"
|
||||
class="text-ink-red-4"
|
||||
/>
|
||||
<DeclinedCallIcon v-else-if="call.status == 'Busy'" />
|
||||
<component
|
||||
@ -116,14 +116,14 @@
|
||||
>
|
||||
<div
|
||||
v-if="['Activity', 'Emails'].includes(title)"
|
||||
class="relative flex justify-center before:absolute before:left-[50%] before:top-0 before:-z-10 before:border-l before:border-gray-200"
|
||||
class="relative flex justify-center before:absolute before:left-[50%] before:top-0 before:-z-10 before:border-l before:border-outline-gray-modals"
|
||||
:class="[i != activities.length - 1 ? 'before:h-full' : 'before:h-4']"
|
||||
>
|
||||
<div
|
||||
class="z-10 flex h-7 w-7 items-center justify-center bg-white"
|
||||
class="z-10 flex h-7 w-7 items-center justify-center bg-surface-white"
|
||||
:class="{
|
||||
'mt-2.5': ['communication'].includes(activity.activity_type),
|
||||
'bg-white': ['added', 'removed', 'changed'].includes(
|
||||
'bg-surface-white': ['added', 'removed', 'changed'].includes(
|
||||
activity.activity_type,
|
||||
),
|
||||
'h-8': [
|
||||
@ -145,7 +145,7 @@
|
||||
activity.activity_type,
|
||||
) && activity.status == 'No Answer'
|
||||
"
|
||||
class="text-red-600"
|
||||
class="text-ink-red-4"
|
||||
/>
|
||||
<DeclinedCallIcon
|
||||
v-else-if="
|
||||
@ -159,8 +159,8 @@
|
||||
:is="activity.icon"
|
||||
:class="
|
||||
['added', 'removed', 'changed'].includes(activity.activity_type)
|
||||
? 'text-gray-500'
|
||||
: 'text-gray-800'
|
||||
? 'text-ink-gray-4'
|
||||
: 'text-ink-gray-8'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@ -185,10 +185,10 @@
|
||||
>
|
||||
<div class="flex items-center justify-stretch gap-2 text-base">
|
||||
<div
|
||||
class="inline-flex items-center flex-wrap gap-1.5 text-gray-800 font-medium"
|
||||
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-gray-600">{{ __(activity.data.type) }}</span>
|
||||
<span class="text-ink-gray-5">{{ __(activity.data.type) }}</span>
|
||||
<a
|
||||
v-if="activity.data.file_url"
|
||||
:href="activity.data.file_url"
|
||||
@ -204,8 +204,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="ml-auto whitespace-nowrap">
|
||||
<Tooltip :text="dateFormat(activity.creation, dateTooltipFormat)">
|
||||
<div class="text-sm text-gray-600">
|
||||
<Tooltip :text="formatDate(activity.creation)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -225,7 +225,7 @@
|
||||
<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-gray-800 font-medium"
|
||||
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>
|
||||
@ -243,22 +243,22 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="inline-flex items-center flex-wrap gap-1 text-gray-600"
|
||||
class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5"
|
||||
>
|
||||
<span class="font-medium text-gray-800">
|
||||
<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-gray-800"
|
||||
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-gray-800"
|
||||
class="max-w-xs font-medium text-ink-gray-8"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
@ -274,7 +274,7 @@
|
||||
<span v-if="activity.to">{{ __('to') }}</span>
|
||||
<span
|
||||
v-if="activity.data.value"
|
||||
class="max-w-xs font-medium text-gray-800"
|
||||
class="max-w-xs font-medium text-ink-gray-8"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
@ -290,8 +290,8 @@
|
||||
</div>
|
||||
|
||||
<div class="ml-auto whitespace-nowrap">
|
||||
<Tooltip :text="dateFormat(activity.creation, dateTooltipFormat)">
|
||||
<div class="text-sm text-gray-600">
|
||||
<Tooltip :text="formatDate(activity.creation)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -305,23 +305,23 @@
|
||||
v-for="activity in [activity, ...activity.other_versions]"
|
||||
class="flex items-start justify-stretch gap-2 py-1.5 text-base"
|
||||
>
|
||||
<div class="inline-flex flex-wrap gap-1 text-gray-600">
|
||||
<div class="inline-flex flex-wrap gap-1 text-ink-gray-5">
|
||||
<span
|
||||
v-if="activity.data.field_label"
|
||||
class="max-w-xs truncate text-gray-600"
|
||||
class="max-w-xs truncate text-ink-gray-5"
|
||||
>
|
||||
{{ __(activity.data.field_label) }}
|
||||
</span>
|
||||
<FeatherIcon
|
||||
name="arrow-right"
|
||||
class="mx-1 h-4 w-4 text-gray-600"
|
||||
class="mx-1 h-4 w-4 text-ink-gray-5"
|
||||
/>
|
||||
<span v-if="activity.type">
|
||||
{{ startCase(__(activity.type)) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="activity.data.old_value"
|
||||
class="max-w-xs font-medium text-gray-800"
|
||||
class="max-w-xs font-medium text-ink-gray-8"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
@ -337,7 +337,7 @@
|
||||
<span v-if="activity.to">{{ __('to') }}</span>
|
||||
<span
|
||||
v-if="activity.data.value"
|
||||
class="max-w-xs font-medium text-gray-800"
|
||||
class="max-w-xs font-medium text-ink-gray-8"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-1"
|
||||
@ -353,10 +353,8 @@
|
||||
</div>
|
||||
|
||||
<div class="ml-auto whitespace-nowrap">
|
||||
<Tooltip
|
||||
:text="dateFormat(activity.creation, dateTooltipFormat)"
|
||||
>
|
||||
<div class="text-sm text-gray-600">
|
||||
<Tooltip :text="formatDate(activity.creation)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -366,9 +364,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="title == 'Data'" class="h-full flex flex-col px-3 sm:px-10">
|
||||
<DataFields :doctype="doctype" :docname="doc.data.name" />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-ink-gray-4"
|
||||
>
|
||||
<component :is="emptyTextIcon" class="h-10 w-10" />
|
||||
<span>{{ __(emptyText) }}</span>
|
||||
@ -456,9 +457,11 @@ 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 Email2Icon from '@/components/Icons/Email2Icon.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'
|
||||
@ -481,13 +484,7 @@ 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 {
|
||||
timeAgo,
|
||||
dateFormat,
|
||||
dateTooltipFormat,
|
||||
secondsToDuration,
|
||||
startCase,
|
||||
} from '@/utils'
|
||||
import { timeAgo, formatDate, secondsToDuration, startCase } from '@/utils'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { contactsStore } from '@/stores/contacts'
|
||||
@ -727,6 +724,8 @@ const emptyText = computed(() => {
|
||||
text = 'No Email Communications'
|
||||
} else if (title.value == 'Comments') {
|
||||
text = 'No Comments'
|
||||
} else if (title.value == 'Data') {
|
||||
text = 'No Data'
|
||||
} else if (title.value == 'Calls') {
|
||||
text = 'No Call Logs'
|
||||
} else if (title.value == 'Notes') {
|
||||
@ -747,6 +746,8 @@ const emptyTextIcon = computed(() => {
|
||||
icon = Email2Icon
|
||||
} 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') {
|
||||
@ -758,7 +759,7 @@ const emptyTextIcon = computed(() => {
|
||||
} else if (title.value == 'WhatsApp') {
|
||||
icon = WhatsAppIcon
|
||||
}
|
||||
return h(icon, { class: 'text-gray-500' })
|
||||
return h(icon, { class: 'text-ink-gray-4' })
|
||||
})
|
||||
|
||||
function timelineIcon(activity_type, is_lead) {
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="title !== 'Data'"
|
||||
class="mx-4 my-3 flex items-center justify-between text-lg font-medium sm:mx-10 sm:mb-4 sm:mt-8"
|
||||
>
|
||||
<div class="flex h-8 items-center text-xl font-semibold text-gray-800">
|
||||
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
|
||||
{{ __(title) }}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
<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-gray-50 rounded text-base p-2.5 cursor-pointer"
|
||||
class="activity flex justify-between gap-2 hover:bg-surface-menu-bar rounded text-base p-2.5 cursor-pointer"
|
||||
@click="openFile(attachment)"
|
||||
>
|
||||
<div class="flex gap-2 truncate">
|
||||
<div
|
||||
class="size-11 bg-white rounded overflow-hidden flex-shrink-0 flex justify-center items-center"
|
||||
class="size-11 bg-surface-white rounded overflow-hidden flex-shrink-0 flex justify-center items-center"
|
||||
:class="{ border: !isImage(attachment.file_type) }"
|
||||
>
|
||||
<img
|
||||
@ -18,22 +18,22 @@
|
||||
/>
|
||||
<component
|
||||
v-else
|
||||
class="size-4"
|
||||
class="size-4 text-ink-gray-7"
|
||||
:is="fileIcon(attachment.file_type)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col justify-center gap-1 truncate">
|
||||
<div class="text-base text-gray-800 truncate">
|
||||
<div class="text-base text-ink-gray-8 truncate">
|
||||
{{ attachment.file_name }}
|
||||
</div>
|
||||
<div class="mb-1 text-sm text-gray-600">
|
||||
<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">
|
||||
<Tooltip :text="dateFormat(attachment.creation, dateTooltipFormat)">
|
||||
<div class="text-sm text-gray-600">
|
||||
<Tooltip :text="formatDate(attachment.creation)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(attachment.creation)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -51,7 +51,7 @@
|
||||
>
|
||||
<FeatherIcon
|
||||
:name="attachment.is_private ? 'lock' : 'unlock'"
|
||||
class="size-3 text-gray-700"
|
||||
class="size-3 text-ink-gray-7"
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@ -60,7 +60,7 @@
|
||||
class="!size-5"
|
||||
@click.stop="() => deleteAttachment(attachment.name)"
|
||||
>
|
||||
<FeatherIcon name="trash-2" class="size-3 text-gray-700" />
|
||||
<FeatherIcon name="trash-2" class="size-3 text-ink-gray-7" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
@ -68,7 +68,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="i < attachments.length - 1"
|
||||
class="mx-2 h-px border-t border-gray-200"
|
||||
class="mx-2 h-px border-t border-outline-gray-modals"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -79,13 +79,7 @@ import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
|
||||
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { call, Tooltip } from 'frappe-ui'
|
||||
import {
|
||||
dateFormat,
|
||||
timeAgo,
|
||||
dateTooltipFormat,
|
||||
convertSize,
|
||||
isImage,
|
||||
} from '@/utils'
|
||||
import { formatDate, timeAgo, convertSize, isImage } from '@/utils'
|
||||
|
||||
const props = defineProps({
|
||||
attachments: Array,
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div class="w-full text-sm text-gray-600">
|
||||
<div class="w-full text-sm text-ink-gray-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="ghost" @click="playPause">
|
||||
<template #icon>
|
||||
<PlayIcon v-if="isPaused" class="size-4 text-gray-600" />
|
||||
<PauseIcon v-else class="size-4 text-gray-600" />
|
||||
<PlayIcon v-if="isPaused" class="size-4 text-ink-gray-5" />
|
||||
<PauseIcon v-else class="size-4 text-ink-gray-5" />
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex gap-2 items-center justify-between flex-1">
|
||||
<input
|
||||
class="w-full slider !h-[0.5] bg-gray-200 [&::-webkit-slider-thumb]:shadow [&::-webkit-slider-thumb:hover]:outline [&::-webkit-slider-thumb:hover]:outline-[0.5px]"
|
||||
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, #171717 ${progress}%, #ededed ${progress}%)`,
|
||||
background: `linear-gradient(to right, var(--surface-gray-7, #171717) ${progress}%, var(--surface-gray-3, #ededed) ${progress}%)`,
|
||||
}"
|
||||
type="range"
|
||||
id="track"
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
<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-gray-600">
|
||||
<div class="inline-flex items-center flex-wrap gap-1 text-ink-gray-5">
|
||||
<Avatar
|
||||
:image="activity.caller.image"
|
||||
:label="activity.caller.label"
|
||||
size="md"
|
||||
/>
|
||||
<span class="font-medium text-gray-800 ml-1">
|
||||
<span class="font-medium text-ink-gray-8 ml-1">
|
||||
{{ activity.caller.label }}
|
||||
</span>
|
||||
<span>{{
|
||||
@ -17,15 +17,15 @@
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="ml-auto whitespace-nowrap">
|
||||
<Tooltip :text="dateFormat(activity.creation, dateTooltipFormat)">
|
||||
<div class="text-sm text-gray-600">
|
||||
<Tooltip :text="formatDate(activity.creation)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-col gap-2 border border-gray-200 rounded-md bg-white px-3 py-2.5"
|
||||
class="flex flex-col gap-2 border border-outline-gray-modals rounded-md bg-surface-cards px-3 py-2.5 text-ink-gray-9"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="inline-flex gap-2 items-center text-base font-medium">
|
||||
@ -56,7 +56,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<Badge :label="dateFormat(activity.creation, 'MMM D, dddd')">
|
||||
<Badge :label="formatDate(activity.creation, 'MMM D, dddd')">
|
||||
<template #prefix>
|
||||
<CalendarIcon class="size-3" />
|
||||
</template>
|
||||
@ -97,7 +97,7 @@ import DurationIcon from '@/components/Icons/DurationIcon.vue'
|
||||
import MultipleAvatar from '@/components/MultipleAvatar.vue'
|
||||
import AudioPlayer from '@/components/Activities/AudioPlayer.vue'
|
||||
import { statusLabelMap, statusColorMap } from '@/utils/callLog.js'
|
||||
import { dateFormat, timeAgo, dateTooltipFormat } from '@/utils'
|
||||
import { formatDate, timeAgo } from '@/utils'
|
||||
import { Avatar, Badge, Tooltip } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
<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-gray-600">
|
||||
<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-gray-800">
|
||||
<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-gray-800">
|
||||
<span class="max-w-xs truncate font-medium text-ink-gray-8">
|
||||
{{ __('comment') }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ml-auto whitespace-nowrap">
|
||||
<Tooltip :text="dateFormat(activity.creation, dateTooltipFormat)">
|
||||
<div class="text-sm text-gray-600">
|
||||
<Tooltip :text="formatDate(activity.creation)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer rounded bg-gray-50 px-3 py-[7.5px] text-base leading-6 transition-all duration-300 ease-in-out"
|
||||
class="cursor-pointer rounded bg-surface-gray-1 px-3 py-[7.5px] text-base leading-6 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<div class="prose-f" v-html="activity.content" />
|
||||
<div v-if="activity.attachments.length" class="mt-2 flex flex-wrap gap-2">
|
||||
@ -38,7 +38,7 @@
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||
import { Tooltip } from 'frappe-ui'
|
||||
import { timeAgo, dateFormat, dateTooltipFormat } from '@/utils'
|
||||
import { timeAgo, formatDate } from '@/utils'
|
||||
const props = defineProps({
|
||||
activity: Object,
|
||||
})
|
||||
|
||||
108
frontend/src/components/Activities/DataFields.vue
Normal file
@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div
|
||||
class="my-3 flex items-center justify-between text-lg font-medium sm:mb-4 sm:mt-8"
|
||||
>
|
||||
<div class="flex h-8 items-center text-xl font-semibold text-ink-gray-8">
|
||||
{{ __('Data') }}
|
||||
<Badge
|
||||
v-if="data.isDirty"
|
||||
class="ml-3"
|
||||
:label="'Not Saved'"
|
||||
theme="orange"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<Button v-if="isManager()" @click="showDataFieldsModal = true">
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
label="Save"
|
||||
:disabled="!data.isDirty"
|
||||
variant="solid"
|
||||
:loading="data.save.loading"
|
||||
@click="saveChanges"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="data.get.loading"
|
||||
class="flex flex-1 flex-col items-center justify-center gap-3 text-xl font-medium text-gray-500"
|
||||
>
|
||||
<LoadingIndicator class="h-6 w-6" />
|
||||
<span>{{ __('Loading...') }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<FieldLayout v-if="tabs.data" :tabs="tabs.data" :data="data.doc" />
|
||||
</div>
|
||||
<DataFieldsModal
|
||||
v-if="showDataFieldsModal"
|
||||
v-model="showDataFieldsModal"
|
||||
:doctype="doctype"
|
||||
@reload="
|
||||
() => {
|
||||
tabs.reload()
|
||||
data.reload()
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import DataFieldsModal from '@/components/Modals/DataFieldsModal.vue'
|
||||
import FieldLayout from '@/components/FieldLayout.vue'
|
||||
import { Badge, createResource, createDocumentResource } from 'frappe-ui'
|
||||
import LoadingIndicator from '@/components/Icons/LoadingIndicator.vue'
|
||||
import { createToast } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
doctype: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
docname: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
|
||||
const { isManager } = usersStore()
|
||||
|
||||
const showDataFieldsModal = ref(false)
|
||||
|
||||
const data = createDocumentResource({
|
||||
doctype: props.doctype,
|
||||
name: props.docname,
|
||||
setValue: {
|
||||
onSuccess: () => {
|
||||
data.reload()
|
||||
createToast({
|
||||
title: 'Data Updated',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600',
|
||||
})
|
||||
},
|
||||
onError: (err) => {
|
||||
createToast({
|
||||
title: 'Error',
|
||||
text: err.messages[0],
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600',
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
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() {
|
||||
data.save.submit()
|
||||
}
|
||||
</script>
|
||||
@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div
|
||||
class="cursor-pointer flex flex-col rounded-md shadow bg-white px-3 py-1.5 text-base transition-all duration-300 ease-in-out"
|
||||
class="cursor-pointer flex flex-col rounded-md shadow bg-surface-cards 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">
|
||||
<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-gray-600">
|
||||
<span class="sm:flex hidden text-sm text-ink-gray-5">
|
||||
{{ '<' + activity.data.sender + '>' }}
|
||||
</span>
|
||||
<Badge
|
||||
@ -22,8 +22,8 @@
|
||||
variant="subtle"
|
||||
:theme="status.color"
|
||||
/>
|
||||
<Tooltip :text="dateFormat(activity.creation, dateTooltipFormat)">
|
||||
<div class="text-sm text-gray-600">
|
||||
<Tooltip :text="formatDate(activity.creation)">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ __(timeAgo(activity.creation)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -32,7 +32,7 @@
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-gray-700"
|
||||
class="text-ink-gray-7"
|
||||
@click="reply(activity.data)"
|
||||
>
|
||||
<template #icon>
|
||||
@ -45,7 +45,7 @@
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="text-gray-700"
|
||||
class="text-ink-gray-7"
|
||||
@click="reply(activity.data, true)"
|
||||
>
|
||||
<template #icon>
|
||||
@ -57,24 +57,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-base leading-5 text-gray-800">
|
||||
<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-gray-600"> {{ __('To') }}: </span>
|
||||
<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-gray-600">
|
||||
<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-gray-600">
|
||||
<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-gray-200" />
|
||||
<div class="border-0 border-t mt-3 mb-1 border-outline-gray-modals" />
|
||||
<EmailContent :content="activity.data.content" />
|
||||
<div v-if="activity.data?.attachments?.length" class="flex flex-wrap gap-2">
|
||||
<AttachmentItem
|
||||
@ -92,7 +92,7 @@ import ReplyAllIcon from '@/components/Icons/ReplyAllIcon.vue'
|
||||
import AttachmentItem from '@/components/AttachmentItem.vue'
|
||||
import EmailContent from '@/components/Activities/EmailContent.vue'
|
||||
import { Badge, Tooltip } from 'frappe-ui'
|
||||
import { timeAgo, dateFormat, dateTooltipFormat } from '@/utils'
|
||||
import { timeAgo, formatDate } from '@/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="activity group flex h-48 cursor-pointer flex-col justify-between gap-2 rounded-md bg-gray-50 px-4 py-3 hover:bg-gray-100"
|
||||
class="activity group flex h-48 cursor-pointer flex-col justify-between gap-2 rounded-md bg-surface-menu-bar px-4 py-3 hover:bg-surface-gray-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="truncate text-lg font-medium">
|
||||
@ -20,7 +20,7 @@
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghosted"
|
||||
class="!h-6 !w-6 hover:bg-gray-100"
|
||||
class="!h-6 !w-6 hover:bg-surface-gray-2"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@ -28,21 +28,21 @@
|
||||
v-if="note.content"
|
||||
:content="note.content"
|
||||
:editable="false"
|
||||
editor-class="!prose-sm max-w-none !text-sm text-gray-600 focus:outline-none"
|
||||
editor-class="!prose-sm max-w-none !text-sm text-ink-gray-5 focus:outline-none"
|
||||
class="flex-1 overflow-hidden"
|
||||
/>
|
||||
<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-gray-800"
|
||||
class="truncate text-sm text-ink-gray-8"
|
||||
:title="getUser(note.owner).full_name"
|
||||
>
|
||||
{{ getUser(note.owner).full_name }}
|
||||
</div>
|
||||
</div>
|
||||
<Tooltip :text="dateFormat(note.modified, dateTooltipFormat)">
|
||||
<div class="truncate text-sm text-gray-700">
|
||||
<Tooltip :text="formatDate(note.modified)">
|
||||
<div class="truncate text-sm text-ink-gray-7">
|
||||
{{ __(timeAgo(note.modified)) }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -51,7 +51,7 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import { timeAgo, dateFormat, dateTooltipFormat } from '@/utils'
|
||||
import { timeAgo, formatDate } from '@/utils'
|
||||
import { Tooltip, Dropdown, TextEditor } from 'frappe-ui'
|
||||
import { usersStore } from '@/stores/users'
|
||||
|
||||
|
||||
@ -2,33 +2,33 @@
|
||||
<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-gray-50"
|
||||
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-gray-900 truncate">
|
||||
<div class="font-medium text-ink-gray-9 truncate">
|
||||
{{ task.title }}
|
||||
</div>
|
||||
<div class="flex gap-1.5 text-gray-800">
|
||||
<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-gray-600" :radius="2" />
|
||||
<DotIcon class="h-2.5 w-2.5 text-ink-gray-5" :radius="2" />
|
||||
</div>
|
||||
<div v-if="task.due_date">
|
||||
<Tooltip
|
||||
:text="dateFormat(task.due_date, 'ddd, MMM D, YYYY | hh:mm a')"
|
||||
:text="formatDate(task.due_date, 'ddd, MMM D, YYYY | hh:mm a')"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<CalendarIcon />
|
||||
<div>{{ dateFormat(task.due_date, 'D MMM, hh:mm a') }}</div>
|
||||
<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-gray-600" :radius="2" />
|
||||
<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" />
|
||||
@ -42,7 +42,7 @@
|
||||
@click.stop
|
||||
>
|
||||
<Tooltip :text="__('Change Status')">
|
||||
<Button variant="ghosted" class="hover:bg-gray-300">
|
||||
<Button variant="ghosted" class="hover:bg-surface-gray-4">
|
||||
<TaskStatusIcon :status="task.status" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@ -76,14 +76,14 @@
|
||||
<Button
|
||||
icon="more-horizontal"
|
||||
variant="ghosted"
|
||||
class="hover:bg-gray-300"
|
||||
class="hover:bg-surface-gray-4 text-ink-gray-9"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="i < tasks.length - 1"
|
||||
class="mx-2 h-px border-t border-gray-200"
|
||||
class="mx-2 h-px border-t border-outline-gray-modals"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -94,7 +94,7 @@ 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 { dateFormat, taskStatusOptions } from '@/utils'
|
||||
import { formatDate, taskStatusOptions } from '@/utils'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { globalStore } from '@/stores/global'
|
||||
import { Tooltip, Dropdown } from 'frappe-ui'
|
||||
|
||||
@ -11,12 +11,12 @@
|
||||
>
|
||||
<div
|
||||
:id="whatsapp.name"
|
||||
class="group/message relative max-w-[90%] rounded-md bg-gray-50 p-1.5 pl-2 text-base shadow-sm"
|
||||
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"
|
||||
>
|
||||
<div
|
||||
v-if="whatsapp.is_reply"
|
||||
@click="() => scrollToMessage(whatsapp.reply_to)"
|
||||
class="mb-1 cursor-pointer rounded border-0 border-l-4 bg-gray-200 p-2 text-gray-600"
|
||||
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'
|
||||
@ -27,8 +27,8 @@
|
||||
class="mb-1 text-sm font-bold"
|
||||
:class="
|
||||
whatsapp.reply_to_type == 'Incoming'
|
||||
? 'text-green-500'
|
||||
: 'text-blue-400'
|
||||
? 'text-ink-green-2'
|
||||
: 'text-ink-blue-link'
|
||||
"
|
||||
>
|
||||
{{ whatsapp.reply_to_from || __('You') }}
|
||||
@ -38,25 +38,25 @@
|
||||
{{ whatsapp.header }}
|
||||
</div>
|
||||
<div v-html="formatWhatsAppMessage(whatsapp.reply_message)" />
|
||||
<div v-if="whatsapp.footer" class="text-xs text-gray-600">
|
||||
<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
|
||||
class="absolute -right-0.5 -top-0.5 flex cursor-pointer gap-1 rounded-full bg-white pb-2 pl-2 pr-1.5 pt-1.5 opacity-0 group-hover/message:opacity-100"
|
||||
class="absolute -right-0.5 -top-0.5 flex cursor-pointer gap-1 rounded-full bg-surface-white 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)">
|
||||
<FeatherIcon name="chevron-down" class="size-4 text-gray-600" />
|
||||
<FeatherIcon name="chevron-down" class="size-4 text-ink-gray-5" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div
|
||||
class="absolute -bottom-5 flex gap-1 rounded-full border bg-white p-1 pb-[3px] shadow-sm"
|
||||
class="absolute -bottom-5 flex gap-1 rounded-full border bg-surface-white p-1 pb-[3px] shadow-sm"
|
||||
v-if="whatsapp.reaction"
|
||||
>
|
||||
<div class="flex size-4 items-center justify-center">
|
||||
@ -71,7 +71,7 @@
|
||||
{{ whatsapp.header }}
|
||||
</div>
|
||||
<div v-html="formatWhatsAppMessage(whatsapp.template)" />
|
||||
<div v-if="whatsapp.footer" class="text-xs text-gray-600">
|
||||
<div v-if="whatsapp.footer" class="text-xs text-ink-gray-5">
|
||||
{{ whatsapp.footer }}
|
||||
</div>
|
||||
</div>
|
||||
@ -100,10 +100,10 @@
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<DocumentIcon
|
||||
class="size-10 cursor-pointer rounded-md text-gray-500"
|
||||
class="size-10 cursor-pointer rounded-md text-ink-gray-4"
|
||||
@click="() => openFileInAnotherTab(whatsapp.attach)"
|
||||
/>
|
||||
<div class="text-gray-600">Document</div>
|
||||
<div class="text-ink-gray-5">Document</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="whatsapp.content_type == 'audio'"
|
||||
@ -126,10 +126,10 @@
|
||||
v-html="formatWhatsAppMessage(whatsapp.message)"
|
||||
/>
|
||||
</div>
|
||||
<div class="-mb-1 flex shrink-0 items-end gap-1 text-gray-600">
|
||||
<Tooltip :text="dateFormat(whatsapp.creation, 'ddd, MMM D, YYYY')">
|
||||
<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">
|
||||
{{ dateFormat(whatsapp.creation, 'hh:mm a') }}
|
||||
{{ formatDate(whatsapp.creation, 'hh:mm a') }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div v-if="whatsapp.type == 'Outgoing'">
|
||||
@ -140,7 +140,7 @@
|
||||
<DoubleCheckIcon
|
||||
v-else-if="['read', 'delivered'].includes(whatsapp.status)"
|
||||
class="size-4"
|
||||
:class="{ 'text-blue-500': whatsapp.status == 'read' }"
|
||||
:class="{ 'text-ink-blue-2': whatsapp.status == 'read' }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -159,7 +159,7 @@
|
||||
@click="() => (reaction = true) && togglePopover()"
|
||||
class="rounded-full !size-6 mt-0.5"
|
||||
>
|
||||
<ReactIcon class="text-gray-400" />
|
||||
<ReactIcon class="text-ink-gray-3" />
|
||||
</Button>
|
||||
</IconPicker>
|
||||
</div>
|
||||
@ -173,7 +173,7 @@ 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 { dateFormat } from '@/utils'
|
||||
import { formatDate } from '@/utils'
|
||||
import { capture } from '@/telemetry'
|
||||
import { Tooltip, Dropdown, createResource } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
@ -4,12 +4,12 @@
|
||||
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-gray-100 p-2 text-base text-gray-600"
|
||||
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 font-bold"
|
||||
:class="reply.type == 'Incoming' ? 'text-green-500' : 'text-blue-400'"
|
||||
:class="reply.type == 'Incoming' ? 'text-ink-green-2' : 'text-ink-blue-link'"
|
||||
>
|
||||
{{ reply.from_name || __('You') }}
|
||||
</div>
|
||||
@ -26,7 +26,7 @@
|
||||
<Dropdown :options="uploadOptions(openFileSelector)">
|
||||
<FeatherIcon
|
||||
name="plus"
|
||||
class="size-4.5 cursor-pointer text-gray-600"
|
||||
class="size-4.5 cursor-pointer text-ink-gray-5"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
@ -45,7 +45,7 @@
|
||||
>
|
||||
<SmileIcon
|
||||
@click="togglePopover"
|
||||
class="flex size-4.5 cursor-pointer rounded-sm text-xl leading-none text-gray-500"
|
||||
class="flex size-4.5 cursor-pointer rounded-sm text-xl leading-none text-ink-gray-4"
|
||||
/>
|
||||
</IconPicker>
|
||||
</div>
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
:class="[
|
||||
active ? 'bg-gray-100' : 'text-gray-800',
|
||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base hover:bg-gray-100',
|
||||
active ? 'bg-surface-gray-3' : 'text-ink-gray-6',
|
||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base hover:bg-surface-gray-2',
|
||||
]"
|
||||
@click.prevent="togglePopover()"
|
||||
>
|
||||
@ -14,20 +14,20 @@
|
||||
{{ __('Apps') }}
|
||||
</span>
|
||||
</div>
|
||||
<FeatherIcon name="chevron-right" class="size-4 text-gray-600" />
|
||||
<FeatherIcon name="chevron-right" class="size-4 text-ink-gray-5" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div v-for="app in apps.data" :key="app.name">
|
||||
<a
|
||||
:href="app.route"
|
||||
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-1 hover:bg-gray-100"
|
||||
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-1 hover:bg-surface-gray-2"
|
||||
>
|
||||
<img class="size-8" :src="app.logo" />
|
||||
<div class="text-sm text-gray-700" @click="app.onClick">
|
||||
<div class="text-sm text-ink-gray-7" @click="app.onClick">
|
||||
{{ app.title }}
|
||||
</div>
|
||||
</a>
|
||||
@ -39,8 +39,8 @@
|
||||
<script setup>
|
||||
import AppsIcon from '@/components/Icons/AppsIcon.vue'
|
||||
import { Popover, createResource } from 'frappe-ui'
|
||||
import { onUnmounted } from 'vue';
|
||||
import { stopRecording } from '@/telemetry';
|
||||
import { onUnmounted } from 'vue'
|
||||
import { stopRecording } from '@/telemetry'
|
||||
|
||||
const props = defineProps({
|
||||
active: Boolean,
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
<div v-show="showCallPopup" v-bind="$attrs">
|
||||
<div
|
||||
ref="callPopup"
|
||||
class="fixed z-20 flex w-60 cursor-move select-none flex-col rounded-lg bg-gray-900 p-4 text-gray-300 shadow-2xl"
|
||||
class="fixed z-20 flex w-60 cursor-move select-none flex-col rounded-lg bg-surface-gray-7 p-4 text-ink-gray-2 shadow-2xl"
|
||||
:style="style"
|
||||
>
|
||||
<div class="flex flex-row-reverse items-center gap-1">
|
||||
@ -22,7 +22,7 @@
|
||||
<div class="text-xl font-medium">
|
||||
{{ contact.full_name }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">{{ contact.mobile_no }}</div>
|
||||
<div class="text-sm text-ink-gray-5">{{ contact.mobile_no }}</div>
|
||||
</div>
|
||||
<CountUpTimer ref="counterUp">
|
||||
<div v-if="onCall" class="my-1 text-base">
|
||||
@ -54,15 +54,15 @@
|
||||
<Button class="rounded-full">
|
||||
<template #icon>
|
||||
<NoteIcon
|
||||
class="h-4 w-4 cursor-pointer rounded-full text-gray-900"
|
||||
class="h-4 w-4 cursor-pointer rounded-full text-ink-gray-9"
|
||||
@click="showNoteModal = true"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
<Button class="rounded-full bg-red-600 hover:bg-red-700">
|
||||
<Button class="rounded-full bg-surface-red-5 hover:bg-surface-red-6">
|
||||
<template #icon>
|
||||
<PhoneIcon
|
||||
class="h-4 w-4 rotate-[135deg] fill-white text-white"
|
||||
class="h-4 w-4 rotate-[135deg] fill-white text-ink-white"
|
||||
@click="hangUpCall"
|
||||
/>
|
||||
</template>
|
||||
@ -114,7 +114,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-show="showSmallCallWindow"
|
||||
class="ml-2 flex cursor-pointer select-none items-center justify-between gap-3 rounded-lg bg-gray-900 px-2 py-[7px] text-base text-gray-300"
|
||||
class="ml-2 flex cursor-pointer select-none items-center justify-between gap-3 rounded-lg bg-surface-gray-7 px-2 py-[7px] text-base text-ink-gray-2"
|
||||
@click="toggleCallWindow"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
</template>
|
||||
<template #body="{ close }">
|
||||
<div
|
||||
class="my-2 rounded-lg border border-gray-100 bg-white p-1.5 shadow-xl"
|
||||
class="my-2 p-1.5 min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div v-if="!edit">
|
||||
<Draggable
|
||||
@ -24,7 +24,7 @@
|
||||
>
|
||||
<template #item="{ element }">
|
||||
<div
|
||||
class="flex cursor-grab items-center justify-between gap-6 rounded px-2 py-1.5 text-base text-gray-800 hover:bg-gray-100"
|
||||
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" />
|
||||
@ -49,7 +49,7 @@
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div class="mt-1.5 flex flex-col gap-1 border-t pt-1.5">
|
||||
<div class="mt-1.5 flex flex-col gap-1 border-t border-outline-gray-modals pt-1.5">
|
||||
<Autocomplete
|
||||
value=""
|
||||
:options="fields"
|
||||
@ -57,7 +57,7 @@
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
class="w-full !justify-start !text-gray-600"
|
||||
class="w-full !justify-start !text-ink-gray-5"
|
||||
variant="ghost"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Column')"
|
||||
@ -70,7 +70,7 @@
|
||||
</Autocomplete>
|
||||
<Button
|
||||
v-if="columnsUpdated"
|
||||
class="w-full !justify-start !text-gray-600"
|
||||
class="w-full !justify-start !text-ink-gray-5"
|
||||
variant="ghost"
|
||||
@click="reset(close)"
|
||||
:label="__('Reset Changes')"
|
||||
@ -81,7 +81,7 @@
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!is_default"
|
||||
class="w-full !justify-start !text-gray-600"
|
||||
class="w-full !justify-start !text-ink-gray-5"
|
||||
variant="ghost"
|
||||
@click="resetToDefault(close)"
|
||||
:label="__('Reset to Default')"
|
||||
@ -94,7 +94,7 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
class="flex flex-col items-center justify-between gap-2 rounded px-2 py-1.5 text-base text-gray-800"
|
||||
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
|
||||
@ -114,7 +114,7 @@
|
||||
placeholder="10rem"
|
||||
:description="
|
||||
__(
|
||||
'Width can be in number, pixel or rem (eg. 3, 30px, 10rem)'
|
||||
'Width can be in number, pixel or rem (eg. 3, 30px, 10rem)',
|
||||
)
|
||||
"
|
||||
:debounce="500"
|
||||
@ -295,6 +295,6 @@ watchOnce(
|
||||
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>
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
<Button
|
||||
ref="sendEmailRef"
|
||||
variant="ghost"
|
||||
:class="[showEmailBox ? '!bg-gray-300 hover:!bg-gray-200' : '']"
|
||||
:class="[showEmailBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '']"
|
||||
:label="__('Reply')"
|
||||
@click="toggleEmailBox()"
|
||||
>
|
||||
@ -15,7 +15,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
:label="__('Comment')"
|
||||
:class="[showCommentBox ? '!bg-gray-300 hover:!bg-gray-200' : '']"
|
||||
:class="[showCommentBox ? '!bg-surface-gray-4 hover:!bg-surface-gray-3' : '']"
|
||||
@click="toggleCommentBox()"
|
||||
>
|
||||
<template #prefix>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="space-y-1.5">
|
||||
<div class="space-y-1.5 p-[2px] -m-[2px]">
|
||||
<label class="block" :class="labelClasses" v-if="attrs.label">
|
||||
{{ __(attrs.label) }}
|
||||
</label>
|
||||
@ -34,7 +34,7 @@
|
||||
variant="ghost"
|
||||
class="w-full !justify-start"
|
||||
:label="__('Create New')"
|
||||
@click="attrs.onCreate(value, close)"
|
||||
@click="() => attrs.onCreate(value, close)"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
@ -70,8 +70,8 @@ const props = defineProps({
|
||||
required: true,
|
||||
},
|
||||
filters: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
type: [Array, String],
|
||||
default: [],
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
@ -110,13 +110,13 @@ watchDebounced(
|
||||
text.value = val
|
||||
reload(val)
|
||||
},
|
||||
{ debounce: 300, immediate: true }
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
watchDebounced(
|
||||
() => props.doctype,
|
||||
() => reload(''),
|
||||
{ debounce: 300, immediate: true }
|
||||
{ debounce: 300, immediate: true },
|
||||
)
|
||||
|
||||
const options = createResource({
|
||||
@ -174,7 +174,7 @@ const labelClasses = computed(() => {
|
||||
sm: 'text-xs',
|
||||
md: 'text-base',
|
||||
}[attrs.size || 'sm'],
|
||||
'text-gray-600',
|
||||
'text-ink-gray-5',
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="flex flex-wrap gap-1 min-h-20 p-1.5 cursor-text rounded h-7 text-base border border-gray-300 bg-white hover:border-gray-400 focus:border-gray-500 focus:ring-0 focus-visible:ring-2 focus-visible:ring-gray-400 text-gray-800 transition-colors w-full"
|
||||
class="group flex flex-wrap gap-1 min-h-20 p-1.5 cursor-text rounded h-7 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"
|
||||
@click="setFocus"
|
||||
>
|
||||
<Button
|
||||
@ -11,7 +11,7 @@
|
||||
:label="value"
|
||||
theme="gray"
|
||||
variant="subtle"
|
||||
class="rounded"
|
||||
class="rounded bg-surface-gray-3 group-hover:bg-surface-gray-4 focus-visible:ring-outline-gray-4"
|
||||
@keydown.delete.capture.stop="removeLastValue"
|
||||
>
|
||||
<template #suffix>
|
||||
@ -23,9 +23,9 @@
|
||||
</template>
|
||||
</Button>
|
||||
<div class="flex-1">
|
||||
<TextInput
|
||||
<input
|
||||
ref="search"
|
||||
class="w-full border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
class="w-full border-none h-7 text-base bg-surface-gray-2 group-hover:bg-surface-gray-3 focus:border-none focus:!shadow-none focus-visible:!ring-0 transition-colors"
|
||||
type="text"
|
||||
v-model="query"
|
||||
placeholder="example@email.com"
|
||||
@ -113,7 +113,7 @@ const removeLastValue = () => {
|
||||
}
|
||||
|
||||
function setFocus() {
|
||||
search.value.el.focus()
|
||||
search.value.focus()
|
||||
}
|
||||
|
||||
defineExpose({ setFocus })
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
<template #target="{ togglePopover }">
|
||||
<ComboboxInput
|
||||
ref="search"
|
||||
class="search-input form-input w-full border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
class="search-input form-input w-full border-none bg-surface-white hover:bg-surface-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
type="text"
|
||||
:value="query"
|
||||
@change="
|
||||
@ -41,9 +41,11 @@
|
||||
</template>
|
||||
<template #body="{ isOpen }">
|
||||
<div v-show="isOpen">
|
||||
<div class="mt-1 rounded-lg bg-white py-1 text-base shadow-2xl">
|
||||
<div
|
||||
class="mt-1 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<ComboboxOptions
|
||||
class="my-1 max-h-[12rem] overflow-y-auto px-1.5"
|
||||
class="p-1.5 max-h-[12rem] overflow-y-auto"
|
||||
static
|
||||
>
|
||||
<ComboboxOption
|
||||
@ -55,7 +57,7 @@
|
||||
<li
|
||||
:class="[
|
||||
'flex cursor-pointer items-center rounded px-2 py-1 text-base',
|
||||
{ 'bg-gray-100': active },
|
||||
{ 'bg-surface-gray-3': active },
|
||||
]"
|
||||
>
|
||||
<UserAvatar
|
||||
@ -63,11 +65,11 @@
|
||||
:user="option.value"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="flex flex-col gap-1 p-1 text-gray-800">
|
||||
<div class="flex flex-col gap-1 p-1 text-ink-gray-8">
|
||||
<div class="text-base font-medium">
|
||||
{{ option.label }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">
|
||||
<div class="text-sm text-ink-gray-5">
|
||||
{{ option.value }}
|
||||
</div>
|
||||
</div>
|
||||
@ -146,16 +148,15 @@ const filterOptions = createResource({
|
||||
cache: [text.value, 'Contact'],
|
||||
params: { txt: text.value },
|
||||
transform: (data) => {
|
||||
let allData = data
|
||||
.map((option) => {
|
||||
let fullName = option[0]
|
||||
let email = option[1]
|
||||
let name = option[2]
|
||||
return {
|
||||
label: fullName || name || email,
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
let allData = data.map((option) => {
|
||||
let fullName = option[0]
|
||||
let email = option[1]
|
||||
let name = option[2]
|
||||
return {
|
||||
label: fullName || name || email,
|
||||
value: email,
|
||||
}
|
||||
})
|
||||
return allData
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="group flex w-full items-center justify-between rounded bg-transparent p-1 pl-2 text-base text-gray-800 transition-colors hover:bg-gray-200 active:bg-gray-300"
|
||||
class="group flex w-full items-center justify-between rounded bg-transparent p-1 pl-2 text-base text-ink-gray-8 transition-colors hover:bg-surface-gray-3 active:bg-surface-gray-4"
|
||||
>
|
||||
<div class="flex flex-1 items-center justify-between gap-7">
|
||||
<div v-show="!editMode">{{ option.value }}</div>
|
||||
@ -20,7 +20,7 @@
|
||||
variant="ghost"
|
||||
:label="__('Save')"
|
||||
size="sm"
|
||||
class="opacity-0 hover:bg-gray-300 group-hover:opacity-100"
|
||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||
@click="saveOption"
|
||||
/>
|
||||
<Tooltip text="Set As Primary" v-if="!isNew && !option.selected">
|
||||
@ -28,7 +28,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="opacity-0 hover:bg-gray-300 group-hover:opacity-100"
|
||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||
@click="option.onClick"
|
||||
>
|
||||
<SuccessIcon />
|
||||
@ -40,7 +40,7 @@
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="opacity-0 hover:bg-gray-300 group-hover:opacity-100"
|
||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||
@click="toggleEditMode"
|
||||
>
|
||||
<EditIcon />
|
||||
@ -53,7 +53,7 @@
|
||||
variant="ghost"
|
||||
icon="x"
|
||||
size="sm"
|
||||
class="opacity-0 hover:bg-gray-300 group-hover:opacity-100"
|
||||
class="opacity-0 hover:bg-surface-gray-4 group-hover:opacity-100"
|
||||
@click="() => option.onDelete(option, isNew)"
|
||||
/>
|
||||
</div>
|
||||
@ -61,7 +61,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="option.selected">
|
||||
<FeatherIcon name="check" class="text-primary-500 h-4 w-6" size="sm" />
|
||||
<FeatherIcon name="check" class="text-ink-gray-5 h-4 w-6" size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<template #top>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="sm:mx-10 mx-4 flex items-center gap-2 border-t pt-2.5">
|
||||
<span class="text-xs text-gray-500">{{ __('TO') }}:</span>
|
||||
<span class="text-xs text-ink-gray-4">{{ __('TO') }}:</span>
|
||||
<MultiselectInput
|
||||
class="flex-1"
|
||||
v-model="toEmails"
|
||||
@ -34,7 +34,9 @@
|
||||
variant="ghost"
|
||||
@click="toggleCC()"
|
||||
:class="[
|
||||
cc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
|
||||
cc
|
||||
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
|
||||
: '!text-ink-gray-4',
|
||||
]"
|
||||
/>
|
||||
<Button
|
||||
@ -42,13 +44,15 @@
|
||||
variant="ghost"
|
||||
@click="toggleBCC()"
|
||||
:class="[
|
||||
bcc ? '!bg-gray-300 hover:bg-gray-200' : '!text-gray-500',
|
||||
bcc
|
||||
? '!bg-surface-gray-4 hover:bg-surface-gray-3'
|
||||
: '!text-ink-gray-4',
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">{{ __('CC') }}:</span>
|
||||
<span class="text-xs text-ink-gray-4">{{ __('CC') }}:</span>
|
||||
<MultiselectInput
|
||||
ref="ccInput"
|
||||
class="flex-1"
|
||||
@ -60,7 +64,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-if="bcc" class="sm:mx-10 mx-4 flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500">{{ __('BCC') }}:</span>
|
||||
<span class="text-xs text-ink-gray-4">{{ __('BCC') }}:</span>
|
||||
<MultiselectInput
|
||||
ref="bccInput"
|
||||
class="flex-1"
|
||||
@ -72,9 +76,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:mx-10 mx-4 flex items-center gap-2 pb-2.5">
|
||||
<span class="text-xs text-gray-500">{{ __('SUBJECT') }}:</span>
|
||||
<TextInput
|
||||
class="flex-1 border-none bg-white hover:bg-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
<span class="text-xs text-ink-gray-4">{{ __('SUBJECT') }}:</span>
|
||||
<input
|
||||
class="flex-1 border-none text-ink-gray-9 text-base bg-surface-white hover:bg-surface-white focus:border-none focus:!shadow-none focus-visible:!ring-0"
|
||||
v-model="subject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
271
frontend/src/components/FieldLayout.vue
Normal file
@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col"
|
||||
:class="{
|
||||
'border border-outline-gray-1 rounded-lg': hasTabs,
|
||||
'border-outline-gray-modals': modal && hasTabs,
|
||||
}"
|
||||
>
|
||||
<Tabs
|
||||
v-model="tabIndex"
|
||||
class="!h-full"
|
||||
:tabs="tabs"
|
||||
v-slot="{ tab }"
|
||||
:tablistClass="
|
||||
!hasTabs ? 'hidden' : modal ? 'border-outline-gray-modals' : ''
|
||||
"
|
||||
>
|
||||
<div :class="{ 'my-4 sm:my-6': hasTabs }">
|
||||
<div
|
||||
v-for="(section, i) in tab.sections"
|
||||
:key="section.label"
|
||||
class="section"
|
||||
>
|
||||
<div
|
||||
v-if="i != 0"
|
||||
class="w-full"
|
||||
:class="[section.hideBorder ? 'mt-4' : 'h-px border-t my-5']"
|
||||
/>
|
||||
<Section
|
||||
class="text-lg font-medium"
|
||||
:class="{ 'px-3 sm:px-5': hasTabs }"
|
||||
:label="section.label"
|
||||
:hideLabel="section.hideLabel"
|
||||
:opened="section.opened"
|
||||
:collapsible="section.collapsible"
|
||||
collapseIconPosition="right"
|
||||
>
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:class="[
|
||||
gridClass(section.columns),
|
||||
{ 'px-3 sm:px-5': hasTabs },
|
||||
{ 'mt-6': !section.hideLabel },
|
||||
]"
|
||||
>
|
||||
<div v-for="field in section.fields" :key="field.name">
|
||||
<div
|
||||
class="settings-field"
|
||||
v-if="
|
||||
(field.type == 'Check' ||
|
||||
(field.read_only && data[field.name]) ||
|
||||
!field.read_only ||
|
||||
!field.hidden) &&
|
||||
(!field.depends_on || field.display_via_depends_on)
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="field.type != 'Check'"
|
||||
class="mb-2 text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span
|
||||
class="text-ink-red-3"
|
||||
v-if="
|
||||
field.mandatory ||
|
||||
(field.mandatory_depends_on &&
|
||||
field.mandatory_via_depends_on)
|
||||
"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.type !== 'Check'"
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Select'"
|
||||
type="select"
|
||||
class="form-control"
|
||||
:class="field.prefix ? 'prefix' : ''"
|
||||
:options="field.options"
|
||||
v-model="data[field.name]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
>
|
||||
<template v-if="field.prefix" #prefix>
|
||||
<IndicatorIcon :class="field.prefix" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div
|
||||
v-else-if="field.type == 'Check'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<FormControl
|
||||
class="form-control"
|
||||
type="checkbox"
|
||||
v-model="data[field.name]"
|
||||
@change="(e) => (data[field.name] = e.target.checked)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<label
|
||||
class="text-sm text-ink-gray-5"
|
||||
@click="data[field.name] = !data[field.name]"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span class="text-ink-red-3" v-if="field.mandatory"
|
||||
>*</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-1" v-else-if="field.type === 'Link'">
|
||||
<Link
|
||||
class="form-control flex-1 truncate"
|
||||
:value="data[field.name]"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.name] = v)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
<Button
|
||||
v-if="data[field.name] && field.edit"
|
||||
class="shrink-0"
|
||||
:label="__('Edit')"
|
||||
@click="field.edit(data[field.name])"
|
||||
>
|
||||
<template #prefix>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
v-else-if="field.type === 'User'"
|
||||
class="form-control"
|
||||
:value="getUser(data[field.name]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.name] = v)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar
|
||||
class="mr-2"
|
||||
:user="data[field.name]"
|
||||
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">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<DateTimePicker
|
||||
v-else-if="field.type === 'Datetime'"
|
||||
v-model="data[field.name]"
|
||||
icon-left=""
|
||||
:formatter="(date) => getFormat(date, '', true, true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.type === 'Date'"
|
||||
icon-left=""
|
||||
v-model="data[field.name]"
|
||||
:formatter="(date) => getFormat(date, '', true)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text'].includes(field.type)
|
||||
"
|
||||
type="textarea"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.type)"
|
||||
type="number"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import Section from '@/components/Section.vue'
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { getFormat } from '@/utils'
|
||||
import { Tabs, Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const props = defineProps({
|
||||
tabs: Array,
|
||||
data: Object,
|
||||
modal: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const hasTabs = computed(() => !props.tabs[0].no_tabs)
|
||||
|
||||
const tabIndex = ref(0)
|
||||
|
||||
function gridClass(columns) {
|
||||
columns = columns || 3
|
||||
let griColsMap = {
|
||||
1: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
return griColsMap[columns]
|
||||
}
|
||||
|
||||
const getPlaceholder = (field) => {
|
||||
if (field.placeholder) {
|
||||
return __(field.placeholder)
|
||||
}
|
||||
if (['Select', 'Link'].includes(field.type)) {
|
||||
return __('Select {0}', [__(field.label)])
|
||||
} else {
|
||||
return __('Enter {0}', [__(field.label)])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.form-control.prefix select) {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section:has(.settings-field) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
366
frontend/src/components/FieldLayoutEditor.vue
Normal file
@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-5.5">
|
||||
<div
|
||||
class="flex justify-between items-center gap-1 text-base bg-surface-gray-2 rounded py-2 px-2.5"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<Draggable
|
||||
v-if="tabs.length && !tabs[tabIndex].no_tabs"
|
||||
:list="tabs"
|
||||
item-key="label"
|
||||
class="flex items-center gap-1"
|
||||
@end="(e) => (tabIndex = e.newIndex)"
|
||||
>
|
||||
<template #item="{ element: tab, index: i }">
|
||||
<div
|
||||
class="cursor-pointer rounded"
|
||||
:class="[
|
||||
tabIndex == i
|
||||
? 'text-ink-gray-9 bg-surface-white shadow-sm'
|
||||
: 'text-ink-gray-5 hover:text-ink-gray-9 hover:bg-surface-white hover:shadow-sm',
|
||||
tab.editingLabel ? 'p-1' : 'px-2 py-1',
|
||||
]"
|
||||
@click="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="check"
|
||||
variant="ghost"
|
||||
@click="tab.editingLabel = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!h-6.5 !text-ink-gray-5 hover:!text-ink-gray-9"
|
||||
@click="addTab"
|
||||
:label="__('Add Tab')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
<Dropdown
|
||||
v-if="tabs.length && !tabs[tabIndex].no_tabs"
|
||||
:options="getTabOptions(tabs[tabIndex])"
|
||||
>
|
||||
<template #default>
|
||||
<Button variant="ghost">
|
||||
<FeatherIcon name="more-horizontal" class="h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div v-show="tabIndex == i" v-for="(tab, i) in tabs" :key="tab.label">
|
||||
<Draggable
|
||||
:list="tab.sections"
|
||||
item-key="label"
|
||||
class="flex flex-col gap-5.5"
|
||||
>
|
||||
<template #item="{ element: section }">
|
||||
<div class="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 cursor-pointer items-center gap-2 text-base font-medium leading-4 text-ink-gray-9"
|
||||
@dblclick="() => (section.editingLabel = true)"
|
||||
>
|
||||
<div
|
||||
v-if="!section.editingLabel"
|
||||
class="flex items-center gap-2"
|
||||
:class="{ 'text-ink-gray-3': section.hideLabel }"
|
||||
>
|
||||
{{ __(section.label) || __('Untitled') }}
|
||||
<FeatherIcon
|
||||
v-if="section.collapsible"
|
||||
name="chevron-down"
|
||||
class="h-4 transition-all duration-300 ease-in-out"
|
||||
/>
|
||||
</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="check"
|
||||
variant="ghost"
|
||||
@click="section.editingLabel = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Dropdown :options="getSectionOptions(section)">
|
||||
<template #default>
|
||||
<Button variant="ghost">
|
||||
<FeatherIcon name="more-horizontal" class="h-4" />
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Draggable
|
||||
:list="section.fields"
|
||||
group="fields"
|
||||
item-key="label"
|
||||
class="grid gap-1.5"
|
||||
:class="gridClass(section.columns)"
|
||||
handle=".cursor-grab"
|
||||
>
|
||||
<template #item="{ element: field }">
|
||||
<div
|
||||
class="px-2.5 py-2 border border-outline-gray-2 rounded text-base bg-surface-modal text-ink-gray-8 flex items-center leading-4 justify-between gap-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<DragVerticalIcon class="h-3.5 cursor-grab" />
|
||||
<div>{{ field.label }}</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="!size-4 rounded-sm"
|
||||
icon="x"
|
||||
@click="
|
||||
section.fields.splice(section.fields.indexOf(field), 1)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<Autocomplete
|
||||
v-if="fields.data"
|
||||
value=""
|
||||
:options="fields.data"
|
||||
@change="(e) => addField(section, e)"
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<div class="gap-2 w-full">
|
||||
<Button
|
||||
class="w-full !h-8 !bg-surface-modal"
|
||||
variant="outline"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Field')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<template #item-label="{ option }">
|
||||
<div class="flex flex-col gap-1 text-ink-gray-9">
|
||||
<div>{{ option.label }}</div>
|
||||
<div class="text-ink-gray-4 text-sm">
|
||||
{{ `${option.fieldname} - ${option.fieldtype}` }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
<div class="mt-5.5">
|
||||
<Button
|
||||
class="w-full h-8"
|
||||
variant="subtle"
|
||||
:label="__('Add Section')"
|
||||
@click="
|
||||
tabs[tabIndex].sections.push({
|
||||
label: __('New Section'),
|
||||
opened: true,
|
||||
fields: [],
|
||||
})
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
|
||||
import DragVerticalIcon from '@/components/Icons/DragVerticalIcon.vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { Dropdown, createResource } from 'frappe-ui'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
tabs: Object,
|
||||
doctype: String,
|
||||
})
|
||||
|
||||
const tabIndex = ref(0)
|
||||
|
||||
const restrictedFieldTypes = [
|
||||
'Table',
|
||||
'Geolocation',
|
||||
'Attach',
|
||||
'Attach Image',
|
||||
'HTML',
|
||||
'Signature',
|
||||
]
|
||||
|
||||
const params = computed(() => {
|
||||
return {
|
||||
doctype: props.doctype,
|
||||
restricted_fieldtypes: restrictedFieldTypes,
|
||||
as_array: true,
|
||||
}
|
||||
})
|
||||
|
||||
const fields = createResource({
|
||||
url: 'crm.api.doc.get_fields_meta',
|
||||
params: params.value,
|
||||
cache: ['fieldsMeta', props.doctype],
|
||||
auto: true,
|
||||
})
|
||||
|
||||
function addTab() {
|
||||
if (props.tabs.length == 1 && props.tabs[0].no_tabs) {
|
||||
delete props.tabs[0].no_tabs
|
||||
return
|
||||
}
|
||||
props.tabs.push({ label: __('New Tab'), sections: [] })
|
||||
tabIndex.value = props.tabs.length ? props.tabs.length - 1 : 0
|
||||
}
|
||||
|
||||
function addField(section, field) {
|
||||
if (!field) return
|
||||
section.fields.push(field)
|
||||
}
|
||||
|
||||
function getTabOptions(tab) {
|
||||
return [
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'edit',
|
||||
onClick: () => (tab.editingLabel = true),
|
||||
},
|
||||
{
|
||||
label: 'Remove tab',
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
if (props.tabs.length == 1) {
|
||||
props.tabs[0].no_tabs = true
|
||||
return
|
||||
}
|
||||
props.tabs.splice(tabIndex.value, 1)
|
||||
tabIndex.value = tabIndex.value ? tabIndex.value - 1 : 0
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function getSectionOptions(section) {
|
||||
return [
|
||||
{
|
||||
label: 'Edit',
|
||||
icon: 'edit',
|
||||
onClick: () => (section.editingLabel = true),
|
||||
condition: () => section.editable !== false,
|
||||
},
|
||||
{
|
||||
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: 'Add column',
|
||||
icon: 'columns',
|
||||
onClick: () =>
|
||||
(section.columns = section.columns ? section.columns + 1 : 4),
|
||||
condition: () => !section.columns || section.columns < 4,
|
||||
},
|
||||
{
|
||||
label: 'Remove column',
|
||||
icon: 'columns',
|
||||
onClick: () =>
|
||||
(section.columns = section.columns ? section.columns - 1 : 2),
|
||||
condition: () => !section.columns || section.columns > 1,
|
||||
},
|
||||
{
|
||||
label: 'Remove section',
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
let currentTab = props.tabs[tabIndex.value]
|
||||
currentTab.sections.splice(currentTab.sections.indexOf(section), 1)
|
||||
},
|
||||
condition: () => section.editable !== false,
|
||||
},
|
||||
{
|
||||
label: 'Move to previous tab',
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
let previousTab = props.tabs[tabIndex.value - 1]
|
||||
previousTab.sections.push(section)
|
||||
props.tabs[tabIndex.value].sections.splice(
|
||||
props.tabs[tabIndex.value].sections.indexOf(section),
|
||||
1,
|
||||
)
|
||||
tabIndex.value -= 1
|
||||
},
|
||||
condition: () =>
|
||||
section.editable !== false && props.tabs[tabIndex.value - 1],
|
||||
},
|
||||
{
|
||||
label: 'Move to next tab',
|
||||
icon: 'trash-2',
|
||||
onClick: () => {
|
||||
let nextTab = props.tabs[tabIndex.value + 1]
|
||||
nextTab.sections.push(section)
|
||||
props.tabs[tabIndex.value].sections.splice(
|
||||
props.tabs[tabIndex.value].sections.indexOf(section),
|
||||
1,
|
||||
)
|
||||
tabIndex.value += 1
|
||||
},
|
||||
condition: () =>
|
||||
section.editable !== false && props.tabs[tabIndex.value + 1],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function gridClass(columns) {
|
||||
columns = columns || 3
|
||||
let griColsMap = {
|
||||
1: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-1',
|
||||
2: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-2',
|
||||
3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
|
||||
}
|
||||
return griColsMap[columns]
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.doctype,
|
||||
() => fields.fetch(params.value),
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
@ -1,212 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.label"
|
||||
class="section first:border-t-0 first:pt-0"
|
||||
:class="section.hideBorder ? '' : 'border-t pt-4'"
|
||||
>
|
||||
<div
|
||||
v-if="!section.hideLabel"
|
||||
class="flex h-7 mb-3 max-w-fit cursor-pointer items-center gap-2 text-base font-semibold leading-5"
|
||||
>
|
||||
{{ section.label }}
|
||||
</div>
|
||||
<div
|
||||
class="grid gap-4"
|
||||
:class="
|
||||
section.columns
|
||||
? 'grid-cols-' + section.columns
|
||||
: 'grid-cols-2 sm:grid-cols-3'
|
||||
"
|
||||
>
|
||||
<div v-for="field in section.fields" :key="field.name">
|
||||
<div
|
||||
class="settings-field"
|
||||
v-if="
|
||||
(field.type == 'Check' ||
|
||||
(field.read_only && data[field.name]) ||
|
||||
!field.read_only ||
|
||||
!field.hidden) &&
|
||||
(!field.depends_on || field.display_via_depends_on)
|
||||
"
|
||||
>
|
||||
<div
|
||||
v-if="field.type != 'Check'"
|
||||
class="mb-2 text-sm text-gray-600"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span
|
||||
class="text-red-500"
|
||||
v-if="
|
||||
field.mandatory ||
|
||||
(field.mandatory_depends_on && field.mandatory_via_depends_on)
|
||||
"
|
||||
>*</span
|
||||
>
|
||||
</div>
|
||||
<FormControl
|
||||
v-if="field.read_only && field.type !== 'Check'"
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
:disabled="true"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="field.type === 'Select'"
|
||||
type="select"
|
||||
class="form-control"
|
||||
:class="field.prefix ? 'prefix' : ''"
|
||||
:options="field.options"
|
||||
v-model="data[field.name]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
>
|
||||
<template v-if="field.prefix" #prefix>
|
||||
<IndicatorIcon :class="field.prefix" />
|
||||
</template>
|
||||
</FormControl>
|
||||
<div
|
||||
v-else-if="field.type == 'Check'"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<FormControl
|
||||
class="form-control"
|
||||
type="checkbox"
|
||||
v-model="data[field.name]"
|
||||
@change="(e) => (data[field.name] = e.target.checked)"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
<label
|
||||
class="text-sm text-gray-600"
|
||||
@click="data[field.name] = !data[field.name]"
|
||||
>
|
||||
{{ __(field.label) }}
|
||||
<span class="text-red-500" v-if="field.mandatory">*</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex gap-1" v-else-if="field.type === 'Link'">
|
||||
<Link
|
||||
class="form-control flex-1"
|
||||
:value="data[field.name]"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.name] = v)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:onCreate="field.create"
|
||||
/>
|
||||
<Button
|
||||
v-if="data[field.name] && field.edit"
|
||||
class="shrink-0"
|
||||
:label="__('Edit')"
|
||||
@click="field.edit(data[field.name])"
|
||||
>
|
||||
<template #prefix>
|
||||
<EditIcon class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
v-else-if="field.type === 'User'"
|
||||
class="form-control"
|
||||
:value="getUser(data[field.name]).full_name"
|
||||
:doctype="field.options"
|
||||
:filters="field.filters"
|
||||
@change="(v) => (data[field.name] = v)"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
:hideMe="true"
|
||||
>
|
||||
<template #prefix>
|
||||
<UserAvatar class="mr-2" :user="data[field.name]" 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">
|
||||
{{ getUser(option.value).full_name }}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</template>
|
||||
</Link>
|
||||
<DateTimePicker
|
||||
v-else-if="field.type === 'Datetime'"
|
||||
v-model="data[field.name]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<DatePicker
|
||||
v-else-if="field.type === 'Date'"
|
||||
v-model="data[field.name]"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
input-class="border-none"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="
|
||||
['Small Text', 'Text', 'Long Text'].includes(field.type)
|
||||
"
|
||||
type="textarea"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else-if="['Int'].includes(field.type)"
|
||||
type="number"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
/>
|
||||
<FormControl
|
||||
v-else
|
||||
type="text"
|
||||
:placeholder="getPlaceholder(field)"
|
||||
v-model="data[field.name]"
|
||||
:disabled="Boolean(field.read_only)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import EditIcon from '@/components/Icons/EditIcon.vue'
|
||||
import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue'
|
||||
import UserAvatar from '@/components/UserAvatar.vue'
|
||||
import Link from '@/components/Controls/Link.vue'
|
||||
import { usersStore } from '@/stores/users'
|
||||
import { Tooltip, DatePicker, DateTimePicker } from 'frappe-ui'
|
||||
|
||||
const { getUser } = usersStore()
|
||||
|
||||
const props = defineProps({
|
||||
sections: Array,
|
||||
data: Object,
|
||||
})
|
||||
|
||||
const getPlaceholder = (field) => {
|
||||
if (field.placeholder) {
|
||||
return __(field.placeholder)
|
||||
}
|
||||
if (['Select', 'Link'].includes(field.type)) {
|
||||
return __('Select {0}', [__(field.label)])
|
||||
} else {
|
||||
return __('Enter {0}', [__(field.label)])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.form-control.prefix select) {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.section:has(.settings-field) {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
@ -169,7 +169,7 @@ function uploadViaWebLink() {
|
||||
title: __('Error'),
|
||||
title: __('Please enter a valid URL'),
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600',
|
||||
iconClasses: 'text-ink-red-4',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@ -13,14 +13,14 @@
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed min-h-64 text-gray-600"
|
||||
class="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed min-h-64 text-ink-gray-5"
|
||||
@dragover.prevent="dragover"
|
||||
@dragleave.prevent="dragleave"
|
||||
@drop.prevent="dropfiles"
|
||||
v-show="files.length === 0"
|
||||
>
|
||||
<div v-if="!isDragging" class="flex flex-col gap-3">
|
||||
<div class="text-center text-gray-600">
|
||||
<div class="text-center text-ink-gray-5">
|
||||
{{ __('Drag and drop files here or upload from') }}
|
||||
</div>
|
||||
<div
|
||||
@ -75,8 +75,8 @@
|
||||
/>
|
||||
<component v-else class="size-4" :is="fileIcon(file.type)" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 text-sm text-gray-600 truncate">
|
||||
<div class="text-base text-gray-800 truncate">
|
||||
<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">
|
||||
@ -85,7 +85,7 @@
|
||||
<FormControl
|
||||
v-model="file.private"
|
||||
type="checkbox"
|
||||
class="[&>label]:text-sm [&>label]:text-gray-600"
|
||||
class="[&>label]:text-sm [&>label]:text-ink-gray-5"
|
||||
:label="__('Private')"
|
||||
/>
|
||||
<ErrorMessage
|
||||
@ -99,7 +99,7 @@
|
||||
<CircularProgressBar
|
||||
v-if="file.uploading || file.uploaded == file.total"
|
||||
:class="{
|
||||
'text-green-500': file.uploaded == file.total,
|
||||
'text-ink-green-2': file.uploaded == file.total,
|
||||
}"
|
||||
:theme="{
|
||||
primary: '#22C55E',
|
||||
@ -126,7 +126,7 @@
|
||||
import FileTextIcon from '@/components/Icons/FileTextIcon.vue'
|
||||
import FileAudioIcon from '@/components/Icons/FileAudioIcon.vue'
|
||||
import FileVideoIcon from '@/components/Icons/FileVideoIcon.vue'
|
||||
import { createToast, dateFormat, convertSize } from '@/utils'
|
||||
import { createToast, formatDate, convertSize } from '@/utils'
|
||||
import { FormControl, CircularProgressBar, createResource } from 'frappe-ui'
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
@ -242,7 +242,7 @@ function captureImage() {
|
||||
}
|
||||
|
||||
function uploadViaCamera() {
|
||||
const nowDatetime = dateFormat(new Date(), 'YYYY_MM_DD_HH_mm_ss')
|
||||
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])
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<template #prefix><FilterIcon class="h-4" /></template>
|
||||
<template v-if="filters?.size" #suffix>
|
||||
<div
|
||||
class="flex h-5 w-5 items-center justify-center rounded bg-gray-900 pt-[1px] text-2xs font-medium text-white"
|
||||
class="flex h-5 w-5 items-center justify-center rounded-[5px] bg-surface-white pt-px text-xs font-medium text-ink-gray-8 shadow-sm"
|
||||
>
|
||||
{{ filters.size }}
|
||||
</div>
|
||||
@ -27,7 +27,9 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #body="{ close }">
|
||||
<div class="my-2 rounded-lg border border-gray-100 bg-white shadow-xl">
|
||||
<div
|
||||
class="my-2 min-w-40 rounded-lg bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div class="min-w-72 p-2 sm:min-w-[400px]">
|
||||
<div
|
||||
v-if="filters?.size"
|
||||
@ -38,7 +40,7 @@
|
||||
>
|
||||
<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-gray-600">
|
||||
<div class="text-base text-ink-gray-5">
|
||||
{{ i == 0 ? __('Where') : __('And') }}
|
||||
</div>
|
||||
<Button
|
||||
@ -76,7 +78,7 @@
|
||||
</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-gray-600">
|
||||
<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]">
|
||||
@ -117,7 +119,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="mb-3 flex h-7 items-center px-3 text-sm text-gray-600"
|
||||
class="mb-3 flex h-7 items-center px-3 text-sm text-ink-gray-5"
|
||||
>
|
||||
{{ __('Empty - Choose a field to filter by') }}
|
||||
</div>
|
||||
@ -130,7 +132,7 @@
|
||||
>
|
||||
<template #target="{ togglePopover }">
|
||||
<Button
|
||||
class="!text-gray-600"
|
||||
class="!text-ink-gray-5"
|
||||
variant="ghost"
|
||||
@click="togglePopover()"
|
||||
:label="__('Add Filter')"
|
||||
@ -143,7 +145,7 @@
|
||||
</Autocomplete>
|
||||
<Button
|
||||
v-if="filters?.size"
|
||||
class="!text-gray-600"
|
||||
class="!text-ink-gray-5"
|
||||
variant="ghost"
|
||||
:label="__('Clear all Filter')"
|
||||
@click="clearfilter(close)"
|
||||
|
||||
@ -8,10 +8,10 @@
|
||||
<template #body="{ togglePopover }">
|
||||
<div
|
||||
v-if="reaction"
|
||||
class="flex items-center justify-center gap-2 rounded-full bg-white px-2 py-1 shadow-sm"
|
||||
class="px-2 py-1 flex items-center justify-center gap-2 rounded-full bg-surface-modal shadow-2xl ring-1 ring-black ring-opacity-5 focus:outline-none"
|
||||
>
|
||||
<div
|
||||
class="size-5 cursor-pointer rounded-full bg-white text-xl"
|
||||
class="size-5 cursor-pointer rounded-full bg-surface-transparent text-xl"
|
||||
v-for="r in reactionEmojis"
|
||||
:key="r"
|
||||
@click="() => (emoji = r) && togglePopover()"
|
||||
@ -26,9 +26,12 @@
|
||||
@click.stop="() => (reaction = false)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="my-3 max-w-max transform bg-white px-4 sm:px-0">
|
||||
<div
|
||||
v-else
|
||||
class="my-3 max-w-max transform bg-surface-white px-4 sm:px-0"
|
||||
>
|
||||
<div
|
||||
class="relative max-h-96 overflow-y-auto rounded-lg pb-3 shadow-2xl ring-1 ring-black ring-opacity-5"
|
||||
class="relative max-h-96 pb-3 overflow-y-auto min-w-40 rounded-lg bg-surface-modal 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">
|
||||
@ -43,12 +46,14 @@
|
||||
</div>
|
||||
<div class="w-96"></div>
|
||||
<div class="px-3" v-for="(emojis, group) in emojiGroups" :key="group">
|
||||
<div class="sticky top-0 bg-white pb-2 pt-3 text-sm text-gray-700">
|
||||
<div
|
||||
class="sticky top-0 bg-surface-modal pb-2 pt-3 text-sm text-ink-gray-7"
|
||||
>
|
||||
{{ group }}
|
||||
</div>
|
||||
<div class="grid w-96 grid-cols-12 place-items-center">
|
||||
<button
|
||||
class="h-8 w-8 rounded-md p-1 text-2xl hover:bg-gray-100 focus:outline-none focus:ring focus:ring-blue-200"
|
||||
class="h-8 w-8 rounded-md p-1 text-2xl hover:bg-surface-gray-2 focus:outline-none focus:ring focus:ring-blue-200"
|
||||
v-for="_emoji in emojis"
|
||||
:key="_emoji.description"
|
||||
@click="() => (emoji = _emoji.emoji) && togglePopover()"
|
||||
|
||||
@ -1,31 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M2 4.5H14"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M4 8.5H12"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 12.5H9.5"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2 4.16675C1.72386 4.16675 1.5 4.39061 1.5 4.66675C1.5 4.94289 1.72386 5.16675 2 5.16675H14C14.2761 5.16675 14.5 4.94289 14.5 4.66675C14.5 4.39061 14.2761 4.16675 14 4.16675H2ZM3.49996 7.99761C3.49996 7.72147 3.72382 7.49761 3.99996 7.49761H12C12.2761 7.49761 12.5 7.72147 12.5 7.99761C12.5 8.27375 12.2761 8.49761 12 8.49761H3.99996C3.72382 8.49761 3.49996 8.27375 3.49996 7.99761ZM5.9 11.3289C5.9 11.0527 6.12386 10.8289 6.4 10.8289H9.6C9.87614 10.8289 10.1 11.0527 10.1 11.3289C10.1 11.605 9.87614 11.8289 9.6 11.8289H6.4C6.12386 11.8289 5.9 11.605 5.9 11.3289Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@ -1,45 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1.75 3.75H10.75"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.75 7.75H7.75"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M1.75 11.75H5.75"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14.25 8.25L12.25 6.25L10.25 8.25"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M12.25 12.25L12.25 6.25"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.5 3.5C5.5 3.22386 5.27614 3 5 3C4.72386 3 4.5 3.22386 4.5 3.5V11.2931L2.87846 9.67159C2.68319 9.47633 2.36661 9.47633 2.17135 9.67159C1.97609 9.86686 1.97609 10.1834 2.17135 10.3787L4.64622 12.8536C4.75097 12.9583 4.89063 13.0069 5.02774 12.9992C5.15903 12.9921 5.27684 12.9342 5.36195 12.845L7.8282 10.3787C8.02347 10.1834 8.02347 9.86686 7.8282 9.67159C7.63294 9.47633 7.31636 9.47633 7.1211 9.67159L5.5 11.2927V3.5ZM11.3698 3.16295C11.2784 3.06282 11.1468 3 11.0005 3C10.9947 3 10.989 3.0001 10.9832 3.00029C10.8608 3.00432 10.7396 3.05304 10.6462 3.14647L8.17135 5.62134C7.97609 5.8166 7.97609 6.13319 8.17135 6.32845C8.36661 6.52371 8.68319 6.52371 8.87846 6.32845L10.5005 4.70641V12.5C10.5005 12.7761 10.7243 13 11.0005 13C11.2766 13 11.5005 12.7761 11.5005 12.5V4.70784L13.1211 6.32845C13.3164 6.52371 13.6329 6.52371 13.8282 6.32845C14.0235 6.13319 14.0235 5.8166 13.8282 5.62134L11.3698 3.16295Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
:class="{
|
||||
'bg-red-500': priority === 'High',
|
||||
'bg-yellow-500': priority === 'Medium',
|
||||
'bg-gray-300': priority === 'Low',
|
||||
'bg-surface-gray-4': priority === 'Low',
|
||||
}, $attrs.class"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
class="text-gray-700"
|
||||
class="text-ink-gray-7"
|
||||
:aria-label="status"
|
||||
>
|
||||
<path
|
||||
|
||||