Merge pull request #479 from frappe/develop

chore: Merge develop to main
This commit is contained in:
Shariq Ansari 2024-12-19 17:53:58 +05:30 committed by GitHub
commit 5a97fe952c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
183 changed files with 4751 additions and 5821 deletions

4
.github/logo.svg vendored Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

BIN
.github/screenshots/EmailTemplate.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
.github/screenshots/LeadList.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 KiB

After

Width:  |  Height:  |  Size: 970 KiB

66
.github/workflows/builds.yml vendored Normal file
View 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
View File

@ -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**
![GitHub release (latest by date)](https://img.shields.io/github/v/release/frappe/crm)
<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 thats 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>

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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>

View File

@ -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");
}

View File

@ -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) {

View File

@ -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

View File

@ -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,

View File

@ -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"

View File

@ -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({

View File

@ -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,
})

View 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>

View File

@ -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({

View File

@ -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'

View File

@ -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'

View File

@ -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'

View File

@ -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>

View File

@ -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,

View File

@ -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"
>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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 })

View File

@ -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
},
})

View File

@ -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>

View File

@ -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>

View 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>

View 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>

View File

@ -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>

View File

@ -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
}

View File

@ -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])

View 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)"

View File

@ -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()"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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

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