mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-26 12:28:35 +00:00
Compare commits
1 Commits
chore/test
...
js_skip_te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
28ce2958fa |
@@ -1,12 +0,0 @@
|
||||
reviews:
|
||||
auto_review:
|
||||
ignore_title_keywords:
|
||||
- "sync translations"
|
||||
- "update POT file"
|
||||
- "style: "
|
||||
review_status: false
|
||||
poem: false
|
||||
collapse_walkthrough: true
|
||||
sequence_diagrams: false
|
||||
changed_files_summary: false
|
||||
high_level_summary: false
|
||||
@@ -9,13 +9,6 @@ trim_trailing_whitespace = true
|
||||
charset = utf-8
|
||||
|
||||
# python, js indentation settings
|
||||
[{*.py,*.js,*.vue,*.css,*.scss,*.html}]
|
||||
[{*.py,*.js}]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
max_line_length = 110
|
||||
|
||||
# JSON files - mostly doctype schema files
|
||||
[{*.json}]
|
||||
insert_final_newline = false
|
||||
indent_style = space
|
||||
indent_size = 1
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
"beforeEach": true,
|
||||
"onScan": true,
|
||||
"extend_cscript": true,
|
||||
"localforage": true,
|
||||
"Plaid": true
|
||||
"localforage": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,22 +32,3 @@ baec607ff5905b1c67531096a9cf50ec7ff00a5d
|
||||
|
||||
# bulk refactor with sourcery
|
||||
eb9ee3f79b94e594fc6dfa4f6514580e125eee8c
|
||||
|
||||
# js formatting
|
||||
ec74a5e56617bbd76ac402451468fd4668af543d
|
||||
|
||||
# ruff formatting
|
||||
a308792ee7fda18a681e9181f4fd00b36385bc23
|
||||
|
||||
# noisy typing refactoring of get_item_details
|
||||
7b7211ac79c248a79ba8a999ff34e734d874c0ae
|
||||
d827ed21adc7b36047e247cbb0dc6388d048a7f9
|
||||
|
||||
# `frappe.flags.in_test` => `frappe.in_test`
|
||||
7a482a69985c952de0e8193c9d4e086aee65ee6d
|
||||
|
||||
# these commits actually changed something valuable
|
||||
# but they have a lot of whitespace changes that make blame noisy
|
||||
# PR: https://github.com/frappe/erpnext/pull/49816
|
||||
3ffd50c772735877b330d010c1058f623da8721d
|
||||
0e8f8677b8eb31e7834f72d1c6314d3c3f392ca6
|
||||
|
||||
70
.github/CONTRIBUTING.md
vendored
70
.github/CONTRIBUTING.md
vendored
@@ -1,70 +1,36 @@
|
||||
### Introduction (For First-Time Contributors)
|
||||
### Introduction (first timers)
|
||||
|
||||
Thank you for your interest in raising an issue with ERPNext. An issue can be either a bug report or a feature request.
|
||||
Thank you for your interest in raising an Issue with ERPNext. An Issue could mean a bug report or a request for a missing feature. By raising a bug report, you are contributing to the development of ERPNext and this is the first step of participating in the community. Bug reports are very helpful for developers as they quickly fix the issue before other users start facing it.
|
||||
|
||||
By reporting bugs, you contribute directly to improving ERPNext. Bug reports help developers identify and fix issues quickly before they affect more users.
|
||||
Feature requests are also a great way to take the product forward. New ideas can come in any user scenario and the issue list also acts a roadmap of future features.
|
||||
|
||||
Feature requests are also valuable. They help shape the future of the product by introducing new ideas and improvements based on real-world use cases.
|
||||
When you are raising an Issue, you should keep a few things in mind. Remember that the developer does not have access to your machine so you must give all the information you can while raising an Issue. If you are suggesting a feature, you should be very clear about what you want.
|
||||
|
||||
When raising an issue, keep in mind that developers do not have access to your environment. Therefore, provide as much relevant information as possible.
|
||||
|
||||
If you are suggesting a feature, clearly describe what you expect and how it should behave.
|
||||
|
||||
> ⚠️ The issue tracker is not the right place for general questions or discussions.
|
||||
> Please use the forum instead: https://discuss.frappe.io/c/erpnext/6
|
||||
|
||||
---
|
||||
The Issue list is not the right place to ask a question or start a general discussion. If you want to do that , then the right place is the forum [https://discuss.erpnext.com](https://discuss.erpnext.com).
|
||||
|
||||
### Reply and Closing Policy
|
||||
|
||||
If your issue is unclear or does not meet the guidelines, it may be closed.
|
||||
|
||||
If that happens, please provide the requested information and reopen the issue.
|
||||
|
||||
---
|
||||
If your issue is not clear or does not meet the guidelines, then it will be closed. If it is closed, please supply the information asked and re-open it.
|
||||
|
||||
### General Issue Guidelines
|
||||
|
||||
1. **Search existing issues:**
|
||||
Before creating a new issue, check if it already exists. You can support existing issues with a 👍 or contribute additional details or mockups.
|
||||
|
||||
2. **Report issues separately:**
|
||||
Do not combine multiple unrelated issues into a single report.
|
||||
|
||||
3. **Be concise:**
|
||||
Avoid long explanations. Use bullet points and screenshots where possible.
|
||||
|
||||
---
|
||||
1. **Search existing Issues:** Before raising a Issue, search if it has been raised before. Maybe add a 👍 or give additional help by creating a mockup if it is not already created.
|
||||
1. **Report each issue separately:** Don't club multiple, unreleated issues in one note.
|
||||
1. **Brief:** Please don't include long explanations. Use screenshots and bullet points instead of descriptive paragraphs.
|
||||
|
||||
### Bug Report Guidelines
|
||||
|
||||
1. **Steps to reproduce:**
|
||||
Clearly list the steps required to reproduce the issue. If the issue cannot be reproduced, it cannot be fixed.
|
||||
|
||||
2. **Version number:**
|
||||
Include the ERPNext version. The issue may already be fixed in a newer release.
|
||||
|
||||
3. **Clear title:**
|
||||
Use a descriptive title (e.g., "Unable to submit Purchase Order without Basic Rate" instead of "Cannot submit").
|
||||
|
||||
4. **Screenshots:**
|
||||
Add screenshots or screen recordings (e.g., `.gif`) to illustrate the issue.
|
||||
|
||||
---
|
||||
1. **Steps to Reproduce:** The bug report must have a list of steps needed to reproduce a bug. If we cannot reproduce it, then we cannot solve it.
|
||||
1. **Version Number:** Please add the version number in your report. Often a bug is fixed in the latest version
|
||||
1. **Clear Title:** Add a clear subject to your bug report like "Unable to submit Purchase Order without Basic Rate" instead of just "Cannot Submit"
|
||||
1. **Screenshots:** Screenshots are a great way of communicating issues. Try adding annotations or using LiceCAP to take a screencast in `gif`.
|
||||
|
||||
### Feature Request Guidelines
|
||||
|
||||
1. **Clarity:**
|
||||
Clearly describe the expected behavior. Avoid vague statements.
|
||||
1. **Clarity:** Clearly specify how do you want the feature to behave. Don't just say "I would like multiple PDF formats", say that "Ability to add multiple print formats for customers with different languages".
|
||||
1. **Solution:** Try and identify how the feature should look like.
|
||||
1. **Mockups:** Mockups are a great way to explain your requirement.
|
||||
|
||||
2. **Proposed solution:**
|
||||
Suggest how the feature should work.
|
||||
### What if my Issue is closed
|
||||
|
||||
3. **Mockups:**
|
||||
Provide mockups or examples whenever possible.
|
||||
|
||||
---
|
||||
|
||||
### What if my issue is closed?
|
||||
|
||||
Don't worry. Review the feedback, provide the required information, and reopen the issue.
|
||||
Don't worry, take the feedback, supply the correct information and re-open it!
|
||||
|
||||
4
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
4
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -9,7 +9,7 @@ body:
|
||||
Welcome to ERPNext issue tracker! Before creating an issue, please heed the following:
|
||||
|
||||
1. This tracker should only be used to report bugs and request features / enhancements to ERPNext
|
||||
- For questions and general support, checkout the [user manual](https://docs.erpnext.com/) or use [forum](https://discuss.frappe.io/c/erpnext/6)
|
||||
- For questions and general support, checkout the [user manual](https://docs.erpnext.com/) or use [forum](https://discuss.erpnext.com)
|
||||
- For documentation issues, propose edit on [documentation site](https://docs.erpnext.com/) directly.
|
||||
2. When making a bug report, make sure you provide all required information. The easier it is for
|
||||
maintainers to reproduce, the faster it'll be fixed.
|
||||
@@ -60,7 +60,7 @@ body:
|
||||
description: Share exact version number of Frappe and ERPNext you are using.
|
||||
placeholder: |
|
||||
Frappe version -
|
||||
ERPNext version -
|
||||
ERPNext Verion -
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/config.yml
vendored
2
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,5 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Community Forum
|
||||
url: https://discuss.frappe.io/c/erpnext/6
|
||||
url: https://discuss.erpnext.com/
|
||||
about: For general QnA, discussions and community help.
|
||||
|
||||
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
28
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea or enhancement for ERPNext
|
||||
about: Suggest an idea to improve ERPNext
|
||||
title: ''
|
||||
labels: feature-request
|
||||
assignees: ''
|
||||
@@ -11,27 +11,23 @@ assignees: ''
|
||||
Welcome to ERPNext issue tracker! Before creating an issue, please heed the following:
|
||||
|
||||
1. This tracker should only be used to report bugs and request features / enhancements to ERPNext
|
||||
- For questions and general support, checkout the manual https://docs.erpnext.com or use https://discuss.frappe.io/c/erpnext/6
|
||||
- For questions and general support, checkout the manual https://erpnext.com/docs/user/manual/en or use https://discuss.erpnext.com
|
||||
2. Use the search function before creating a new issue. Duplicates will be closed and directed to
|
||||
the original discussion.
|
||||
3. When making a feature request, make sure to be as verbose as possible. The better you convey your message, the greater the drive to make it happen.
|
||||
|
||||
|
||||
Please keep in mind that we get many requests and we can't possibly work on all of them, we prioritize development based on the goals of the product and organization. Feature requests are still welcome as it helps us in research when we do decide to work on the requested feature.
|
||||
Please keep in mind that we get many many requests and we can't possibly work on all of them, we prioritize development based on the goals of the product and organization. Feature requests are still welcome as it helps us in research when we do decide to work on the requested feature.
|
||||
|
||||
If you're in urgent need of a feature, please try the following channels to get paid developments done quickly:
|
||||
If you're in urgent need to a feature, please try the following channels to get paid developments done quickly:
|
||||
1. Certified ERPNext partners: https://erpnext.com/partners
|
||||
2. Developer community on ERPNext forums: https://discuss.frappe.io/c/framework/5
|
||||
2. Developer community on ERPNext forums: https://discuss.erpnext.com/c/developers/5
|
||||
3. Telegram group for ERPNext/Frappe development work: https://t.me/erpnext_opps
|
||||
|
||||
-->
|
||||
## Before Submitting
|
||||
- [ ] I searched existing issues and confirmed this is not a duplicate
|
||||
- [ ] This is a feature request, not a bug or support question
|
||||
- [ ] For support: https://discuss.frappe.io/c/erpnext/6
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. As a [role], I have to [painful task] because [missing feature].
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
@@ -39,17 +35,5 @@ A clear and concise description of what you want to happen.
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Impact**
|
||||
<!-- Check one: -->
|
||||
- [ ] Blocks critical workflow — no viable workaround
|
||||
- [ ] Significant friction — workaround exists but is painful
|
||||
- [ ] Nice to have — minor improvement
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
**Environment**
|
||||
- ERPNext Version: <!-- Find this in Help > About, e.g. v15.12.0 -->
|
||||
- Frappe Version: <!-- Find this in Help > About, e.g. v15.10.0 -->
|
||||
- Deployment: <!-- Frappe Cloud / Self-hosted / ERPNext Cloud -->
|
||||
|
||||
|
||||
183
.github/POSTGRES_COMPATIBILITY.md
vendored
183
.github/POSTGRES_COMPATIBILITY.md
vendored
@@ -1,183 +0,0 @@
|
||||
# PostgreSQL compatibility — review guide
|
||||
|
||||
ERPNext targets **both MariaDB and PostgreSQL from a single codebase**. The full server
|
||||
test suite passes on both, but the PostgreSQL CI job is **label-gated** (it does not run on
|
||||
every PR), so until it is required this guide is the always-on guard. Greptile loads it as
|
||||
review context (`.greptile/config.json`).
|
||||
|
||||
When reviewing a PR, flag any **new or changed query** (raw `frappe.db.sql`, `frappe.qb`,
|
||||
`frappe.get_all/get_list/get_value`, report SQL) that would **error on PostgreSQL** or
|
||||
**return different results on the two engines**.
|
||||
|
||||
## The one rule that governs everything
|
||||
|
||||
**MariaDB behaviour must not change; PostgreSQL is brought into line with MariaDB — never the
|
||||
reverse.** A "fix" that changes the value, row count, or ordering MariaDB produced is a
|
||||
regression, even if the new behaviour looks more correct. The only accepted MariaDB-output
|
||||
change is replacing a genuinely *undefined/arbitrary* result with a deterministic one (row
|
||||
count preserved) — and that should be called out explicitly.
|
||||
|
||||
There are two failure modes to watch for:
|
||||
1. **Hard breaks** — PostgreSQL raises an exception; MariaDB is green. Easy to catch in CI,
|
||||
but the gated job may not run.
|
||||
2. **Silent divergences** — both engines succeed but return *different* results. CI on one
|
||||
engine stays green; the bug only shows on a PostgreSQL site. These are the dangerous ones.
|
||||
|
||||
---
|
||||
|
||||
## 1. Hard breaks — would error on PostgreSQL
|
||||
|
||||
Flag a changed query that uses any of these:
|
||||
|
||||
- **Loose `GROUP BY`** — selecting/ordering a column that is neither in `GROUP BY` nor wrapped
|
||||
in an aggregate. MariaDB tolerates it; PostgreSQL errors (`must appear in the GROUP BY
|
||||
clause or be used in an aggregate function`). This **also covers an aggregate (`Sum`/`Count`/…)
|
||||
selected alongside bare columns with NO `.groupby()` at all** — MariaDB silently collapses
|
||||
every row into one arbitrary-valued row (often a *wrong-output* bug there too), PostgreSQL
|
||||
errors. Fix: add the bare column to `GROUP BY` **if it is functionally dependent on the group
|
||||
key**, otherwise wrap it in `Max()`/`Min()`. **See §3 — the row-count trap — before suggesting
|
||||
"add it to GROUP BY".**
|
||||
- **MySQL-only functions** — `TIMESTAMP(date,time)`, `TIMEDIFF`, `STR_TO_DATE`, `DATE_FORMAT`,
|
||||
`DATE_ADD/SUB`, `GROUP_CONCAT`, `PERIOD_DIFF`, SQL `IF(cond,a,b)`. Use the portable
|
||||
`frappe.query_builder.functions` equivalents (`CombineDatetime`, `DateDiff`, `Case`,
|
||||
`GroupConcat`, …) or a precomputed column (e.g. `posting_datetime`).
|
||||
- **`UPDATE … JOIN`** — not valid on PostgreSQL. Rewrite as `UPDATE … WHERE name IN (subquery)`.
|
||||
- **`HAVING` referencing a `SELECT` alias** — PostgreSQL rejects output-column aliases in
|
||||
`HAVING` (regardless of whether the query has a `GROUP BY`; MariaDB allows them). Repeat the
|
||||
underlying expression in `HAVING`, or move a non-aggregate predicate into `WHERE`.
|
||||
- **`SELECT DISTINCT … ORDER BY <expr not in the select list>`** — add the expr to the select.
|
||||
- **Single-quoted column alias** `AS 'x'` — PostgreSQL reads `'x'` as a string literal. Use an
|
||||
unquoted (or double-quoted) alias.
|
||||
- **`varchar | varchar`** (bitwise OR misused as a coalesce) — errors on PostgreSQL. Use
|
||||
`Coalesce(...)`.
|
||||
- **Capital-cased identifiers** used as column/field names in `get_value(dt, dn, "Status")`,
|
||||
`get_all(dt, fields=["Account"])`, and similar — PostgreSQL quotes the identifier and matches
|
||||
it case-sensitively; a stored column named `status`/`account` won't match `"Status"`/`"Account"`
|
||||
(`column "Account" does not exist`). Use the exact stored (lower-case) fieldname.
|
||||
- **Boolean passed where an integer column is expected** — `frappe.db.set_value(dt, dn,
|
||||
check_field, True)`, `doc.db_set(field, False)`, or `frappe.qb.update(dt).set(check_field, True)`
|
||||
emit `SET col = true`, which PostgreSQL rejects on a `smallint`/`Check` column
|
||||
(`column is of type smallint but expression is of type boolean`). Pass `1`/`0`.
|
||||
- **`.like()`/`.ilike()` (or raw `LIKE`) on a NON-text column** — `idx`, `docstatus`, a date, etc.
|
||||
frappe maps `.like()` → `ILIKE`, and PostgreSQL has no `bigint ILIKE text` operator (`operator
|
||||
does not exist: bigint ~~* unknown`). Cast the column to text first — **`Cast_(col, "varchar")`**,
|
||||
not `Cast(col, "char")` (see below). MariaDB coerces the int implicitly, so the cast is a no-op there.
|
||||
- **`CAST(… AS CHAR)` / `Cast(x, "char")`** — on PostgreSQL bare `CHAR` is `character(1)`, so
|
||||
`CAST(12 AS CHAR)` → `'1'` (silently truncates multi-digit values); MariaDB gives the full string.
|
||||
Use `VARCHAR` / `Cast_(x, "varchar")`.
|
||||
- **`.rlike()` / raw `RLIKE`** — frappe rewrites `REGEXP` → `~*` on PostgreSQL but does **not**
|
||||
translate `RLIKE` (no such PostgreSQL operator). Use `.regexp()` (or `.like()` for a simple prefix).
|
||||
- **`IfNull`/`Coalesce` of a typed column with a different-typed literal** — `IfNull(asset.disposal_date, 0)`
|
||||
renders `COALESCE("disposal_date", 0)`, coalescing a **DATE** with an **integer**. PostgreSQL requires
|
||||
`COALESCE` args to share a type (`DatatypeMismatch: COALESCE types date and integer cannot be matched`);
|
||||
MariaDB's `IFNULL` is permissive. The common shape is `IfNull(date_col, 0) != 0 / == 0` as a presence test —
|
||||
replace with `date_col.isnotnull()` / `date_col.isnull()` (identical, and valid on both). Otherwise coalesce
|
||||
to a **same-type** default (`Coalesce(date_col, '1900-01-01')`, `Coalesce(text_col, '')`).
|
||||
- **Division by a possibly-zero divisor** — `Sum(a) / Sum(b)`, `x / col`, etc. where the
|
||||
divisor can be `0`/empty. MariaDB returns `NULL` for division by zero; PostgreSQL raises
|
||||
`division by zero` and aborts the query. Wrap the divisor in `NullIf(divisor, 0)` — that
|
||||
yields `NULL` on both engines, matching MariaDB's value. (Only the *literal* `/ 0` is a parse
|
||||
constant; the trap is a divisor that is an aggregate or column the data can drive to zero.)
|
||||
|
||||
---
|
||||
|
||||
## 2. Silent divergences — succeeds on both, returns different results
|
||||
|
||||
These don't error, so a one-engine CI stays green. Flag them:
|
||||
|
||||
- **Case sensitivity on text equality** — `==`, `.isin()`, `Strpos`/`Locate` on free-text
|
||||
columns are case-**sensitive** on PostgreSQL but case-**insensitive** under MariaDB's default
|
||||
collation. `Lower()` both sides. *(Not `.like()`/`["like", …]` — those already render as
|
||||
`ILIKE` on PostgreSQL; see §4.)*
|
||||
- **Case sensitivity in a doc-`name` lookup** — lower-casing a value then using it as a
|
||||
document name in `get_value`/`get_doc`/`exists` misses on PostgreSQL (names are
|
||||
case-sensitive). Keep original case for the identifier; lower-case only comparison operands.
|
||||
- **Empty string vs NULL** — PostgreSQL stores a blank link/data field as `NULL` on some paths
|
||||
while MariaDB keeps `''`; `Concat`/`Concat_ws` then diverge. Prefer the stored full value, or
|
||||
`Coalesce(col, '')` per argument.
|
||||
- **NULL ordering** — MariaDB sorts `NULL` first, PostgreSQL sorts it last. For
|
||||
`ORDER BY … LIMIT 1`/`[0]` on a nullable column, guard with `Coalesce`/`isnotnull()`.
|
||||
- **`ORDER BY … LIMIT 1` with no unique tiebreaker** — when rows tie on the ordered column the
|
||||
two engines may pick different rows. Add a `creation`/`name` tiebreaker **only if it does not
|
||||
change MariaDB's current pick** (see §4).
|
||||
- **Integer division** — `int / int` truncates on PostgreSQL but is decimal on MariaDB, e.g.
|
||||
`COUNT(...) / COUNT(...) * 100` → `0`, or `manufacturing_time_in_mins / 1440` flooring a
|
||||
lead-time to whole days. Force float: multiply by `100.0`, or make a literal a float
|
||||
(`/ 1440` → `/ 1440.0`), or cast an operand. (Only SQL-level `/` on integer **columns/literals**
|
||||
— Python `/` is already float.)
|
||||
- **`DISTINCT` list ordering** — `frappe.get_all(distinct=True, order_by=…)` /
|
||||
`SELECT DISTINCT … ORDER BY`: frappe's `db_query` **silently drops `ORDER BY` for distinct
|
||||
queries on PostgreSQL**, so the result is unordered there. Sort in Python instead — and use
|
||||
`key=str.casefold`, because bare `sorted()` is case-sensitive (ASCII) while MariaDB's
|
||||
collation is case-insensitive, so a plain sort reorders MariaDB's output.
|
||||
- **Engine-specific function rewrites** — e.g. a PostgreSQL `regexp_replace` branch
|
||||
reimplementing MariaDB's `CAST(SUBSTRING_INDEX(name,' ',-1) AS UNSIGNED)` (leading digits of
|
||||
the last whitespace token). Verify the rewrite matches MariaDB on edge cases (`"X - 3a"→3`,
|
||||
`"X - 1.5"→1`) by diffing both engines on literal rows.
|
||||
- **`UnixTimestamp(date)` / date→epoch** is timezone-dependent (midnight in the DB session TZ),
|
||||
so a strict `epoch <= now` bound is flaky on PostgreSQL.
|
||||
|
||||
---
|
||||
|
||||
## 3. The `GROUP BY` row-count trap (the single most important rule)
|
||||
|
||||
When making a loose `GROUP BY` PostgreSQL-valid, **do not add a non-functionally-dependent
|
||||
column to the `GROUP BY` just to satisfy PostgreSQL** — that turns one group row into N and
|
||||
**changes the MariaDB row count** (a regression). The classic traps are adding the **child/row
|
||||
primary key** or an **editable per-row field**. Instead **`Max()`/`Min()`-wrap** the offending
|
||||
column: the row count is preserved and the value goes from arbitrary (MariaDB's old loose pick)
|
||||
to deterministic.
|
||||
|
||||
**Judge functional dependence by the source table, not the column name:**
|
||||
- A column from a **master joined on the group key** (`t3.x` where `t1.key = t3.name`) is FD →
|
||||
safe to keep in `GROUP BY`.
|
||||
- A descriptive field on the **transaction** table (`t1.supplier_name`, `t1.territory`,
|
||||
`t1.item_name` — fetched/editable, can differ across historical rows for the same key) is
|
||||
**not** FD even though it looks master-derived → `Max()`-wrap it.
|
||||
|
||||
Conversely, do **not** suggest changing a `Max()`/`Min()`-wrapped column to `Sum()` (or vice
|
||||
versa) to make a number "more correct" — that changes the MariaDB value. The wrap reproduces
|
||||
MariaDB's prior one-value-per-group output; a different aggregate is a product change, out of
|
||||
scope for a portability fix.
|
||||
|
||||
---
|
||||
|
||||
## 4. False positives — do NOT flag these
|
||||
|
||||
These are auto-handled by the framework and are **not** breaks:
|
||||
|
||||
- **`.like()` / `["like", …]`** already renders as `ILIKE` on PostgreSQL — not a
|
||||
case-sensitivity bug. *(Exception: `.like()` on a **non-text** column — `idx`, `docstatus` —
|
||||
is a hard break, `bigint ILIKE`; see §1.)*
|
||||
- **Raw `ifnull(...)`** inside `frappe.db.sql()` is rewritten to `coalesce(...)` on all engines.
|
||||
- **Backticks**, **`LOCATE`**, **`REGEXP`** / **`.regexp()`** in raw SQL are auto-translated on
|
||||
PostgreSQL (`REGEXP` → `~*`). **But `RLIKE` / `.rlike()` is NOT translated** — that one is a
|
||||
hard break (see §1).
|
||||
- **An `ORDER BY … LIMIT 1` tie where the two engines already agree**, or where adding a
|
||||
tiebreaker would *change* MariaDB's current pick — leave it; "fixing" it would either change
|
||||
MariaDB or has no observable effect.
|
||||
|
||||
---
|
||||
|
||||
## 5. Transaction / runtime (not query-shape, still PostgreSQL-only)
|
||||
|
||||
- **Catch-and-continue inserts** — on PostgreSQL a failed `insert()` aborts the **whole
|
||||
transaction**, so code that swallows a duplicate and keeps going dies on the next statement
|
||||
with `InFailedSqlTransaction` (frappe dropped its blanket per-statement savepoint in
|
||||
frappe#40075). Such a handler must wrap the fallible insert in `frappe.db.savepoint(name)` +
|
||||
`rollback(save_point=name)` — unless it re-`throw`s with no DB call before the throw, or the
|
||||
insert uses `ignore_if_duplicate=True` / `autoname="hash"` (→ `ON CONFLICT DO NOTHING`).
|
||||
|
||||
---
|
||||
|
||||
## How to review
|
||||
|
||||
For every changed query: does it (a) use a construct from §1 (would error on PostgreSQL), or
|
||||
(b) match a divergence in §2/§3 (different result across engines)? If so, comment with the
|
||||
portable fix and confirm it leaves **MariaDB output unchanged**. Skip the §4 false positives.
|
||||
Prefer a comment that names the rule (e.g. "loose GROUP BY — Max()-wrap, don't add to GROUP BY:
|
||||
splits the row count") so the fix is unambiguous.
|
||||
|
||||
The static pre-commit checker (`.github/helper/postgres_compat.py`) catches the *mechanical*
|
||||
§1 breaks; the **semantic** §2/§3 divergences are exactly what a reviewer (and this guide) must
|
||||
cover, because no static check can see them.
|
||||
16
.github/helper/documentation.py
vendored
16
.github/helper/documentation.py
vendored
@@ -1,7 +1,7 @@
|
||||
import sys
|
||||
import requests
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
WEBSITE_REPOS = [
|
||||
"erpnext_com",
|
||||
@@ -10,7 +10,6 @@ WEBSITE_REPOS = [
|
||||
|
||||
DOCUMENTATION_DOMAINS = [
|
||||
"docs.erpnext.com",
|
||||
"docs.frappe.io",
|
||||
"frappeframework.com",
|
||||
]
|
||||
|
||||
@@ -37,7 +36,11 @@ def is_documentation_link(word: str) -> bool:
|
||||
|
||||
|
||||
def contains_documentation_link(body: str) -> bool:
|
||||
return any(is_documentation_link(word) for line in body.splitlines() for word in line.split())
|
||||
return any(
|
||||
is_documentation_link(word)
|
||||
for line in body.splitlines()
|
||||
for word in line.split()
|
||||
)
|
||||
|
||||
|
||||
def check_pull_request(number: str) -> "tuple[int, str]":
|
||||
@@ -50,7 +53,12 @@ def check_pull_request(number: str) -> "tuple[int, str]":
|
||||
head_sha = (payload.get("head") or {}).get("sha")
|
||||
body = (payload.get("body") or "").lower()
|
||||
|
||||
if not title.startswith("feat") or not head_sha or "no-docs" in body or "backport" in body:
|
||||
if (
|
||||
not title.startswith("feat")
|
||||
or not head_sha
|
||||
or "no-docs" in body
|
||||
or "backport" in body
|
||||
):
|
||||
return 0, "Skipping documentation checks... 🏃"
|
||||
|
||||
if contains_documentation_link(body):
|
||||
|
||||
72
.github/helper/hydrate.sh
vendored
72
.github/helper/hydrate.sh
vendored
@@ -1,72 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Hydrate a test shard from the setup job's artifact.
|
||||
#
|
||||
# The bench (apps, venv, node_modules, sites) is already on disk at ~/frappe-bench — the
|
||||
# workflow untar'd it from the artifact the setup job built. So there is NO bench init, no
|
||||
# asset build, and no reinstall here: just bring the DB up on the baked datadir and start redis
|
||||
# so tests can run. The whole point is that the expensive work happened ONCE in the setup job.
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
db_host="${DB_HOST:-127.0.0.1}"
|
||||
|
||||
# Re-exec as the ci user (uid 1001) so bench/cache ownership matches the artifact, same as
|
||||
# install.sh. The workflow untar'd as root with -p, so the files are already owned by ci.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' DB_HOST='$db_host' DB='${DB:-}' bash '$0'"
|
||||
fi
|
||||
|
||||
cd ~/frappe-bench
|
||||
|
||||
# Start the DB on the datadir baked into the artifact. It's already populated (the setup job
|
||||
# reinstalled into this very datadir), so there is NO restore — the server comes up on the
|
||||
# existing files. This is what replaces the per-shard SQL replay.
|
||||
bash ~/frappe-bench/start-db.sh
|
||||
|
||||
# Bring up redis (lightmode unit tests need cache + queue). In the self-hosted container we use the
|
||||
# full `bench start` (web/workers too, like install.sh). On the bare GitHub Postgres shard
|
||||
# `bench start` (honcho) lagged — it blocks the redis procs behind web/worker procs the lightmode
|
||||
# suite never uses, so the wait below burned its full timeout (~4m). There, start the two redis
|
||||
# instances directly: fast and deterministic.
|
||||
if [ "${DB:-mariadb}" = "postgres" ]; then
|
||||
# Start redis directly as daemons — reliable and persists across steps. Do NOT route it through
|
||||
# `bench start`: honcho tears the whole process group down if any one Procfile proc dies on the
|
||||
# bare shard, which took redis with it (redis @ 13000 refused in Run Tests). Keeping redis
|
||||
# independent is what makes it survive. The web server (for PDF tests) is NOT started here — a
|
||||
# backgrounded server doesn't survive into the next step; it's started inside the Run Tests step.
|
||||
for conf in redis_cache redis_queue; do
|
||||
[ -f ~/frappe-bench/config/$conf.conf ] && redis-server ~/frappe-bench/config/$conf.conf --daemonize yes
|
||||
done
|
||||
else
|
||||
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
|
||||
fi
|
||||
|
||||
# Wait for redis, failing fast instead of silently burning minutes if it never comes up.
|
||||
cfg=~/frappe-bench/sites/common_site_config.json
|
||||
if [ -f "$cfg" ]; then
|
||||
ports=$(python - "$cfg" <<'PY'
|
||||
import json, re, sys
|
||||
try:
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for key in ("redis_cache", "redis_queue"):
|
||||
m = re.search(r":(\d+)", str(cfg.get(key, "")))
|
||||
if m:
|
||||
print(m.group(1))
|
||||
PY
|
||||
)
|
||||
for port in $ports; do
|
||||
up=0
|
||||
for _ in $(seq 1 60); do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then exec 3>&- 3<&-; up=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; exit 1; }
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Hydrated: DB up on baked datadir, redis up — ready for tests."
|
||||
370
.github/helper/install.sh
vendored
370
.github/helper/install.sh
vendored
@@ -4,360 +4,72 @@ set -e
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
sudo apt update
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client-10.6
|
||||
|
||||
pip install frappe-bench
|
||||
|
||||
githubbranch=${GITHUB_BASE_REF:-${GITHUB_REF##*/}}
|
||||
frappeuser=${FRAPPE_USER:-"frappe"}
|
||||
frappecommitish=${FRAPPE_BRANCH:-$githubbranch}
|
||||
db_host=${DB_HOST:-"127.0.0.1"}
|
||||
db_user_host=${DB_USER_HOST:-"localhost"}
|
||||
wkhtmltox_deb=${WKHTMLTOX_DEB:-"/tmp/wkhtmltox.deb"}
|
||||
bench_cache_dir=${BENCH_CACHE_DIR:-}
|
||||
frappebranch=${FRAPPE_BRANCH:-$githubbranch}
|
||||
|
||||
run_as_ci_user_if_needed() {
|
||||
if [ "$(id -u)" != "0" ] || [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ] || [ "${ERPNEXT_CI_NON_ROOT:-0}" = "1" ]; then
|
||||
return
|
||||
fi
|
||||
git clone "https://github.com/${frappeuser}/frappe" --branch "${frappebranch}" --depth 1
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
local missing_packages=()
|
||||
if ! command -v pkg-config >/dev/null 2>&1; then
|
||||
missing_packages+=("pkg-config")
|
||||
fi
|
||||
if ! command -v mariadb_config >/dev/null 2>&1 && ! command -v mysql_config >/dev/null 2>&1; then
|
||||
missing_packages+=("libmariadb-dev")
|
||||
fi
|
||||
if ! command -v crontab >/dev/null 2>&1; then
|
||||
missing_packages+=("cron")
|
||||
fi
|
||||
|
||||
if [ "${#missing_packages[@]}" -gt 0 ]; then
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends "${missing_packages[@]}"
|
||||
fi
|
||||
|
||||
local ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
if ! id "$ci_user" >/dev/null 2>&1; then
|
||||
useradd --home-dir "$HOME" --no-create-home --shell /bin/bash "$ci_user"
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
local ci_dirs=(
|
||||
"$HOME"
|
||||
"$GITHUB_WORKSPACE"
|
||||
"$HOME/.cache"
|
||||
"${PIP_CACHE_DIR:-$HOME/.cache/pip}"
|
||||
"${npm_config_cache:-$HOME/.npm}"
|
||||
"${YARN_CACHE_FOLDER:-$HOME/.cache/yarn}"
|
||||
"$HOME/.yarn"
|
||||
"${UV_CACHE_DIR:-$HOME/.cache/uv}"
|
||||
"$(dirname "$wkhtmltox_deb")"
|
||||
)
|
||||
if [ -n "$bench_cache_dir" ]; then
|
||||
ci_dirs+=("$bench_cache_dir")
|
||||
fi
|
||||
|
||||
# Create + own (non-recursively) the home/cache/workspace dirs before dropping to
|
||||
# the ci user. We deliberately do NOT wipe the yarn/uv caches here so a persistent
|
||||
# cache (mounted volume or baked image layer) stays warm across runs.
|
||||
mkdir -p "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
chown "$ci_user:$ci_user" "${ci_dirs[@]}" "$HOME/.yarn"
|
||||
|
||||
export ERPNEXT_CI_NON_ROOT=1
|
||||
exec su -m "$ci_user" -s /bin/bash -c "cd '$HOME' && bash '$GITHUB_WORKSPACE/.github/helper/install.sh'"
|
||||
}
|
||||
|
||||
run_as_ci_user_if_needed
|
||||
|
||||
run_ci_step() {
|
||||
local label=$1
|
||||
shift
|
||||
|
||||
echo "::group::${label}"
|
||||
date -u
|
||||
local exit_code=0
|
||||
timeout --foreground "${CI_INSTALL_STEP_TIMEOUT:-1800}" "$@" || exit_code=$?
|
||||
date -u
|
||||
echo "::endgroup::"
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
if [ -n "${GITHUB_WORKSPACE:-}" ]; then
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE" || true
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE/.git" || true
|
||||
fi
|
||||
|
||||
rm -rf ~/frappe ~/frappe-bench
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 1 — parallelise the three slow, independent setup steps:
|
||||
# a) system packages b) frappe-bench pip install c) frappe git fetch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if [ "${SKIP_SYSTEM_SETUP:-0}" != "1" ]; then
|
||||
sudo apt-get update
|
||||
|
||||
# apt remove/install must run sequentially but can overlap with pip and git.
|
||||
sudo apt-get remove -y mysql-server mysql-client
|
||||
sudo apt-get install -y libcups2-dev redis-server mariadb-client libmariadb-dev &
|
||||
apt_pid=$!
|
||||
|
||||
pip install frappe-bench &
|
||||
pip_pid=$!
|
||||
else
|
||||
apt_pid=
|
||||
pip_pid=
|
||||
fi
|
||||
|
||||
mkdir frappe
|
||||
(
|
||||
cd frappe
|
||||
git init
|
||||
git remote add origin "https://github.com/${frappeuser}/frappe"
|
||||
git fetch origin "${frappecommitish}" --depth 1
|
||||
) &
|
||||
clone_pid=$!
|
||||
|
||||
if [ -n "$apt_pid" ]; then wait $apt_pid; fi
|
||||
if [ -n "$pip_pid" ]; then wait $pip_pid; fi
|
||||
wait $clone_pid
|
||||
|
||||
pushd frappe
|
||||
git checkout FETCH_HEAD
|
||||
popd
|
||||
frappe_sha=$(git -C frappe rev-parse HEAD)
|
||||
|
||||
get_bench_cache_archive() {
|
||||
if [ -z "$bench_cache_dir" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
mkdir -p "$bench_cache_dir"
|
||||
|
||||
# Keyed on tool versions only (NOT the frappe SHA): any recent base bench works, because
|
||||
# restore_warm_bench fast-forwards it to the exact live develop SHA. This is what lets a
|
||||
# constantly-moving develop still hit the cache.
|
||||
local cache_key
|
||||
cache_key=$(
|
||||
{
|
||||
uname -m
|
||||
python --version
|
||||
node --version
|
||||
bench --version
|
||||
} | sha256sum | awk '{print $1}'
|
||||
)
|
||||
|
||||
echo "${bench_cache_dir}/frappe-bench-base-${cache_key}.tar.zst"
|
||||
}
|
||||
|
||||
restore_warm_bench() {
|
||||
bench_cache_archive=$(get_bench_cache_archive)
|
||||
[ -n "$bench_cache_archive" ] && [ -f "$bench_cache_archive" ] || return 1
|
||||
|
||||
echo "Restoring base bench from ${bench_cache_archive}"
|
||||
tar --use-compress-program=unzstd -xf "$bench_cache_archive" -C ~ || return 1
|
||||
[ -d ~/frappe-bench/apps/frappe/.git ] || return 1
|
||||
mkdir -p ~/frappe-bench/sites ~/frappe-bench/logs
|
||||
[ -f ~/frappe-bench/sites/apps.txt ] || printf "frappe\n" > ~/frappe-bench/sites/apps.txt
|
||||
[ -f ~/frappe-bench/sites/common_site_config.json ] || printf "{}\n" > ~/frappe-bench/sites/common_site_config.json
|
||||
|
||||
# Fast-forward the restored frappe to the EXACT live develop SHA fetched in phase 1, then
|
||||
# rebuild only what changed. The editable install means the venv tracks the new code with
|
||||
# no reinstall. Any failure returns non-zero so the caller falls back to a full bench init.
|
||||
if ! (
|
||||
cd ~/frappe-bench/apps/frappe || exit 1
|
||||
# Phase 1 already fetched ~/frappe to the exact live develop SHA. Fetch that commit
|
||||
# straight from it (bench init names the remote 'upstream', not 'origin', and points
|
||||
# it at this local clone — so a plain `git fetch origin` does not work).
|
||||
git fetch --no-tags "$HOME/frappe" HEAD || exit 1
|
||||
git checkout --force FETCH_HEAD || exit 1
|
||||
); then
|
||||
echo "Fast-forward to ${frappe_sha} failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Pick up any frappe dependency changes since the base was built (cached → fast if none),
|
||||
# so a develop commit that bumped requirements doesn't leave a stale venv.
|
||||
if ! ~/frappe-bench/env/bin/python -m pip install -q -e ~/frappe-bench/apps/frappe; then
|
||||
echo "frappe dependency refresh failed; falling back to full init"
|
||||
rm -rf ~/frappe-bench
|
||||
return 1
|
||||
fi
|
||||
|
||||
( cd ~/frappe-bench && CI=Yes bench build --app frappe ) || { rm -rf ~/frappe-bench; return 1; }
|
||||
return 0
|
||||
}
|
||||
|
||||
save_warm_bench() {
|
||||
if [ -z "${bench_cache_archive:-}" ] || [ -f "$bench_cache_archive" ]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [ -n "$bench_cache_dir" ] && [ ! -w "$bench_cache_dir" ]; then
|
||||
echo "Skipping warm bench save because ${bench_cache_dir} is not writable"
|
||||
return
|
||||
fi
|
||||
|
||||
local tmp_archive
|
||||
tmp_archive="${bench_cache_archive}.${$}.tmp"
|
||||
|
||||
echo "Saving warm bench to ${bench_cache_archive}"
|
||||
# Keep sites/common_site_config.json (the redis ports live there — dropping it makes the
|
||||
# restore path fall back to a default redis port that bench start never bound, so reinstall
|
||||
# fails with "redis ... connection refused"). Only the rebuildable sites/assets is excluded;
|
||||
# restore_warm_bench runs `bench build` to regenerate it.
|
||||
tar \
|
||||
--use-compress-program="zstd -T0 -3" \
|
||||
--exclude="frappe-bench/logs" \
|
||||
--exclude="frappe-bench/sites/assets" \
|
||||
-cf "$tmp_archive" \
|
||||
-C ~ frappe-bench
|
||||
mv "$tmp_archive" "$bench_cache_archive"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Phase 2 — bench init and site setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
install_whktml() {
|
||||
# Re-use the .deb if the wkhtmltopdf cache step already restored it.
|
||||
if [ ! -f "$wkhtmltox_deb" ]; then
|
||||
wget -O "$wkhtmltox_deb" https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
fi
|
||||
sudo apt-get install -y "$wkhtmltox_deb"
|
||||
}
|
||||
if [ "${SKIP_WKHTMLTOX_SETUP:-0}" != "1" ]; then
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
else
|
||||
wkpid=
|
||||
fi
|
||||
|
||||
if ! restore_warm_bench; then
|
||||
bench init --skip-assets --frappe-path ~/frappe --python "$(which python)" frappe-bench
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
CI=Yes bench build --app frappe
|
||||
save_warm_bench
|
||||
fi
|
||||
|
||||
if [ -n "$wkpid" ]; then wait $wkpid; fi
|
||||
|
||||
mkdir -p ~/frappe-bench/sites/test_site
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
if [ "$db_host" != "127.0.0.1" ]; then
|
||||
sed -i "s/\"db_host\": \"127.0.0.1\"/\"db_host\": \"${db_host}\"/" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
else
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_postgres.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
fi
|
||||
|
||||
|
||||
if [ "$DB" == "mariadb" ];then
|
||||
for _ in {1..60}; do
|
||||
if mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
mariadb-admin ping --host "$db_host" --port 3306 -u root -proot --silent
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL character_set_server = 'utf8mb4'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'"
|
||||
|
||||
# Throwaway-DB durability tuning at runtime. (innodb_doublewrite is read-only on MariaDB
|
||||
# 10.6, so it can't be disabled here — would need a server startup flag.)
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot \
|
||||
-e "SET GLOBAL innodb_flush_log_at_trx_commit=0; SET GLOBAL sync_binlog=0;"
|
||||
|
||||
# Opt-in DDL speedup: a shared tablespace avoids a create+fsync per DocType table during
|
||||
# reinstall — a big win under disk contention. But ROW_FORMAT=DYNAMIC must be accepted in
|
||||
# the system tablespace on this MariaDB. Enable with CI_INNODB_SHARED_TABLESPACE=1; if
|
||||
# reinstall then errors on table creation, unset it (off by default — zero risk).
|
||||
if [ "${CI_INNODB_SHARED_TABLESPACE:-0}" = "1" ]; then
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "SET GLOBAL innodb_file_per_table=0;"
|
||||
fi
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE USER 'test_frappe'@'${db_user_host}' IDENTIFIED BY 'test_frappe'"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "CREATE DATABASE test_frappe"
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'${db_user_host}'"
|
||||
|
||||
mariadb --host "$db_host" --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -proot -e "FLUSH PRIVILEGES"
|
||||
fi
|
||||
|
||||
if [ "$DB" == "postgres" ];then
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
|
||||
# Disposable CI DB: durability off for speed (postgres fsyncs every commit by default, which
|
||||
# dominates a commit-heavy suite). All reloadable, no restart. The postgres workflow runs a
|
||||
# service-container DB and never calls start-db.sh, so the flags must be applied here.
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -U postgres \
|
||||
-c "ALTER SYSTEM SET synchronous_commit = 'off'" \
|
||||
-c "ALTER SYSTEM SET fsync = 'off'" \
|
||||
-c "ALTER SYSTEM SET full_page_writes = 'off'" \
|
||||
-c "SELECT pg_reload_conf()";
|
||||
fi
|
||||
|
||||
|
||||
install_whktml() {
|
||||
if [ "$(lsb_release -rs)" = "22.04" ]; then
|
||||
wget -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
sudo apt install /tmp/wkhtmltox.deb
|
||||
else
|
||||
echo "Please update this script to support wkhtmltopdf for $(lsb_release -ds)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
install_whktml &
|
||||
wkpid=$!
|
||||
|
||||
|
||||
cd ~/frappe-bench || exit
|
||||
|
||||
run_ci_step "Get payments app" bench get-app payments --branch develop
|
||||
sed -i 's/watch:/# watch:/g' Procfile
|
||||
sed -i 's/schedule:/# schedule:/g' Procfile
|
||||
sed -i 's/socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/redis_socketio:/# redis_socketio:/g' Procfile
|
||||
|
||||
# Opt-in: skip building erpnext's frontend assets. Server tests don't need them, but PDF
|
||||
# tests (print formats) do — they pass only if the PDF renderer ignores missing assets.
|
||||
# Enable with CI_SKIP_ERPNEXT_ASSETS=1 to test; if PDF tests fail, unset it.
|
||||
erpnext_get_app_args=()
|
||||
if [ "${CI_SKIP_ERPNEXT_ASSETS:-0}" = "1" ]; then erpnext_get_app_args=(--skip-assets); fi
|
||||
run_ci_step "Get erpnext app" bench get-app erpnext "${GITHUB_WORKSPACE}" "${erpnext_get_app_args[@]}"
|
||||
bench get-app payments --branch ${githubbranch%"-hotfix"}
|
||||
bench get-app erpnext "${GITHUB_WORKSPACE}"
|
||||
|
||||
if [ "$TYPE" == "server" ]; then run_ci_step "Setup dev requirements" bench setup requirements --dev; fi
|
||||
if [ "$TYPE" == "server" ]; then bench setup requirements --dev; fi
|
||||
|
||||
bench start >> ~/frappe-bench/bench_start.log 2>&1 &
|
||||
wait $wkpid
|
||||
|
||||
# Under heavy concurrency, gunicorn's startup can delay redis coming up. reinstall and the
|
||||
# tests need redis, so wait for it (best-effort, bounded) instead of racing — contention
|
||||
# then slows the job rather than failing it.
|
||||
wait_for_redis() {
|
||||
local cfg=~/frappe-bench/sites/common_site_config.json
|
||||
[ -f "$cfg" ] || return 0
|
||||
local ports port
|
||||
ports=$(python - "$cfg" <<'PY'
|
||||
import json, re, sys
|
||||
try:
|
||||
cfg = json.load(open(sys.argv[1]))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
for key in ("redis_cache", "redis_queue"):
|
||||
match = re.search(r":(\d+)", str(cfg.get(key, "")))
|
||||
if match:
|
||||
print(match.group(1))
|
||||
PY
|
||||
)
|
||||
for port in $ports; do
|
||||
local up=0
|
||||
for _ in $(seq 1 120); do
|
||||
if (exec 3<>"/dev/tcp/127.0.0.1/$port") 2>/dev/null; then
|
||||
exec 3>&- 3<&-; up=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
# Fail clearly instead of letting reinstall die later on a vague socket-connection error
|
||||
# when redis never bound.
|
||||
[ "$up" = "1" ] || { echo "redis did not come up on port $port"; return 1; }
|
||||
done
|
||||
}
|
||||
wait_for_redis
|
||||
|
||||
# Site setup: build the schema (~1000 DocTypes) into the DB. This is the single-threaded-Python
|
||||
# bottleneck, but the fan-out amortises it — it runs once here in the setup job, and the test
|
||||
# shards start the DB on the baked datadir instead of repeating the reinstall.
|
||||
run_ci_step "Reinstall test site" bench --site test_site reinstall --yes
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
CI=Yes bench build --app frappe &
|
||||
bench --site test_site reinstall --yes
|
||||
|
||||
52
.github/helper/merge_po_files.py
vendored
52
.github/helper/merge_po_files.py
vendored
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Overlay develop's .po translations onto hotfix's .po files.
|
||||
|
||||
Called by sync_hotfix_translations.sh before `bench update-po-files`.
|
||||
Merge rules:
|
||||
a. msgid absent from develop → keep hotfix's existing msgstr
|
||||
b. language not yet in hotfix → copy file as-is (bench will filter to main.pot)
|
||||
c. msgid present in both → use develop's msgstr
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from babel.messages.pofile import read_po, write_po
|
||||
|
||||
DEVELOP = Path("/tmp/develop-po/erpnext/locale/")
|
||||
LOCALE = Path("./apps/erpnext/erpnext/locale/")
|
||||
|
||||
added = updated = 0
|
||||
|
||||
for src in sorted(DEVELOP.glob("*.po")):
|
||||
dst = LOCALE / src.name
|
||||
|
||||
with src.open("rb") as f:
|
||||
dev = read_po(f)
|
||||
|
||||
if not dst.exists():
|
||||
dev.revision_date = datetime.now(timezone.utc)
|
||||
with dst.open("wb") as f:
|
||||
write_po(f, dev)
|
||||
added += 1
|
||||
print(f" [new] {src.name}")
|
||||
continue
|
||||
|
||||
with dst.open("rb") as f:
|
||||
hf = read_po(f)
|
||||
|
||||
changes = 0
|
||||
for msg in hf:
|
||||
if msg.id and msg.id in dev and dev[msg.id].string and dev[msg.id].string != msg.string:
|
||||
msg.string = dev[msg.id].string
|
||||
changes += 1
|
||||
|
||||
if changes:
|
||||
hf.revision_date = datetime.now(timezone.utc)
|
||||
with dst.open("wb") as f:
|
||||
write_po(f, hf)
|
||||
updated += 1
|
||||
print(f" [updated] {src.name} ({changes} msgstr(s) from develop)")
|
||||
else:
|
||||
print(f" [no-op] {src.name}")
|
||||
|
||||
print(f"\n{added} new language(s), {updated} updated.")
|
||||
241
.github/helper/postgres_compat.py
vendored
241
.github/helper/postgres_compat.py
vendored
@@ -1,241 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Static guard against MySQL-only SQL that breaks on PostgreSQL.
|
||||
|
||||
The Postgres test job is label-gated, so it does not run on every PR. This pre-commit
|
||||
hook is the always-on first line of defence: it flags the *mechanical* Postgres breaks
|
||||
that static analysis can catch reliably with a low false-positive rate.
|
||||
|
||||
It deliberately does NOT try to catch the *semantic* divergences (loose GROUP BY,
|
||||
case-sensitive ==/IN, NULL ordering, ORDER BY ... LIMIT 1 tiebreakers, integer-division
|
||||
intent, division by a possibly-zero divisor, savepoint discipline) — those genuinely need
|
||||
the test suite or a human/Greptile reviewer. Run the full suite on a Postgres site for those.
|
||||
|
||||
Escape hatch: put `# pg-ok` anywhere on the offending statement's line span (e.g. on a
|
||||
`SHOW INDEX` query that lives inside an `if frappe.db.db_type == "mariadb":` branch).
|
||||
|
||||
Usage: postgres_compat.py <file.py> [<file.py> ...] (pre-commit passes staged files)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import re
|
||||
import sys
|
||||
|
||||
IGNORE = "pg-ok"
|
||||
|
||||
# Strings are only scanned for the patterns below when they have real SQL *structure*
|
||||
# (not just an English word like "select" or "from"), to keep false positives near zero.
|
||||
SQL_HINT = re.compile(
|
||||
r"\bselect\b[\s\S]{0,800}\bfrom\b" # SELECT ... FROM
|
||||
r"|\bupdate\b[\s\S]{0,400}\bset\b" # UPDATE ... SET
|
||||
r"|\bdelete\s+from\b"
|
||||
r"|\binsert\s+into\b"
|
||||
r"|\bshow\s+(?:index|tables|columns)\b"
|
||||
r"|\bfrom\s+[\"'`]?tab", # FROM `tabDocType`
|
||||
re.I,
|
||||
)
|
||||
|
||||
# MySQL-only constructs with NO frappe auto-translation. (frappe.db.sql already rewrites
|
||||
# ifnull->coalesce on all engines and backtick/locate/REGEXP on Postgres, and .like()
|
||||
# renders ILIKE — so those are NOT listed here; flagging them would be false positives.)
|
||||
SQL_PATTERNS: list[tuple[re.Pattern, str]] = [
|
||||
(re.compile(r"\btimestamp\s*\(\s*[^,()]+,", re.I),
|
||||
"timestamp(date, time) is MySQL-only -> use CombineDatetime() or a precomputed datetime column"),
|
||||
(re.compile(r"\btimediff\s*\(", re.I),
|
||||
"timediff() is MySQL-only -> compute the delta in Python"),
|
||||
(re.compile(r"\bstr_to_date\s*\(", re.I),
|
||||
"str_to_date() is MySQL-only -> parse in Python and pass a real date"),
|
||||
(re.compile(r"\bdate_format\s*\(", re.I),
|
||||
"date_format() is MySQL-only -> filter on a date range instead"),
|
||||
(re.compile(r"\bdate_(add|sub)\s*\(", re.I),
|
||||
"date_add()/date_sub() are MySQL-only -> use Python date math or interval arithmetic"),
|
||||
(re.compile(r"\bgroup_concat\s*\(", re.I),
|
||||
"group_concat() is MySQL-only -> use GroupConcat (string_agg) or aggregate in Python"),
|
||||
(re.compile(r"\bperiod_diff\s*\(", re.I),
|
||||
"period_diff() is MySQL-only -> compute in Python"),
|
||||
(re.compile(r"\bshow\s+index\b", re.I),
|
||||
"SHOW INDEX is MySQL-only -> use frappe.db.has_index() / get_column_index()"),
|
||||
(re.compile(r"\bshow\s+(tables|columns)\b", re.I),
|
||||
"SHOW TABLES/COLUMNS is MySQL-only -> use frappe.db.get_tables()/table_columns / information-schema helpers"),
|
||||
(re.compile(r"\bas\s+'[^']+'", re.I),
|
||||
"single-quoted column alias breaks on Postgres -> use a bare or double-quoted alias"),
|
||||
(re.compile(r"\bif\s*\(", re.I),
|
||||
"SQL IF() is MySQL-only -> use CASE WHEN ... THEN ... ELSE ... END (frappe.qb.Case())"),
|
||||
(re.compile(r"\brlike\b", re.I),
|
||||
"RLIKE is MySQL-only -> frappe rewrites REGEXP->~* on Postgres but NOT RLIKE; use REGEXP / .regexp() / ~"),
|
||||
(re.compile(r"\bcast\s*\(.+?\bas\s+char\b", re.I | re.S), # .+? spans nested parens, e.g. CAST(ABS(x) AS CHAR)
|
||||
"CAST(... AS CHAR) is character(1) on Postgres and truncates -> CAST AS VARCHAR (frappe Cast_(x, 'varchar'))"),
|
||||
]
|
||||
|
||||
# UPDATE ... JOIN: both keywords in the same SQL string.
|
||||
UPDATE_JOIN = (re.compile(r"\bupdate\b", re.I), re.compile(r"\bjoin\b", re.I))
|
||||
|
||||
MYSQL_RESULT_KEYS = {"Column_name", "Key_name", "Seq_in_index", "Non_unique", "Index_type"}
|
||||
|
||||
SET_BOOL_FUNCS = {"set_value", "db_set"}
|
||||
|
||||
# query-builder cast helpers: pypika Cast / frappe Cast_. A "char" target type is character(1)
|
||||
# on Postgres (truncates); "varchar" is the full-length cast.
|
||||
CAST_FUNCS = {"Cast", "Cast_"}
|
||||
|
||||
# frappe.get_all / get_list: frappe's db_query SILENTLY drops ORDER BY for `distinct` queries on
|
||||
# Postgres (the ORDER BY column must appear in the SELECT-DISTINCT list), so `distinct=True` together
|
||||
# with a literal `order_by` is a no-op on PG and the result comes back unordered.
|
||||
DISTINCT_ORDER_FUNCS = {"get_all", "get_list"}
|
||||
|
||||
|
||||
def _docstring_ids(tree: ast.AST) -> set[int]:
|
||||
"""ids of Constant nodes that are docstrings (so prose describing the rules isn't flagged)."""
|
||||
ids: set[int] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
body = getattr(node, "body", None)
|
||||
if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) and isinstance(body[0].value.value, str):
|
||||
ids.add(id(body[0].value))
|
||||
return ids
|
||||
|
||||
|
||||
class Visitor(ast.NodeVisitor):
|
||||
def __init__(self, lines: list[str], docstrings: set[int]):
|
||||
self.lines = lines
|
||||
self.docstrings = docstrings
|
||||
self.violations: list[tuple[int, str]] = []
|
||||
|
||||
def _ignored(self, node: ast.AST) -> bool:
|
||||
start = getattr(node, "lineno", 1)
|
||||
end = getattr(node, "end_lineno", start) or start
|
||||
# honour `# pg-ok` anywhere on the node's line span, the line just above (the enclosing
|
||||
# call, e.g. `frappe.db.sql( # pg-ok`), or the line just below (a multi-line call's `) # pg-ok`).
|
||||
lo = max(0, start - 2)
|
||||
return any(IGNORE in self.lines[i] for i in range(lo, min(end + 1, len(self.lines))))
|
||||
|
||||
def _flag(self, node: ast.AST, msg: str) -> None:
|
||||
if not self._ignored(node):
|
||||
self.violations.append((getattr(node, "lineno", 1), msg))
|
||||
|
||||
def _scan_sql(self, text: str, node: ast.AST) -> None:
|
||||
if not SQL_HINT.search(text):
|
||||
return
|
||||
for pattern, msg in SQL_PATTERNS:
|
||||
if pattern.search(text):
|
||||
self._flag(node, msg)
|
||||
if UPDATE_JOIN[0].search(text) and UPDATE_JOIN[1].search(text):
|
||||
self._flag(node, "UPDATE ... JOIN is MySQL-only -> use a correlated subquery (WHERE ... IN/EXISTS)")
|
||||
|
||||
def visit_Constant(self, node: ast.Constant) -> None:
|
||||
# plain string literals, incl. `"...".format()` and `"..." % (...)` templates
|
||||
if isinstance(node.value, str) and id(node) not in self.docstrings:
|
||||
self._scan_sql(node.value, node)
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_JoinedStr(self, node: ast.JoinedStr) -> None:
|
||||
# f-string: scan its STATIC text (interpolated values become a placeholder) so MySQL-isms
|
||||
# in dynamic SQL are caught, without flagging safe interpolation of identifiers.
|
||||
text = "".join(
|
||||
v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else " ? "
|
||||
for v in node.values
|
||||
)
|
||||
self._scan_sql(text, node)
|
||||
# don't recurse: child literal chunks would otherwise be re-scanned individually
|
||||
|
||||
def visit_Call(self, node: ast.Call) -> None:
|
||||
fn = node.func
|
||||
name = fn.attr if isinstance(fn, ast.Attribute) else (fn.id if isinstance(fn, ast.Name) else "")
|
||||
|
||||
# row.get("Column_name") — MySQL SHOW INDEX result key
|
||||
if name == "get" and node.args and isinstance(node.args[0], ast.Constant) and node.args[0].value in MYSQL_RESULT_KEYS:
|
||||
self._flag(node, f'"{node.args[0].value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
|
||||
|
||||
# set_value(..., True) / db_set("field", True) on a Check (int) column.
|
||||
# Only the field *value* arg carries bool->smallint risk — NOT trailing flags like
|
||||
# update_modified. db_set(field, value, update_modified, ...) -> value at args[1] (or a dict
|
||||
# at args[0]); set_value(dt, dn, field, value, ...) -> value at args[3] (or a dict at args[2]).
|
||||
if name in SET_BOOL_FUNCS:
|
||||
value_idx, dict_idx = (1, 0) if name == "db_set" else (3, 2)
|
||||
dict_arg = (
|
||||
node.args[dict_idx]
|
||||
if len(node.args) > dict_idx and isinstance(node.args[dict_idx], ast.Dict)
|
||||
else None
|
||||
)
|
||||
if dict_arg is not None:
|
||||
for v in dict_arg.values:
|
||||
if isinstance(v, ast.Constant) and isinstance(v.value, bool):
|
||||
self._flag(node, f"{name}(...) sets an int/Check column with a bool in a dict -> pass 1/0 (Postgres rejects bool->smallint)")
|
||||
elif len(node.args) > value_idx:
|
||||
a = node.args[value_idx]
|
||||
if isinstance(a, ast.Constant) and isinstance(a.value, bool):
|
||||
self._flag(node, f"{name}(..., {a.value}) sets an int/Check column with a bool -> pass 1/0 (Postgres rejects bool->smallint)")
|
||||
|
||||
# frappe.get_all/get_list(..., distinct=True, order_by="<col>") -> ORDER BY is silently dropped
|
||||
# for distinct queries on Postgres, so the result is unordered there. Sort in python instead
|
||||
# (e.g. sorted(frappe.get_all(..., distinct=True), key=str.casefold)). An empty order_by="" (the
|
||||
# explicit "suppress the injected default" idiom) and a dynamic/variable order_by are not flagged.
|
||||
if name in DISTINCT_ORDER_FUNCS:
|
||||
has_distinct = any(
|
||||
kw.arg == "distinct" and isinstance(kw.value, ast.Constant) and kw.value.value
|
||||
for kw in node.keywords
|
||||
)
|
||||
order_kw = next((kw for kw in node.keywords if kw.arg == "order_by"), None)
|
||||
has_literal_order = (
|
||||
order_kw is not None
|
||||
and isinstance(order_kw.value, ast.Constant)
|
||||
and isinstance(order_kw.value.value, str)
|
||||
and order_kw.value.value.strip()
|
||||
)
|
||||
if has_distinct and has_literal_order:
|
||||
self._flag(node, f"{name}(distinct=True, order_by=...) -> frappe drops ORDER BY for distinct queries on Postgres; sort in python instead, e.g. sorted(..., key=str.casefold)")
|
||||
|
||||
# query-builder .rlike(...): pypika emits the MySQL-only RLIKE operator, which frappe does
|
||||
# NOT translate for Postgres (it rewrites only REGEXP -> ~*).
|
||||
if name == "rlike":
|
||||
self._flag(node, ".rlike() emits MySQL-only RLIKE (not translated on Postgres) -> use .regexp() (rewritten to ~*) or .like()")
|
||||
|
||||
# Cast(col, "char") / Cast_(col, "char"): on Postgres a bare CHAR is character(1) and truncates
|
||||
# (e.g. CAST(12 AS CHAR) -> '1'); use "varchar" for a full-length string cast.
|
||||
if name in CAST_FUNCS:
|
||||
for arg in (*node.args, *(kw.value for kw in node.keywords)):
|
||||
if isinstance(arg, ast.Constant) and isinstance(arg.value, str) and arg.value.strip().lower() == "char":
|
||||
self._flag(node, f"{name}(..., 'char') is character(1) on Postgres and truncates -> use 'varchar'")
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def visit_Subscript(self, node: ast.Subscript) -> None:
|
||||
key = node.slice
|
||||
if isinstance(key, ast.Constant) and key.value in MYSQL_RESULT_KEYS:
|
||||
self._flag(node, f'"{key.value}" is a MySQL SHOW INDEX result key -> use frappe.db.has_index()/get_column_index()')
|
||||
self.generic_visit(node)
|
||||
|
||||
|
||||
def check_file(path: str) -> list[str]:
|
||||
try:
|
||||
# nosemgrep: frappe-semgrep-rules.rules.security.frappe-security-file-traversal -- dev-only lint tool; `path` is a source file supplied by pre-commit, not user input
|
||||
src = open(path, encoding="utf-8").read()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return []
|
||||
try:
|
||||
tree = ast.parse(src, filename=path)
|
||||
except SyntaxError:
|
||||
return [] # check-ast hook reports real syntax errors
|
||||
v = Visitor(src.splitlines(), _docstring_ids(tree))
|
||||
v.visit(tree)
|
||||
return [f"{path}:{line}: [pg-compat] {msg}" for line, msg in sorted(set(v.violations))]
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
out: list[str] = []
|
||||
for path in argv:
|
||||
if path.endswith(".py"):
|
||||
out.extend(check_file(path))
|
||||
if out:
|
||||
print("\n".join(out))
|
||||
print(
|
||||
f"\n{len(out)} PostgreSQL-incompatibility issue(s). Fix them, or add `# pg-ok` to a "
|
||||
"line that is intentionally MariaDB-only (e.g. inside an `if frappe.db.db_type == 'mariadb':` branch)."
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
1
.github/helper/site_config_mariadb.json
vendored
1
.github/helper/site_config_mariadb.json
vendored
@@ -8,7 +8,6 @@
|
||||
"mail_login": "test@example.com",
|
||||
"mail_password": "test",
|
||||
"admin_password": "admin",
|
||||
"use_mysqlclient": 1,
|
||||
"root_login": "root",
|
||||
"root_password": "root",
|
||||
"host_name": "http://test_site:8000",
|
||||
|
||||
2
.github/helper/site_config_postgres.json
vendored
2
.github/helper/site_config_postgres.json
vendored
@@ -13,6 +13,6 @@
|
||||
"root_login": "postgres",
|
||||
"root_password": "travis",
|
||||
"host_name": "http://test_site:8000",
|
||||
"install_apps": ["payments", "erpnext"],
|
||||
"install_apps": ["erpnext"],
|
||||
"throttle_user_limit": 100
|
||||
}
|
||||
|
||||
79
.github/helper/start-db.sh
vendored
79
.github/helper/start-db.sh
vendored
@@ -1,79 +0,0 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Run MariaDB INSIDE the runner container, on a datadir we control. Because the datadir can be
|
||||
# packaged into the bench artifact, test shards start an already-loaded server instead of
|
||||
# replaying a SQL dump (the ~60s hydrate restore). Each shard gets its own copy → isolation kept.
|
||||
#
|
||||
# CI_DB_DATADIR picks the path:
|
||||
# - setup job: /home/ci/db-data (OUTSIDE the bench, so install.sh's `rm -rf ~/frappe-bench`
|
||||
# doesn't wipe it; it's moved into the bench just before packaging)
|
||||
# - test shard: ~/frappe-bench/mariadb-data (where the artifact untar'd it)
|
||||
#
|
||||
# Idempotent: inits a fresh datadir if absent (setup), else starts on the existing one (shards).
|
||||
#
|
||||
set -e
|
||||
|
||||
ci_user="${ERPNEXT_CI_USER:-frappe}"
|
||||
|
||||
# Re-exec as the ci user so mariadbd and the datadir are owned consistently (root mariadbd is
|
||||
# refused anyway). Mirrors install.sh's user switch.
|
||||
if [ "$(id -u)" = "0" ] && [ "${SKIP_SYSTEM_SETUP:-0}" = "1" ] && [ "$ci_user" != "root" ]; then
|
||||
exec su -m "$ci_user" -s /bin/bash -c \
|
||||
"ERPNEXT_CI_USER='$ci_user' CI_DB_DATADIR='${CI_DB_DATADIR:-}' DB='${DB:-}' bash '$0'"
|
||||
fi
|
||||
|
||||
# --- PostgreSQL (GitHub-hosted CI): run in-runner on a PGDATA so it bakes into the artifact,
|
||||
# same idea as the mariadb datadir. Trust auth (throwaway CI) skips password setup; durability
|
||||
# off for speed. Postgres is preinstalled on ubuntu-latest under /usr/lib/postgresql/<ver>/bin.
|
||||
if [ "${DB:-mariadb}" = "postgres" ]; then
|
||||
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin 2>/dev/null | sort -V | tail -1)
|
||||
[ -n "$PG_BIN" ] && export PATH="$PG_BIN:$PATH"
|
||||
PGDATA="${CI_DB_DATADIR:-$HOME/frappe-bench/pgdata}"
|
||||
if [ ! -d "$PGDATA/base" ]; then
|
||||
initdb -D "$PGDATA" -U postgres --auth-local=trust --auth-host=trust >/dev/null
|
||||
echo "host all all 127.0.0.1/32 trust" >> "$PGDATA/pg_hba.conf"
|
||||
fi
|
||||
pg_ctl -D "$PGDATA" -w -o "-p 5432 -c listen_addresses=127.0.0.1 -c unix_socket_directories=$PGDATA -c fsync=off -c synchronous_commit=off -c full_page_writes=off" start
|
||||
echo "PostgreSQL up in-runner (pgdata=$PGDATA)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- MariaDB ---
|
||||
DATADIR="${CI_DB_DATADIR:-$HOME/frappe-bench/mariadb-data}"
|
||||
SOCK="$DATADIR/mysqld.sock"
|
||||
fresh=0
|
||||
|
||||
if [ ! -d "$DATADIR/mysql" ]; then
|
||||
mkdir -p "$DATADIR"
|
||||
mariadb-install-db --no-defaults --datadir="$DATADIR" \
|
||||
--auth-root-authentication-method=normal --skip-test-db >/dev/null 2>&1
|
||||
fresh=1
|
||||
fi
|
||||
|
||||
# Throwaway-CI durability off; bind TCP 127.0.0.1:3306 so bench/install.sh connect as usual.
|
||||
mariadbd --no-defaults --datadir="$DATADIR" --socket="$SOCK" --pid-file="$DATADIR/mysqld.pid" \
|
||||
--port=3306 --bind-address=127.0.0.1 \
|
||||
--innodb-flush-log-at-trx-commit=0 --sync-binlog=0 --skip-log-bin \
|
||||
> "$HOME/mariadb.log" 2>&1 &
|
||||
|
||||
up=0
|
||||
for _ in $(seq 1 60); do
|
||||
if mariadb-admin --socket="$SOCK" ping --silent 2>/dev/null; then up=1; break; fi
|
||||
sleep 1
|
||||
done
|
||||
# Fail loudly instead of letting the loop fall through (exit 0 of the last `sleep`) into SQL that
|
||||
# would error with a vague socket-connection failure.
|
||||
[ "$up" = "1" ] || { echo "mariadbd did not come up on $SOCK"; cat "$HOME/mariadb.log" 2>/dev/null; exit 1; }
|
||||
|
||||
if [ "$fresh" = "1" ]; then
|
||||
# A fresh datadir has only a password-less root@localhost. Give it the password install.sh
|
||||
# uses, plus a TCP-reachable root@127.0.0.1, so the rest of install.sh works unchanged.
|
||||
mariadb --no-defaults --socket="$SOCK" -u root <<'SQL'
|
||||
ALTER USER 'root'@'localhost' IDENTIFIED BY 'root';
|
||||
CREATE USER IF NOT EXISTS 'root'@'127.0.0.1' IDENTIFIED BY 'root';
|
||||
GRANT ALL PRIVILEGES ON *.* TO 'root'@'127.0.0.1' WITH GRANT OPTION;
|
||||
FLUSH PRIVILEGES;
|
||||
SQL
|
||||
fi
|
||||
|
||||
echo "MariaDB up in-container (datadir=$DATADIR, fresh=$fresh)"
|
||||
121
.github/helper/sync_hotfix_translations.sh
vendored
121
.github/helper/sync_hotfix_translations.sh
vendored
@@ -1,121 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Syncs Crowdin translations from develop to a hotfix branch.
|
||||
# Merge logic: see merge_po_files.py.
|
||||
# Env: GH_TOKEN, PR_REVIEWER, GITHUB_WORKSPACE, APP_NAME, GITHUB_REPOSITORY
|
||||
# (all set by Actions).
|
||||
|
||||
set -e
|
||||
|
||||
HOTFIX_BRANCH="${HOTFIX_BRANCH:?HOTFIX_BRANCH env var is required}"
|
||||
APP_NAME="${APP_NAME:?APP_NAME env var is required}"
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
echo "=== Setting up bench ==="
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
|
||||
cd ./frappe-bench || exit
|
||||
bench get-app --skip-assets "${APP_NAME}" "${GITHUB_WORKSPACE}"
|
||||
|
||||
echo "=== Setting up sync_translations_${HOTFIX_BRANCH} branch ==="
|
||||
cd "./apps/${APP_NAME}" || exit
|
||||
git config user.email "developers@erpnext.com"
|
||||
git config user.name "frappe-pr-bot"
|
||||
git remote set-url upstream "https://github.com/${GITHUB_REPOSITORY}.git"
|
||||
git config remote.upstream.fetch "+refs/heads/*:refs/remotes/upstream/*"
|
||||
gh auth setup-git
|
||||
git fetch upstream "${HOTFIX_BRANCH}"
|
||||
|
||||
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
|
||||
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
|
||||
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/sync_translations_${HOTFIX_BRANCH}"
|
||||
git merge -X theirs "upstream/${HOTFIX_BRANCH}" --no-edit
|
||||
else
|
||||
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/${HOTFIX_BRANCH}"
|
||||
fi
|
||||
cd ../.. || exit
|
||||
|
||||
echo "=== Fetching develop's .po files ==="
|
||||
mkdir -p /tmp/develop-po
|
||||
git -C "${GITHUB_WORKSPACE}" fetch origin develop
|
||||
git -C "${GITHUB_WORKSPACE}" archive origin/develop "${APP_NAME}/locale/" \
|
||||
| tar -xf - -C /tmp/develop-po/
|
||||
|
||||
po_count=$(find "/tmp/develop-po/${APP_NAME}/locale" -name "*.po" | wc -l)
|
||||
if [ "${po_count}" -eq 0 ]; then
|
||||
echo "ERROR: No .po files found in develop's archive. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted ${po_count} .po file(s) from develop."
|
||||
|
||||
echo "=== Merging and reconciling ==="
|
||||
env/bin/python "${GITHUB_WORKSPACE}/.github/helper/merge_po_files.py"
|
||||
bench update-po-files --app "${APP_NAME}"
|
||||
|
||||
cd "./apps/${APP_NAME}" || exit
|
||||
|
||||
if git diff --quiet "${APP_NAME}/locale/" && [ -z "$(git ls-files --others --exclude-standard "${APP_NAME}/locale/")" ]; then
|
||||
echo "Translations are already up to date. No PR needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
git diff --name-only "${APP_NAME}/locale/"
|
||||
git ls-files --others --exclude-standard "${APP_NAME}/locale/"
|
||||
|
||||
echo "=== Committing ==="
|
||||
while IFS= read -r file; do
|
||||
git add "${file}"
|
||||
lang=$(basename "${file}" .po)
|
||||
git commit -m "chore: add ${lang} translation to ${HOTFIX_BRANCH}"
|
||||
done < <(git ls-files --others --exclude-standard "${APP_NAME}/locale/" | grep '\.po$' | sort)
|
||||
|
||||
while IFS= read -r file; do
|
||||
git add "${file}"
|
||||
if ! git diff --staged --quiet -- "${file}"; then
|
||||
lang=$(basename "${file}" .po)
|
||||
git commit -m "chore: sync ${lang} translation to ${HOTFIX_BRANCH}"
|
||||
else
|
||||
git restore --staged -- "${file}"
|
||||
fi
|
||||
done < <(git diff --name-only "${APP_NAME}/locale/" | grep '\.po$' | sort)
|
||||
|
||||
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
|
||||
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
|
||||
git merge -X ours "upstream/sync_translations_${HOTFIX_BRANCH}" --no-edit
|
||||
fi
|
||||
git push -u upstream sync_translations_${HOTFIX_BRANCH}
|
||||
|
||||
echo "=== Opening PR (if not already open) ==="
|
||||
existing_pr=$(gh pr list \
|
||||
--base "${HOTFIX_BRANCH}" \
|
||||
--head "sync_translations_${HOTFIX_BRANCH}" \
|
||||
--state open \
|
||||
--json number \
|
||||
--jq 'length' \
|
||||
-R "${GITHUB_REPOSITORY}")
|
||||
|
||||
if [ "${existing_pr}" -gt 0 ]; then
|
||||
echo "PR already open — branch updated in place. No new PR needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--base "${HOTFIX_BRANCH}" \
|
||||
--head "sync_translations_${HOTFIX_BRANCH}" \
|
||||
--title "chore: sync translations to ${HOTFIX_BRANCH}" \
|
||||
--body "Automated sync of Crowdin translations from \`develop\` to \`${HOTFIX_BRANCH}\`.
|
||||
|
||||
A 3-way merge is performed per language, then \`bench update-po-files\` reconciles each \`.po\` against hotfix's \`main.pot\`:
|
||||
|
||||
| Case | Condition | Result |
|
||||
|------|-----------|--------|
|
||||
| **a** | \`msgid\` in hotfix's \`main.pot\`, **not** in develop's \`.po\` | Hotfix's existing \`msgstr\` is **preserved** (string removed from develop but still needed in hotfix) |
|
||||
| **b** | \`msgid\` **not** in hotfix's \`main.pot\` | **Dropped** from hotfix's \`.po\` |
|
||||
| **c** | \`msgid\` in both hotfix's \`main.pot\` and develop's \`.po\` | Develop's \`msgstr\` is used (Crowdin translation wins) |
|
||||
|
||||
Generated by the \`sync-hotfix-translations\` workflow." \
|
||||
--label "translation" \
|
||||
--label "skip-release-notes" \
|
||||
--reviewer "${PR_REVIEWER}" \
|
||||
-R "${GITHUB_REPOSITORY}"
|
||||
34
.github/helper/translation.py
vendored
34
.github/helper/translation.py
vendored
@@ -2,9 +2,7 @@ import re
|
||||
import sys
|
||||
|
||||
errors_encounter = 0
|
||||
pattern = re.compile(
|
||||
r"_\(([\"']{,3})(?P<message>((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P<py_context>((?!\5).)*)\5)*(\s*,(\s*?.*?\n*?)*(,\s*([\"'])(?P<js_context>((?!\11).)*)\11)*)*\)"
|
||||
)
|
||||
pattern = re.compile(r"_\(([\"']{,3})(?P<message>((?!\1).)*)\1(\s*,\s*context\s*=\s*([\"'])(?P<py_context>((?!\5).)*)\5)*(\s*,(\s*?.*?\n*?)*(,\s*([\"'])(?P<js_context>((?!\11).)*)\11)*)*\)")
|
||||
words_pattern = re.compile(r"_{1,2}\([\"'`]{1,3}.*?[a-zA-Z]")
|
||||
start_pattern = re.compile(r"_{1,2}\([f\"'`]{1,3}")
|
||||
f_string_pattern = re.compile(r"_\(f[\"']")
|
||||
@@ -12,14 +10,14 @@ starts_with_f_pattern = re.compile(r"_\(f")
|
||||
|
||||
# skip first argument
|
||||
files = sys.argv[1:]
|
||||
files_to_scan = [_file for _file in files if _file.endswith((".py", ".js"))]
|
||||
files_to_scan = [_file for _file in files if _file.endswith(('.py', '.js'))]
|
||||
|
||||
for _file in files_to_scan:
|
||||
with open(_file) as f:
|
||||
print(f"Checking: {_file}")
|
||||
with open(_file, 'r') as f:
|
||||
print(f'Checking: {_file}')
|
||||
file_lines = f.readlines()
|
||||
for line_number, line in enumerate(file_lines, 1):
|
||||
if "frappe-lint: disable-translate" in line:
|
||||
if 'frappe-lint: disable-translate' in line:
|
||||
continue
|
||||
|
||||
start_matches = start_pattern.search(line)
|
||||
@@ -30,9 +28,7 @@ for _file in files_to_scan:
|
||||
has_f_string = f_string_pattern.search(line)
|
||||
if has_f_string:
|
||||
errors_encounter += 1
|
||||
print(
|
||||
f"\nF-strings are not supported for translations at line number {line_number}\n{line.strip()[:100]}"
|
||||
)
|
||||
print(f'\nF-strings are not supported for translations at line number {line_number}\n{line.strip()[:100]}')
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
@@ -40,29 +36,25 @@ for _file in files_to_scan:
|
||||
match = pattern.search(line)
|
||||
error_found = False
|
||||
|
||||
if not match and line.endswith((",\n", "[\n")):
|
||||
if not match and line.endswith((',\n', '[\n')):
|
||||
# concat remaining text to validate multiline pattern
|
||||
line = "".join(file_lines[line_number - 1 :])
|
||||
line = line[start_matches.start() + 1 :]
|
||||
line = "".join(file_lines[line_number - 1:])
|
||||
line = line[start_matches.start() + 1:]
|
||||
match = pattern.match(line)
|
||||
|
||||
if not match:
|
||||
error_found = True
|
||||
print(f"\nTranslation syntax error at line number {line_number}\n{line.strip()[:100]}")
|
||||
print(f'\nTranslation syntax error at line number {line_number}\n{line.strip()[:100]}')
|
||||
|
||||
if not error_found and not words_pattern.search(line):
|
||||
error_found = True
|
||||
print(
|
||||
f"\nTranslation is useless because it has no words at line number {line_number}\n{line.strip()[:100]}"
|
||||
)
|
||||
print(f'\nTranslation is useless because it has no words at line number {line_number}\n{line.strip()[:100]}')
|
||||
|
||||
if error_found:
|
||||
errors_encounter += 1
|
||||
|
||||
if errors_encounter > 0:
|
||||
print(
|
||||
'\nVisit "https://frappeframework.com/docs/user/en/translations" to learn about valid translation strings.'
|
||||
)
|
||||
print('\nVisit "https://frappeframework.com/docs/user/en/translations" to learn about valid translation strings.')
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("\nGood To Go!")
|
||||
print('\nGood To Go!')
|
||||
|
||||
40
.github/helper/update_pot_file.sh
vendored
40
.github/helper/update_pot_file.sh
vendored
@@ -1,40 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd ~ || exit
|
||||
|
||||
echo "Setting Up Bench..."
|
||||
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
|
||||
cd ./frappe-bench || exit
|
||||
|
||||
echo "Get ERPNext..."
|
||||
bench get-app --skip-assets erpnext "${GITHUB_WORKSPACE}"
|
||||
|
||||
echo "Generating POT file..."
|
||||
bench generate-pot-file --app erpnext
|
||||
|
||||
cd ./apps/erpnext || exit
|
||||
|
||||
echo "Configuring git user..."
|
||||
git config user.email "developers@erpnext.com"
|
||||
git config user.name "frappe-pr-bot"
|
||||
|
||||
echo "Setting the correct git remote..."
|
||||
# Here, the git remote is a local file path by default. Let's change it to the upstream repo.
|
||||
git remote set-url upstream https://github.com/frappe/erpnext.git
|
||||
|
||||
echo "Creating a new branch..."
|
||||
isodate=$(date -u +"%Y-%m-%d")
|
||||
branch_name="pot_${BASE_BRANCH}_${isodate}"
|
||||
git checkout -b "${branch_name}"
|
||||
|
||||
echo "Commiting changes..."
|
||||
git add erpnext/locale/main.pot
|
||||
git commit -m "chore: update POT file"
|
||||
|
||||
gh auth setup-git
|
||||
git push -u upstream "${branch_name}"
|
||||
|
||||
echo "Creating a PR..."
|
||||
gh pr create --fill --base "${BASE_BRANCH}" --head "${branch_name}" --reviewer ${PR_REVIEWER} -R frappe/erpnext
|
||||
4
.github/release.yml
vendored
4
.github/release.yml
vendored
@@ -1,4 +0,0 @@
|
||||
changelog:
|
||||
exclude:
|
||||
labels:
|
||||
- skip-release-notes
|
||||
8
.github/stale.yml
vendored
8
.github/stale.yml
vendored
@@ -12,14 +12,6 @@ exemptProjects: true
|
||||
# Set to true to ignore issues in a milestone (defaults to false)
|
||||
exemptMilestones: true
|
||||
|
||||
# Skip the stale action for draft PRs
|
||||
exemptDraftPr: true
|
||||
|
||||
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- hotfix
|
||||
- no-stale
|
||||
|
||||
pulls:
|
||||
daysUntilStale: 15
|
||||
daysUntilClose: 3
|
||||
|
||||
5
.github/workflows/backport.yml
vendored
5
.github/workflows/backport.yml
vendored
@@ -5,16 +5,13 @@ on:
|
||||
- closed
|
||||
- labeled
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- name: Checkout Actions
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: "frappe/backport"
|
||||
path: ./actions
|
||||
|
||||
70
.github/workflows/build-and-commit-assets.yml
vendored
70
.github/workflows/build-and-commit-assets.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Build and Upload Assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- 'version-*'
|
||||
|
||||
concurrency:
|
||||
group: build-assets-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-assets:
|
||||
name: Build JS/CSS and upload to release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: frappe/frappe
|
||||
path: apps/frappe
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: apps/erpnext
|
||||
|
||||
- name: Create bench structure
|
||||
run: |
|
||||
mkdir -p sites
|
||||
printf "frappe\nerpnext\n" > sites/apps.txt
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: yarn
|
||||
cache-dependency-path: apps/frappe/yarn.lock
|
||||
|
||||
- name: Install frappe JS dependencies
|
||||
working-directory: apps/frappe
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install erpnext JS dependencies
|
||||
working-directory: apps/erpnext
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Link node_modules into public/
|
||||
working-directory: apps/frappe
|
||||
run: ln -s "$PWD/node_modules" frappe/public/node_modules
|
||||
|
||||
- name: Build assets (production)
|
||||
working-directory: apps/frappe
|
||||
run: yarn run production
|
||||
|
||||
- name: Package assets
|
||||
working-directory: apps/erpnext
|
||||
run: tar czf erpnext-assets.tar.gz -C ../../sites/assets/erpnext dist
|
||||
|
||||
- name: Upload to rolling release
|
||||
working-directory: apps/erpnext
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="assets-${GITHUB_REF_NAME//\//-}"
|
||||
gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true
|
||||
gh release upload "$TAG" erpnext-assets.tar.gz --clobber
|
||||
6
.github/workflows/docker-release.yml
vendored
6
.github/workflows/docker-release.yml
vendored
@@ -2,10 +2,6 @@ name: Trigger Docker build on release
|
||||
on:
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
curl:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -15,4 +11,4 @@ jobs:
|
||||
- name: curl
|
||||
run: |
|
||||
apk add curl bash
|
||||
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/core-build-stable.yml/dispatches -d '{"ref":"main"}'
|
||||
curl -X POST -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.CI_PAT }}" https://api.github.com/repos/frappe/frappe_docker/actions/workflows/build_stable.yml/dispatches -d '{"ref":"main"}'
|
||||
|
||||
7
.github/workflows/docs-checker.yml
vendored
7
.github/workflows/docs-checker.yml
vendored
@@ -3,9 +3,6 @@ on:
|
||||
pull_request:
|
||||
types: [ opened, synchronize, reopened, edited ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -13,12 +10,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: 'Setup Environment'
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: 'Clone repo'
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Validate Docs
|
||||
env:
|
||||
|
||||
44
.github/workflows/generate-pot-file.yml
vendored
44
.github/workflows/generate-pot-file.yml
vendored
@@ -1,44 +0,0 @@
|
||||
# This workflow is agnostic to branches. Only maintain on develop branch.
|
||||
# To add/remove branches just modify the matrix.
|
||||
|
||||
name: Regenerate POT file (translatable strings)
|
||||
on:
|
||||
schedule:
|
||||
# 9:30 UTC => 3 PM IST Sunday
|
||||
- cron: "30 9 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
regenerate-pot-file:
|
||||
name: Regenerate POT file
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
branch: ["develop", "version-16-hotfix"]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ matrix.branch }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run script to update POT file
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/update_pot_file.sh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
BASE_BRANCH: ${{ matrix.branch }}
|
||||
PR_REVIEWER: barredterra # change to your GitHub username if you copied this file
|
||||
6
.github/workflows/initiate_release.yml
vendored
6
.github/workflows/initiate_release.yml
vendored
@@ -2,10 +2,6 @@
|
||||
# To add/remove versions just modify the matrix.
|
||||
|
||||
name: Create weekly release pull requests
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 9:30 UTC => 3 PM IST Tuesday
|
||||
@@ -19,7 +15,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
version: ["15", "16"]
|
||||
version: ["14", "15"]
|
||||
|
||||
steps:
|
||||
- uses: octokit/request-action@v2.x
|
||||
|
||||
30
.github/workflows/label-base-on-title.yml
vendored
30
.github/workflows/label-base-on-title.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: "Auto-label PRs based on title"
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
add-label-if-prefix-matches:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check PR title and add label if it matches prefixes
|
||||
uses: actions/github-script@v7
|
||||
continue-on-error: true
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.pull_request.title.toLowerCase();
|
||||
const prefixes = ['chore', 'ci', 'style', 'test', 'refactor'];
|
||||
|
||||
// Check if the PR title starts with any of the prefixes
|
||||
if (prefixes.some(prefix => title.startsWith(prefix))) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.payload.pull_request.number,
|
||||
labels: ['skip-release-notes']
|
||||
});
|
||||
}
|
||||
4
.github/workflows/labeller.yml
vendored
4
.github/workflows/labeller.yml
vendored
@@ -3,10 +3,6 @@ on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
22
.github/workflows/linters.yml
vendored
22
.github/workflows/linters.yml
vendored
@@ -3,21 +3,18 @@ name: Linters
|
||||
on:
|
||||
pull_request: { }
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
linters:
|
||||
name: linters
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.14
|
||||
uses: actions/setup-python@v6
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
|
||||
- name: Install and Run Pre-commit
|
||||
@@ -27,12 +24,12 @@ jobs:
|
||||
name: semgrep
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python 3.14
|
||||
uses: actions/setup-python@v6
|
||||
- name: Set up Python 3.10
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.14'
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
|
||||
- name: Download Semgrep rules
|
||||
@@ -43,6 +40,3 @@ jobs:
|
||||
|
||||
- name: Run Semgrep rules
|
||||
run: semgrep ci --config ./frappe-semgrep-rules/rules --config r/python.lang.correctness
|
||||
|
||||
- name: Semgrep for Test Correctness
|
||||
run: semgrep ci --include=**/test_*.py --config ./semgrep/test-correctness.yml
|
||||
|
||||
70
.github/workflows/patch.yml
vendored
70
.github/workflows/patch.yml
vendored
@@ -8,14 +8,8 @@ on:
|
||||
- '**.md'
|
||||
- '**.html'
|
||||
- '**.csv'
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: patch-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: true
|
||||
@@ -29,7 +23,7 @@ jobs:
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:11.8
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
ports:
|
||||
@@ -38,48 +32,32 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
python -m compileall -fq "${GITHUB_WORKSPACE}"
|
||||
python -m compileall -f "${GITHUB_WORKSPACE}"
|
||||
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
|
||||
then echo "Found merge conflicts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: "actions/setup-python@v4"
|
||||
with:
|
||||
python-version: |
|
||||
3.11
|
||||
3.13
|
||||
3.14
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# The v14 baseline backup is a fixed published file — cache it instead of re-downloading
|
||||
# ~100MB from frappe.io every run.
|
||||
- name: Cache erpnext v14 backup
|
||||
id: cache-v14
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/erpnext-v14.sql.gz
|
||||
key: erpnext-v14-sql-gz
|
||||
|
||||
- name: Download erpnext v14 backup
|
||||
if: steps.cache-v14.outputs.cache-hit != 'true'
|
||||
run: wget -O ~/erpnext-v14.sql.gz https://frappe.io/files/erpnext-v14.sql.gz
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
@@ -88,7 +66,7 @@ jobs:
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v2
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
@@ -101,9 +79,9 @@ jobs:
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
- uses: actions/cache@v2
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
@@ -126,20 +104,12 @@ jobs:
|
||||
jq 'del(.install_apps)' ~/frappe-bench/sites/test_site/site_config.json > tmp.json
|
||||
mv tmp.json ~/frappe-bench/sites/test_site/site_config.json
|
||||
|
||||
bench --site test_site --force restore ~/erpnext-v14.sql.gz
|
||||
wget https://erpnext.com/files/v13-erpnext.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/v13-erpnext.sql.gz
|
||||
|
||||
git -C "apps/frappe" remote set-url upstream https://github.com/frappe/frappe.git
|
||||
git -C "apps/erpnext" remote set-url upstream https://github.com/frappe/erpnext.git
|
||||
|
||||
# Start every bench process except the background workers. If workers run during a
|
||||
# migrate, they pick up the orphan-link cleanup jobs it enqueues and race its schema
|
||||
# changes, which fails with MySQL 1412 "Table definition has changed". Redis and the
|
||||
# other services stay up; the queued jobs simply wait and are harmless here.
|
||||
function start_bench_without_workers() {
|
||||
local procs
|
||||
procs=$(awk -F: '/^[a-z_]+:/ && $1 !~ /worker/ {print $1}' ~/frappe-bench/Procfile)
|
||||
honcho start -f ~/frappe-bench/Procfile $procs &>> ~/frappe-bench/bench_start.log &
|
||||
}
|
||||
|
||||
function update_to_version() {
|
||||
version=$1
|
||||
@@ -155,28 +125,26 @@ jobs:
|
||||
|
||||
# Resetup env and install apps
|
||||
pgrep honcho | xargs kill
|
||||
sleep 10
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env --python python$2
|
||||
bench -v setup env
|
||||
bench pip install -e ./apps/erpnext
|
||||
start_bench_without_workers
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
|
||||
bench --site test_site migrate
|
||||
}
|
||||
|
||||
update_to_version 15 3.13
|
||||
update_to_version 16 3.14
|
||||
update_to_version 14
|
||||
update_to_version 15
|
||||
|
||||
echo "Updating to latest version"
|
||||
git -C "apps/frappe" fetch --depth 1 upstream "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
|
||||
git -C "apps/frappe" checkout -q -f FETCH_HEAD
|
||||
git -C "apps/frappe" checkout -q -f "${GITHUB_BASE_REF:-${GITHUB_REF##*/}}"
|
||||
git -C "apps/erpnext" checkout -q -f "$GITHUB_SHA"
|
||||
|
||||
pgrep honcho | xargs kill
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env
|
||||
bench pip install -e ./apps/erpnext
|
||||
start_bench_without_workers
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
|
||||
bench --site test_site migrate
|
||||
|
||||
|
||||
6
.github/workflows/patch_faux.yml
vendored
6
.github/workflows/patch_faux.yml
vendored
@@ -10,12 +10,6 @@ on:
|
||||
- "**.md"
|
||||
- "**.html"
|
||||
- "**.csv"
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
@@ -3,22 +3,18 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- version-13
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Entire Repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 20
|
||||
- name: Setup dependencies
|
||||
|
||||
6
.github/workflows/release_notes.yml
vendored
6
.github/workflows/release_notes.yml
vendored
@@ -29,11 +29,7 @@ jobs:
|
||||
steps:
|
||||
- name: Update notes
|
||||
run: |
|
||||
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/generate-notes -f tag_name=$RELEASE_TAG \
|
||||
| jq -r '.body' \
|
||||
| sed -E '/^\* (chore|ci|test|docs|style)/d' \
|
||||
| sed -E 's/by @mergify //'
|
||||
)
|
||||
NEW_NOTES=$(gh api --method POST -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/generate-notes -f tag_name=$RELEASE_TAG | jq -r '.body' | sed -E '/^\* (chore|ci|test|docs|style)/d' )
|
||||
RELEASE_ID=$(gh api -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/tags/$RELEASE_TAG | jq -r '.id')
|
||||
gh api --method PATCH -H "Accept: application/vnd.github+json" /repos/frappe/erpnext/releases/$RELEASE_ID -f body="$NEW_NOTES"
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Review translation PRs
|
||||
description: "Posts review comments with relevant translation changes that are hard to inspect in the diff view."
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
paths:
|
||||
- "**/*.po"
|
||||
- "**/*.pot"
|
||||
|
||||
concurrency:
|
||||
group: po-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
review-po-pr:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: alyf-de/po-review-action@v1.1.0
|
||||
@@ -1,52 +0,0 @@
|
||||
# Runner — maintain this file on each hotfix branch, not on develop.
|
||||
#
|
||||
# Fires when main.pot changes on this branch (i.e. after a POT update PR
|
||||
# merges), or when dispatched by the orchestrator on develop (weekly schedule).
|
||||
#
|
||||
# Uses github.ref_name so the file is identical across all hotfix branches
|
||||
# with no branch-specific edits required.
|
||||
|
||||
name: Run hotfix translation sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# One run at a time per branch. cancel-in-progress: false to avoid leaving
|
||||
# an orphaned remote branch from a mid-flight git push + gh pr create.
|
||||
concurrency:
|
||||
group: sync-hotfix-translations-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
name: Sync translations from develop into ${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
HOTFIX_BRANCH: ${{ github.ref_name }}
|
||||
APP_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ env.HOTFIX_BRANCH }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.HOTFIX_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run sync script
|
||||
run: |
|
||||
bash "${GITHUB_WORKSPACE}/.github/helper/sync_hotfix_translations.sh"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
PR_REVIEWER: diptanilsaha
|
||||
143
.github/workflows/run-individual-tests.yml
vendored
143
.github/workflows/run-individual-tests.yml
vendored
@@ -1,143 +0,0 @@
|
||||
name: Individual
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: server-individual-tests-lightmode-develop
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
- id: set-matrix
|
||||
run: |
|
||||
# Use grep and find to get the list of test files
|
||||
matrix=$(find . -path '*/test_*.py' | xargs grep -l 'def test_' | sort | awk '{
|
||||
# Remove ./ prefix, file extension, and replace / with .
|
||||
gsub(/^\.\//, "", $0)
|
||||
gsub(/\.py$/, "", $0)
|
||||
gsub(/\//, ".", $0)
|
||||
# Add to array
|
||||
tests[NR] = $0
|
||||
}
|
||||
END {
|
||||
# Start JSON array
|
||||
printf "{\n \"include\": [\n"
|
||||
# Loop through array and create JSON objects
|
||||
for (i=1; i<=NR; i++) {
|
||||
printf " {\"test\": \"%s\"}", tests[i]
|
||||
if (i < NR) printf ","
|
||||
printf "\n"
|
||||
}
|
||||
# Close JSON array
|
||||
printf " ]\n}"
|
||||
}')
|
||||
|
||||
# Output the matrix
|
||||
echo "matrix=$(echo "$matrix" | jq -c)" >> $GITHUB_OUTPUT
|
||||
|
||||
# For debugging (optional)
|
||||
echo "Generated matrix:"
|
||||
echo "$matrix"
|
||||
test:
|
||||
needs: discover
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{fromJson(needs.discover.outputs.matrix)}}
|
||||
max-parallel: 14
|
||||
|
||||
name: Test
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.14'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
DB: mariadb
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
site_name=$(echo "${{matrix.test}}" | sed -e 's/.*\.\(test_.*$\)/\1/')
|
||||
echo "$site_name"
|
||||
mkdir ~/frappe-bench/sites/$site_name
|
||||
cp -r "${GITHUB_WORKSPACE}/.github/helper/site_config_mariadb.json" ~/frappe-bench/sites/$site_name/site_config.json
|
||||
cd ~/frappe-bench/
|
||||
bench --site $site_name reinstall --yes
|
||||
bench --site $site_name set-config allow_tests true
|
||||
bench --site $site_name run-tests --module ${{ matrix.test }} --lightmode
|
||||
|
||||
4
.github/workflows/semantic-commits.yml
vendored
4
.github/workflows/semantic-commits.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
name: Check Commit Titles
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 200
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
|
||||
@@ -7,15 +7,8 @@ on:
|
||||
paths:
|
||||
- "**.js"
|
||||
- "**.css"
|
||||
- "**.svg"
|
||||
- "**.md"
|
||||
- "**.html"
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
|
||||
217
.github/workflows/server-tests-mariadb.yml
vendored
217
.github/workflows/server-tests-mariadb.yml
vendored
@@ -1,18 +1,12 @@
|
||||
name: Server (Mariadb)
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [frappe-framework-change]
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.css'
|
||||
- '**.svg'
|
||||
- '**.md'
|
||||
- '**.html'
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
schedule:
|
||||
# Run everday at midnight UTC / 5:30 IST
|
||||
- cron: "0 0 * * *"
|
||||
@@ -29,70 +23,90 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
concurrency:
|
||||
group: server-mariadb-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Shared across both jobs. Both run in the SAME CI image so the bench lives at the identical
|
||||
# path (/home/ci/frappe-bench) on the setup runner and the test shards — that's what makes the
|
||||
# packaged Python venv portable between them.
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
NODE_ENV: "production"
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
ERPNEXT_CI_USER: ci
|
||||
PIP_CACHE_DIR: /home/ci/.cache/pip
|
||||
npm_config_cache: /home/ci/.cache/npm
|
||||
YARN_CACHE_FOLDER: /home/ci/.cache/yarn
|
||||
UV_CACHE_DIR: /home/ci/.cache/uv
|
||||
|
||||
jobs:
|
||||
# Build the bench (clone + pip + yarn + assets) and reinstall test_site ONCE, on a free
|
||||
# GitHub-hosted runner, then publish the whole bench (with a DB dump baked in) as an artifact.
|
||||
# The expensive, non-parallelisable work happens here exactly once instead of on every shard.
|
||||
setup:
|
||||
name: Build & reinstall (setup)
|
||||
# Dedicated scale set (fat cpu request) so the build+reinstall runs at full speed, uncontended
|
||||
# by the many thin test shards. Same CI image + /home/ci path + 127.0.0.1 DB as the shards,
|
||||
# so the packaged bench (and its venv) transplants cleanly.
|
||||
runs-on: erpnext-arc-setup
|
||||
timeout-minutes: 40
|
||||
container:
|
||||
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
|
||||
credentials:
|
||||
username: ${{ secrets.GHCR_USERNAME || github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
name: Python Unit Tests
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:10.6
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: 'root'
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="mariadb-admin ping" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
python -m compileall -fq "${GITHUB_WORKSPACE}"
|
||||
python -m compileall -f "${GITHUB_WORKSPACE}"
|
||||
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
|
||||
then echo "Found merge conflicts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# MariaDB runs in-container on a datadir OUTSIDE the bench, because install.sh's next step
|
||||
# does `rm -rf ~/frappe-bench`. After the reinstall, the datadir is moved into the bench so
|
||||
# it ships in the artifact — test shards then start an already-loaded server (no restore).
|
||||
- name: Start DB
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v2
|
||||
env:
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
CI_DB_DATADIR: /home/ci/db-data
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v2
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
@@ -100,120 +114,39 @@ jobs:
|
||||
DB: mariadb
|
||||
TYPE: server
|
||||
FRAPPE_USER: ${{ github.event.inputs.user }}
|
||||
FRAPPE_BRANCH: ${{ github.event.client_payload.sha || github.event.inputs.branch }}
|
||||
DB_HOST: 127.0.0.1
|
||||
DB_USER_HOST: '%'
|
||||
WKHTMLTOX_DEB: /tmp/wkhtmltox.deb
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
SKIP_WKHTMLTOX_SETUP: "1"
|
||||
|
||||
# Clean shutdown (consistent InnoDB datadir), then stage it inside the bench for packaging.
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
mariadb-admin -h 127.0.0.1 -P 3306 -u root -proot shutdown || true
|
||||
for _ in $(seq 1 30); do [ -f /home/ci/db-data/mysqld.pid ] || break; sleep 1; done
|
||||
# Don't bake a dirty datadir — fail if mariadbd didn't finish stopping, rather than ship
|
||||
# an inconsistent datadir the shards would have to crash-recover.
|
||||
[ -f /home/ci/db-data/mysqld.pid ] && { echo "mariadbd did not shut down cleanly"; exit 1; }
|
||||
mv /home/ci/db-data /home/ci/frappe-bench/mariadb-data
|
||||
|
||||
# Package the whole bench (apps, venv, node_modules, sites, the DB dump, and hydrate.sh)
|
||||
# into one artifact for the test shards to consume.
|
||||
# Single-node hand-off: stage the bench on a node-local hostPath instead of round-tripping
|
||||
# through GitHub artifact storage (~60s/shard). Setup and shards share the same disk, so
|
||||
# the shards just untar it locally. NOTE: this assumes one node — a shard on a different
|
||||
# node could not read this path (then you'd need GitHub artifacts or an NFS/RWX volume).
|
||||
- name: Stage bench on node (hostPath)
|
||||
run: |
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/ci/frappe-bench/hydrate.sh
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/ci/frappe-bench/start-db.sh
|
||||
mkdir -p /opt/ci-bench-staging
|
||||
# self-clean: drop bench tars from runs older than 2h
|
||||
find /opt/ci-bench-staging -maxdepth 1 -name '*.tar.gz' -mmin +120 -delete 2>/dev/null || true
|
||||
# Exclude .git/node_modules; the mariadb-data datadir IS included (the pre-loaded DB).
|
||||
tar czpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci \
|
||||
--exclude='.git' --exclude='node_modules' frappe-bench
|
||||
ls -lh "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz"
|
||||
|
||||
# Fan-out: each shard downloads the bench, untars it, starts MariaDB on the baked datadir, and
|
||||
# runs its slice of the suite. No clone, no build, no reinstall, no DB dump restore on the shards.
|
||||
test:
|
||||
name: Python Unit Tests
|
||||
needs: setup
|
||||
runs-on: erpnext-arc
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: ghcr.io/frappe/erpnext-ci-mariadb:py3.14-node24
|
||||
credentials:
|
||||
username: ${{ secrets.GHCR_USERNAME || github.actor }}
|
||||
password: ${{ secrets.GHCR_TOKEN || github.token }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
|
||||
steps:
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# Read the bench straight from the node-local hostPath the setup job staged it on — no
|
||||
# GitHub download. -p preserves the ci (uid 1001) ownership so bench runs as ci cleanly.
|
||||
- name: Untar bench from node (hostPath)
|
||||
run: |
|
||||
tar xzpf "/opt/ci-bench-staging/${GITHUB_RUN_ID}.tar.gz" -C /home/ci
|
||||
ls -ld /home/ci/frappe-bench
|
||||
|
||||
- name: Hydrate (start DB on baked datadir + bench start)
|
||||
run: bash /home/ci/frappe-bench/hydrate.sh
|
||||
env:
|
||||
DB_HOST: 127.0.0.1
|
||||
SKIP_SYSTEM_SETUP: "1"
|
||||
FRAPPE_BRANCH: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
su -m "${ERPNEXT_CI_USER:-frappe}" -s /bin/bash <<'EOF'
|
||||
cd ~/frappe-bench/
|
||||
coverage_flag=""
|
||||
if [ "$WITH_COVERAGE" = "true" ]; then coverage_flag="--with-coverage"; fi
|
||||
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||
--total-builds ${{ strategy.job-total }} \
|
||||
--build-number ${{ matrix.container }} \
|
||||
$coverage_flag
|
||||
EOF
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --with-coverage --total-builds 4 --build-number ${{ matrix.container }}'
|
||||
env:
|
||||
TYPE: server
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: cat ~/frappe-bench/bench_start.log || true
|
||||
|
||||
- name: Upload coverage data
|
||||
if: ${{ env.WITH_COVERAGE == 'true' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-${{ matrix.container }}
|
||||
path: /home/ci/frappe-bench/sites/coverage.xml
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: [test]
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
name: MariaDB
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
|
||||
205
.github/workflows/server-tests-postgres.yml
vendored
205
.github/workflows/server-tests-postgres.yml
vendored
@@ -1,189 +1,112 @@
|
||||
name: Server (Postgres)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 03:00 AM IST daily (21:30 UTC the previous day)
|
||||
- cron: "30 21 * * *"
|
||||
pull_request:
|
||||
# 'labeled' so adding the 'postgres' label to an already-open PR re-triggers the run.
|
||||
types: [opened, reopened, synchronize, labeled]
|
||||
paths-ignore:
|
||||
- '**.js'
|
||||
- '**.md'
|
||||
- '**.html'
|
||||
- 'crowdin.yml'
|
||||
- '.coderabbit.yml'
|
||||
- '.mergify.yml'
|
||||
workflow_dispatch:
|
||||
types: [opened, labelled, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: server-postgres-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Postgres CI stays on GitHub-hosted (free, full-speed VM per shard) but follows the same fan-out
|
||||
# we built for MariaDB: build the bench + reinstall ONCE in the setup job, bake the PostgreSQL
|
||||
# PGDATA into the artifact, and have 4 test shards start Postgres on that datadir — no per-shard
|
||||
# clone/build/reinstall/restore. Python is pinned so the venv transplants between VMs.
|
||||
env:
|
||||
TZ: 'Asia/Kolkata'
|
||||
NODE_ENV: "production"
|
||||
PYTHON_VERSION: '3.14'
|
||||
|
||||
jobs:
|
||||
setup:
|
||||
name: Build & reinstall (setup)
|
||||
test:
|
||||
if: ${{ contains(github.event.pull_request.labels.*.name, 'postgres') }}
|
||||
runs-on: ubuntu-latest
|
||||
# Runs on the daily schedule (and workflow_dispatch). On PRs it runs ONLY when the PR carries
|
||||
# the 'postgres' label — the test job needs setup, so it's skipped too when this is.
|
||||
if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'postgres')
|
||||
timeout-minutes: 40
|
||||
timeout-minutes: 60
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1]
|
||||
|
||||
name: Python Unit Tests
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:13.3
|
||||
env:
|
||||
POSTGRES_PASSWORD: travis
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
steps:
|
||||
|
||||
- name: Clone
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
python -m compileall -fq "${GITHUB_WORKSPACE}"
|
||||
python -m compileall -f "${GITHUB_WORKSPACE}"
|
||||
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
|
||||
then echo "Found merge conflicts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache deps (uv/pip/npm/yarn)
|
||||
uses: actions/cache@v4
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
~/.cache/pip
|
||||
~/.npm
|
||||
~/.cache/yarn
|
||||
key: ${{ runner.os }}-deps-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-deps-
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
# Warm-bench cache (the big one): install.sh saves the built base bench — frappe + env +
|
||||
# node_modules + assets — here as frappe-bench-base-*.tar.zst. Later runs restore it and only
|
||||
# fast-forward to the live develop SHA + rebuild the delta, so the bench BUILD is near-free and
|
||||
# only the test_site reinstall (per-run DB, uncacheable) stays slow — matching the self-hosted
|
||||
# box. The first run after a deps change populates it; every run after that is fast.
|
||||
- name: Cache warm bench (base build)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/bench-cache
|
||||
key: ${{ runner.os }}-warmbench-v2-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/yarn.lock') }}
|
||||
restore-keys: ${{ runner.os }}-warmbench-v2-
|
||||
|
||||
# Postgres runs in-runner on a PGDATA OUTSIDE the bench (install.sh wipes ~/frappe-bench);
|
||||
# after the reinstall it's moved into the bench so it ships in the artifact.
|
||||
- name: Start DB
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/start-db.sh
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v2
|
||||
env:
|
||||
DB: postgres
|
||||
CI_DB_DATADIR: /home/runner/pgdata
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v2
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install
|
||||
run: bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
DB: postgres
|
||||
TYPE: server
|
||||
FRAPPE_BRANCH: develop
|
||||
BENCH_CACHE_DIR: /home/runner/bench-cache
|
||||
|
||||
- name: Stop DB and stage datadir
|
||||
run: |
|
||||
PG_BIN=$(ls -d /usr/lib/postgresql/*/bin | sort -V | tail -1)
|
||||
"$PG_BIN/pg_ctl" -D /home/runner/pgdata -m fast -w stop || true
|
||||
mv /home/runner/pgdata /home/runner/frappe-bench/pgdata
|
||||
|
||||
- name: Package bench for test shards
|
||||
run: |
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/hydrate.sh" /home/runner/frappe-bench/hydrate.sh
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/start-db.sh" /home/runner/frappe-bench/start-db.sh
|
||||
tar czpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner \
|
||||
--exclude='.git' --exclude='node_modules' frappe-bench
|
||||
ls -lh "${GITHUB_WORKSPACE}/bench.tar.gz"
|
||||
|
||||
- name: Upload bench artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bench-pg
|
||||
path: bench.tar.gz
|
||||
retention-days: 1
|
||||
compression-level: 0
|
||||
|
||||
test:
|
||||
name: Python Unit Tests (PG)
|
||||
needs: setup
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
container: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: Download bench artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: bench-pg
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
# The bench CLI (frappe-bench) and redis are global/system tools — not in the bench tarball.
|
||||
# The setup runner got them via install.sh; the MariaDB shards get them from the arc5 image.
|
||||
# GitHub-hosted PG shards install them here (cheap vs the build+reinstall that setup did once).
|
||||
- name: Install shard runtime (bench CLI + redis + wkhtmltopdf)
|
||||
run: |
|
||||
pip install frappe-bench
|
||||
command -v redis-server >/dev/null || { sudo apt-get update -qq && sudo apt-get install -y -qq redis-server; }
|
||||
# wkhtmltopdf (patched-qt build) for print-format / PDF tests — same .deb install.sh uses.
|
||||
if ! command -v wkhtmltopdf >/dev/null; then
|
||||
wget -qO /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
sudo apt-get install -y -qq /tmp/wkhtmltox.deb
|
||||
fi
|
||||
|
||||
- name: Untar bench
|
||||
run: |
|
||||
tar xzpf "${GITHUB_WORKSPACE}/bench.tar.gz" -C /home/runner
|
||||
ls -ld /home/runner/frappe-bench
|
||||
|
||||
- name: Hydrate (start Postgres on the baked datadir)
|
||||
run: bash /home/runner/frappe-bench/hydrate.sh
|
||||
env:
|
||||
DB: postgres
|
||||
DB_HOST: 127.0.0.1
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
# print-format / PDF tests are engine-independent (they exercise wkhtmltopdf rendering,
|
||||
# not postgres SQL — the MariaDB CI already covers them). They only fetch the static asset
|
||||
# bundles from http://test_site:8000/assets/..., so a plain static file server over sites/
|
||||
# satisfies wkhtmltopdf without the frappe web server (which never bound on a bare runner).
|
||||
( cd ~/frappe-bench/sites && nohup python3 -m http.server 8000 --bind 127.0.0.1 > ~/frappe-bench/web.log 2>&1 & )
|
||||
for _ in $(seq 1 15); do (exec 3<>/dev/tcp/127.0.0.1/8000) 2>/dev/null && { exec 3>&- 3<&-; break; }; sleep 1; done
|
||||
bench --site test_site run-parallel-tests --lightmode --app erpnext \
|
||||
--total-builds ${{ strategy.job-total }} --build-number ${{ matrix.container }}
|
||||
run: cd ~/frappe-bench/ && bench --site test_site run-parallel-tests --app erpnext --use-orchestrator
|
||||
env:
|
||||
TYPE: server
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
ORCHESTRATOR_URL: http://test-orchestrator.frappe.io
|
||||
|
||||
40
.github/workflows/sync-hotfix-translations.yml
vendored
40
.github/workflows/sync-hotfix-translations.yml
vendored
@@ -1,40 +0,0 @@
|
||||
# Orchestrator — lives on develop only.
|
||||
#
|
||||
# Triggers on the weekly schedule and dispatches the runner workflow on each
|
||||
# hotfix branch listed in the matrix. To add or remove a branch, edit the
|
||||
# matrix below.
|
||||
#
|
||||
# POT-change triggers are handled by the runner on each hotfix branch
|
||||
# (run-hotfix-translation-sync.yml), since GitHub only evaluates a workflow
|
||||
# from the branch that receives the push.
|
||||
|
||||
name: Sync translations to hotfix branches
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 10:00 UTC Monday
|
||||
- cron: "0 10 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
# The runner dispatch uses RELEASE_TOKEN (a PAT), not the default GITHUB_TOKEN,
|
||||
# so no GITHUB_TOKEN permissions are required.
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
trigger-runners:
|
||||
name: Trigger sync → ${{ matrix.hotfix_branch }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
hotfix_branch:
|
||||
- version-16-hotfix
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
- name: Dispatch runner on ${{ matrix.hotfix_branch }}
|
||||
run: |
|
||||
gh workflow run run-hotfix-translation-sync.yml \
|
||||
--repo "${{ github.repository }}" \
|
||||
--ref "${{ matrix.hotfix_branch }}"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -2,6 +2,7 @@
|
||||
*.py~
|
||||
.DS_Store
|
||||
conf.py
|
||||
locale
|
||||
latest_updates.json
|
||||
.wnf-lang-status
|
||||
*.egg-info
|
||||
@@ -14,12 +15,5 @@ __pycache__
|
||||
*~
|
||||
.idea/
|
||||
.vscode/
|
||||
.helix/
|
||||
node_modules/
|
||||
.backportrc.json
|
||||
# Aider AI Chat
|
||||
.aider*
|
||||
|
||||
# Banking SPA
|
||||
erpnext/public/banking
|
||||
erpnext/www/banking.html
|
||||
.backportrc.json
|
||||
File diff suppressed because one or more lines are too long
53
.mergify.yml
53
.mergify.yml
@@ -2,27 +2,29 @@ pull_request_rules:
|
||||
- name: Auto-close PRs on stable branch
|
||||
conditions:
|
||||
- and:
|
||||
- and:
|
||||
- author!=surajshetty3416
|
||||
- author!=gavindsouza
|
||||
- author!=rohitwaghchaure
|
||||
- author!=nabinhait
|
||||
- author!=ankush
|
||||
- author!=deepeshgarg007
|
||||
- author!=frappe-pr-bot
|
||||
- author!=mergify[bot]
|
||||
- or:
|
||||
- base=version-13
|
||||
- base=version-12
|
||||
- base=version-14
|
||||
- base=version-15
|
||||
- base=version-16
|
||||
- and:
|
||||
- author!=surajshetty3416
|
||||
- author!=gavindsouza
|
||||
- author!=rohitwaghchaure
|
||||
- author!=nabinhait
|
||||
- author!=ankush
|
||||
- author!=deepeshgarg007
|
||||
- author!=frappe-pr-bot
|
||||
- author!=mergify[bot]
|
||||
|
||||
- or:
|
||||
- base=version-13
|
||||
- base=version-12
|
||||
- base=version-14
|
||||
- base=version-15
|
||||
- base=version-16
|
||||
actions:
|
||||
close:
|
||||
comment:
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
|
||||
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
|
||||
message: |
|
||||
@{{author}}, thanks for the contribution, but we do not accept pull requests on a stable branch. Please raise PR on an appropriate hotfix branch.
|
||||
https://github.com/frappe/erpnext/wiki/Pull-Request-Checklist#which-branch
|
||||
|
||||
- name: backport to develop
|
||||
conditions:
|
||||
- label="backport develop"
|
||||
@@ -32,6 +34,7 @@ pull_request_rules:
|
||||
- develop
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to version-14-hotfix
|
||||
conditions:
|
||||
- label="backport version-14-hotfix"
|
||||
@@ -41,6 +44,7 @@ pull_request_rules:
|
||||
- version-14-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
- name: backport to version-15-hotfix
|
||||
conditions:
|
||||
- label="backport version-15-hotfix"
|
||||
@@ -50,15 +54,18 @@ pull_request_rules:
|
||||
- version-15-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
- name: backport to version-16-hotfix
|
||||
|
||||
- name: backport to version-13-hotfix
|
||||
conditions:
|
||||
- label="backport version-16-hotfix"
|
||||
- label="backport version-13-hotfix"
|
||||
actions:
|
||||
backport:
|
||||
branches:
|
||||
- version-16-hotfix
|
||||
- version-13-hotfix
|
||||
assignees:
|
||||
- "{{ author }}"
|
||||
|
||||
|
||||
- name: Automatic merge on CI success and review
|
||||
conditions:
|
||||
- status-success=linters
|
||||
@@ -89,6 +96,6 @@ pull_request_rules:
|
||||
merge:
|
||||
method: squash
|
||||
commit_message_template: |
|
||||
{{ title }} (#{{ number }})
|
||||
{{ title }} (#{{ number }})
|
||||
|
||||
{{ body }}
|
||||
{{ body }}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
exclude: 'node_modules|.git'
|
||||
default_stages: [pre-commit]
|
||||
default_stages: [commit]
|
||||
fail_fast: false
|
||||
|
||||
|
||||
@@ -20,21 +20,6 @@ repos:
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.7.1
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [javascript, vue, scss]
|
||||
# Ignore any files that might contain jinja / bundles
|
||||
exclude: |
|
||||
(?x)^(
|
||||
erpnext/public/dist/.*|
|
||||
cypress/.*|
|
||||
.*node_modules.*|
|
||||
.*boilerplate.*|
|
||||
erpnext/templates/includes/.*
|
||||
)$
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v8.44.0
|
||||
hooks:
|
||||
@@ -53,30 +38,29 @@ repos:
|
||||
erpnext/templates/includes/.*
|
||||
)$
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.2.0
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "Run ruff import sorter"
|
||||
args: ["--select=I", "--fix"]
|
||||
- id: flake8
|
||||
additional_dependencies: [
|
||||
'flake8-bugbear',
|
||||
'flake8-tuple',
|
||||
]
|
||||
args: ['--config', '.github/helper/.flake8_strict']
|
||||
exclude: ".*setup.py$"
|
||||
|
||||
- id: ruff
|
||||
name: "Run ruff linter"
|
||||
|
||||
- id: ruff-format
|
||||
name: "Run ruff formatter"
|
||||
|
||||
- repo: local
|
||||
- repo: https://github.com/adityahase/black
|
||||
rev: 9cb0a69f4d0030cdf687eddf314468b39ed54119
|
||||
hooks:
|
||||
- id: postgres-compat
|
||||
name: "PostgreSQL compatibility (static check)"
|
||||
description: "Flags MySQL-only SQL that breaks on Postgres; the label-gated PG test job is the backstop for semantic divergences."
|
||||
entry: .github/helper/postgres_compat.py
|
||||
language: script
|
||||
files: ^erpnext/.*\.py$
|
||||
# patches/ are historical, version-gated migrations (skipped on fresh Postgres installs);
|
||||
# out of scope for the always-on gate.
|
||||
exclude: ^erpnext/patches/
|
||||
- id: black
|
||||
additional_dependencies: ['click==8.0.4']
|
||||
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.12.0
|
||||
hooks:
|
||||
- id: isort
|
||||
exclude: ".*setup.py$"
|
||||
|
||||
|
||||
ci:
|
||||
autoupdate_schedule: weekly
|
||||
|
||||
31
CODEOWNERS
31
CODEOWNERS
@@ -3,21 +3,22 @@
|
||||
# These owners will be the default owners for everything in
|
||||
# the repo. Unless a later match takes precedence,
|
||||
|
||||
erpnext/accounts/ @ruthra-kumar
|
||||
erpnext/assets/ @khushi8112
|
||||
erpnext/regional @ruthra-kumar
|
||||
erpnext/selling @ruthra-kumar
|
||||
erpnext/accounts/ @deepeshgarg007 @ruthra-kumar
|
||||
erpnext/assets/ @anandbaburajan @deepeshgarg007
|
||||
erpnext/regional @deepeshgarg007 @ruthra-kumar
|
||||
erpnext/selling @deepeshgarg007 @ruthra-kumar
|
||||
erpnext/support/ @deepeshgarg007
|
||||
pos*
|
||||
|
||||
erpnext/buying/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/maintenance/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/manufacturing/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/quality_management/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/stock/ @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/subcontracting/ @mihir-kandoi
|
||||
erpnext/projects/ @nishkagosalia
|
||||
erpnext/buying/ @rohitwaghchaure @s-aga-r
|
||||
erpnext/maintenance/ @rohitwaghchaure @s-aga-r
|
||||
erpnext/manufacturing/ @rohitwaghchaure @s-aga-r
|
||||
erpnext/quality_management/ @rohitwaghchaure @s-aga-r
|
||||
erpnext/stock/ @rohitwaghchaure @s-aga-r
|
||||
erpnext/subcontracting @rohitwaghchaure @s-aga-r
|
||||
|
||||
erpnext/controllers/ @ruthra-kumar @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/patches/ @ruthra-kumar @rohitwaghchaure @mihir-kandoi
|
||||
erpnext/controllers/ @deepeshgarg007 @rohitwaghchaure
|
||||
erpnext/patches/ @deepeshgarg007
|
||||
|
||||
.github/ @ruthra-kumar @mihir-kandoi
|
||||
pyproject.toml @ruthra-kumar
|
||||
.github/ @deepeshgarg007
|
||||
pyproject.toml @phot0n
|
||||
|
||||
202
README.md
202
README.md
@@ -1,177 +1,87 @@
|
||||
|
||||
<div align="center">
|
||||
<a href="https://frappe.io/erpnext">
|
||||
<img src="./erpnext/public/images/v16/erpnext.svg" alt="ERPNext Logo" height="80px" width="80px"/>
|
||||
<a href="https://erpnext.com">
|
||||
<img src="https://raw.githubusercontent.com/frappe/erpnext/develop/erpnext/public/images/erpnext-logo.png" height="128">
|
||||
</a>
|
||||
<h2>ERPNext</h2>
|
||||
<div align="center">
|
||||
<p>Powerful, Intuitive and Open-Source ERP</p>
|
||||
</div>
|
||||
<p align="center">
|
||||
<p>ERP made simple</p>
|
||||
</p>
|
||||
|
||||
[](https://frappe.school)<br><br>
|
||||
[](https://github.com/frappe/erpnext/actions/workflows/server-tests-mariadb.yml)
|
||||
[](https://hub.docker.com/r/frappe/erpnext)
|
||||
[](https://github.com/frappe/erpnext/actions/workflows/server-tests.yml)
|
||||
[](https://github.com/erpnext/erpnext_ui_tests/actions/workflows/ui-tests.yml)
|
||||
[](https://www.codetriage.com/frappe/erpnext)
|
||||
[](https://codecov.io/gh/frappe/erpnext)
|
||||
[](https://hub.docker.com/r/frappe/erpnext-worker)
|
||||
|
||||
[https://erpnext.com](https://erpnext.com)
|
||||
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<img src="./erpnext/public/images/v16/hero_image.png" alt="ERPNext Hero Image"/>
|
||||
ERPNext as a monolith includes the following areas for managing businesses:
|
||||
|
||||
1. [Accounting](https://erpnext.com/open-source-accounting)
|
||||
1. [Warehouse Management](https://erpnext.com/distribution/warehouse-management-system)
|
||||
1. [CRM](https://erpnext.com/open-source-crm)
|
||||
1. [Sales](https://erpnext.com/open-source-sales-purchase)
|
||||
1. [Purchase](https://erpnext.com/open-source-sales-purchase)
|
||||
1. [HRMS](https://erpnext.com/open-source-hrms)
|
||||
1. [Project Management](https://erpnext.com/open-source-projects)
|
||||
1. [Support](https://erpnext.com/open-source-help-desk-software)
|
||||
1. [Asset Management](https://erpnext.com/open-source-asset-management-software)
|
||||
1. [Quality Management](https://erpnext.com/docs/user/manual/en/quality-management)
|
||||
1. [Manufacturing](https://erpnext.com/open-source-manufacturing-erp-software)
|
||||
1. [Website Management](https://erpnext.com/open-source-website-builder-software)
|
||||
1. [Customize ERPNext](https://erpnext.com/docs/user/manual/en/customize-erpnext)
|
||||
1. [And More](https://erpnext.com/docs/user/manual/en/)
|
||||
|
||||
ERPNext is built on the [Frappe Framework](https://github.com/frappe/frappe), a full-stack web app framework built with Python & JavaScript.
|
||||
|
||||
## Installation
|
||||
|
||||
<div align="center" style="max-height: 40px;">
|
||||
<a href="https://frappecloud.com/erpnext/signup">
|
||||
<img src=".github/try-on-f-cloud-button.svg" height="40">
|
||||
</a>
|
||||
<a href="https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/frappe/frappe_docker/main/pwd.yml">
|
||||
<img src="https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png" alt="Try in PWD" height="37"/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://erpnext-demo.frappe.cloud/api/method/erpnext_demo.erpnext_demo.auth.login_demo">Live Demo</a>
|
||||
-
|
||||
<a href="https://frappe.io/erpnext">Website</a>
|
||||
-
|
||||
<a href="https://docs.frappe.io/erpnext/">Documentation</a>
|
||||
</div>
|
||||
> Login for the PWD site: (username: Administrator, password: admin)
|
||||
|
||||
## ERPNext
|
||||
### Containerized Installation
|
||||
|
||||
100% Open-Source ERP System to help you run your business.
|
||||
Use docker to deploy ERPNext in production or for development of [Frappe](https://github.com/frappe/frappe) apps. See https://github.com/frappe/frappe_docker for more details.
|
||||
|
||||
### Motivation
|
||||
|
||||
Running a business is a complex task - handling invoices, tracking stock, managing personnel, and other daily operations. In a market where software is sold separately to manage each of these tasks, ERPNext does all of the above and more, for free.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Accounting**: All the tools you need to manage cash flow in one place, right from recording transactions to summarizing and analyzing financial reports.
|
||||
- **Order Management**: Track inventory levels, replenish stock, and manage sales orders, customers, suppliers, shipments, deliverables, and order fulfillment.
|
||||
- **Manufacturing**: Simplifies the production cycle, helps track material consumption, exhibits capacity planning, handles subcontracting, and more!
|
||||
- **Asset Management**: From purchase to disposal, IT infrastructure to equipment. Covers every branch of your organization, all in one centralized system.
|
||||
- **Projects**: Deliver both internal and external projects on time, budget and profitability. Track tasks, timesheets, and issues by project.
|
||||
|
||||
<details open>
|
||||
|
||||
<summary>More</summary>
|
||||
<img src="https://erpnext.com/files/v16_bom.png"/>
|
||||
<img src="https://erpnext.com/files/v16_stock_summary.png"/>
|
||||
<img src="https://erpnext.com/files/v16_job_card.png"/>
|
||||
<img src="https://erpnext.com/files/v16_tasks.png"/>
|
||||
</details>
|
||||
|
||||
### Under the Hood
|
||||
|
||||
- [**Frappe Framework**](https://github.com/frappe/frappe): A full-stack web application framework written in Python and JavaScript. The framework provides a robust foundation for building web applications, including a database abstraction layer, user authentication, and a REST API.
|
||||
|
||||
- [**Frappe UI**](https://github.com/frappe/frappe-ui): A Vue-based UI library, to provide a modern user interface. The Frappe UI library provides a variety of components that can be used to build single-page applications on top of the Frappe Framework.
|
||||
|
||||
## Production Setup
|
||||
|
||||
### Managed Hosting
|
||||
|
||||
You can try [Frappe Cloud](https://frappecloud.com), a simple, user-friendly, and sophisticated [open-source](https://github.com/frappe/press) platform to host Frappe applications reliably and securely.
|
||||
|
||||
It handles installation, setup, upgrades, monitoring, maintenance, and support of your Frappe deployments. It is a fully featured developer platform with an ability to manage and control multiple Frappe deployments.
|
||||
|
||||
<div>
|
||||
<a href="https://erpnext-demo.frappe.cloud/app/home" target="_blank" rel="noopener noreferrer">
|
||||
<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>
|
||||
|
||||
|
||||
### Self-Hosted
|
||||
#### Docker
|
||||
|
||||
See [Frappe Docker Documentation](https://github.com/frappe/frappe_docker) for full documentation & FAQ on Docker setup
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [Docker Compose v2](https://docs.docker.com/compose/)
|
||||
- [git](https://docs.github.com/en/get-started/getting-started-with-git/set-up-git)
|
||||
|
||||
> For Docker basics and best practices refer to Docker's [documentation](https://docs.docker.com)
|
||||
|
||||
### Try on your environment
|
||||
|
||||
> **⚠️ Disposable demo only**
|
||||
>
|
||||
> **This setup is intended for quick evaluation. Expect to throw the environment away.** You will not be able to install custom apps to this setup. For production deployments, custom configurations, and detailed explanations, see the full documentation.
|
||||
|
||||
First clone the repo:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/frappe/frappe_docker
|
||||
cd frappe_docker
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```sh
|
||||
docker compose -f pwd.yml up -d
|
||||
```
|
||||
Wait for a couple of minutes for ERPNext site to be created or check the `create-site` container logs before opening browser on port `8080`. (username: `Administrator`, password: `admin`)
|
||||
|
||||
See [Frappe Docker](https://github.com/frappe/frappe_docker/blob/main/docs/01-getting-started/03-arm64.md) for ARM based docker setup
|
||||
|
||||
|
||||
## Development Setup
|
||||
### Manual Install
|
||||
|
||||
The Easy Way: our install script for bench will install all dependencies (e.g. MariaDB). See https://github.com/frappe/bench for more details.
|
||||
|
||||
New passwords will be created for the ERPNext "Administrator" user, the MariaDB root user, and the Frappe user (the script displays the passwords and saves them to ~/frappe_passwords.txt).
|
||||
New passwords will be created for the ERPNext "Administrator" user, the MariaDB root user, and the frappe user (the script displays the passwords and saves them to ~/frappe_passwords.txt).
|
||||
|
||||
|
||||
### Local
|
||||
## Learning and community
|
||||
|
||||
To setup the repository locally follow the steps mentioned below:
|
||||
|
||||
1. Setup bench by following the [Installation Steps](https://frappeframework.com/docs/user/en/installation) and start the server
|
||||
```
|
||||
bench start
|
||||
```
|
||||
|
||||
2. In a separate terminal window, run the following commands:
|
||||
```
|
||||
# Create a new site
|
||||
bench new-site erpnext.localhost
|
||||
```
|
||||
|
||||
3. Get the ERPNext app and install it
|
||||
```
|
||||
# Get the ERPNext app
|
||||
bench get-app https://github.com/frappe/erpnext
|
||||
|
||||
# Install the app
|
||||
bench --site erpnext.localhost install-app erpnext
|
||||
```
|
||||
|
||||
4. Open the URL `http://erpnext.localhost:8000/app` in your browser, you should see the app running
|
||||
|
||||
## Learning and Community
|
||||
|
||||
1. [Frappe School](https://school.frappe.io) - Learn Frappe Framework and ERPNext from the various courses by the maintainers or from the community.
|
||||
1. [Frappe School](https://frappe.school) - Learn Frappe Framework and ERPNext from the various courses by the maintainers or from the community.
|
||||
2. [Official documentation](https://docs.erpnext.com/) - Extensive documentation for ERPNext.
|
||||
3. [Discussion Forum](https://discuss.frappe.io/c/erpnext/6) - Engage with the community of ERPNext users and service providers.
|
||||
3. [Discussion Forum](https://discuss.erpnext.com/) - Engage with community of ERPNext users and service providers.
|
||||
4. [Telegram Group](https://erpnext_public.t.me) - Get instant help from huge community of users.
|
||||
|
||||
|
||||
## Contributing
|
||||
|
||||
1. [Issue Guidelines](https://github.com/frappe/erpnext/wiki/Issue-Guidelines)
|
||||
2. [Report Security Vulnerabilities](https://erpnext.com/security)
|
||||
3. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
|
||||
4. [Translations](https://crowdin.com/project/frappe)
|
||||
1. [Report Security Vulnerabilities](https://erpnext.com/security)
|
||||
1. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
|
||||
|
||||
## License
|
||||
|
||||
GNU/General Public License (see [license.txt](license.txt))
|
||||
|
||||
The ERPNext code is licensed as GNU General Public License (v3) and the Documentation is licensed as Creative Commons (CC-BY-SA-3.0) and the copyright is owned by Frappe Technologies Pvt Ltd (Frappe) and Contributors.
|
||||
|
||||
By contributing to ERPNext, you agree that your contributions will be licensed under its GNU General Public License (v3).
|
||||
|
||||
## Logo and Trademark Policy
|
||||
|
||||
Please read our [Logo and Trademark Policy](TRADEMARK_POLICY.md).
|
||||
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
The ERPNext team and community take security issues seriously. To report a security issue, please go through the information mentioned [here](https://frappe.io/security).
|
||||
The ERPNext team and community take security issues seriously. To report a security issue, fill out the form at [https://erpnext.com/security/report](https://erpnext.com/security/report).
|
||||
|
||||
You can help us make ERPNext and all its users more secure by following the [Reporting guidelines](https://frappe.io/security).
|
||||
You can help us make ERPNext and all it's users more secure by following the [Reporting guidelines](https://erpnext.com/security).
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly, and will keep you updated throughout the process.
|
||||
We appreciate your efforts to responsibly disclose your findings. We'll endeavor to respond quickly, and will keep you updated throughout the process.
|
||||
@@ -18,9 +18,8 @@ We will grant permission to use the ERPNext name and logo for projects that meet
|
||||
|
||||
- The primary purpose of your project is to promote the spread and improvement of the ERPNext software.
|
||||
- Your project is non-commercial in nature (it can make money to cover its costs or contribute to non-profit entities, but it cannot be run as a for-profit project or business).
|
||||
- Your project neither promotes nor is associated with entities that currently fail to comply with the GPL license under which ERPNext is distributed.
|
||||
|
||||
If your project meets these criteria, you will be permitted to use the ERPNext name and logo to promote your project in any way you see fit with one exception: Please do not use ERPNext as part of a domain name.
|
||||
Your project neither promotes nor is associated with entities that currently fail to comply with the GPL license under which ERPNext is distributed.
|
||||
- If your project meets these criteria, you will be permitted to use the ERPNext name and logo to promote your project in any way you see fit with one exception: Please do not use ERPNext as part of a domain name.
|
||||
|
||||
Use of the ERPNext name and logo is additionally allowed in the following situations:
|
||||
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
**/setup/setup_wizard/data/uom_data.json,erpnext.gettext.extractors.uom_data.extract
|
||||
**/setup/doctype/incoterm/incoterms.csv,erpnext.gettext.extractors.incoterms.extract
|
||||
**/setup/setup_wizard/data/*.txt,erpnext.gettext.extractors.lines_from_txt_file.extract
|
||||
**.tsx,frappe.gettext.extractors.html_template.extract
|
||||
**.ts,frappe.gettext.extractors.html_template.extract
|
||||
|
@@ -1 +0,0 @@
|
||||
VITE_BASE_NAME="banking"
|
||||
24
banking/.gitignore
vendored
24
banking/.gitignore
vendored
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,73 +0,0 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": "off",
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -1,50 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ lang }}" dir="{{layout_direction}}">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- Chrome, Firefox OS and Opera -->
|
||||
<meta name="theme-color" content="#0089FF">
|
||||
<!-- Windows Phone -->
|
||||
<meta name="msapplication-navbutton-color" content="#0089FF">
|
||||
<!-- iOS Safari -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="#0089FF">
|
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="utf-8" http-equiv="encoding">
|
||||
<meta name="author" content="">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,
|
||||
maximum-scale=1.0, minimum-scale=1.0, user-scalable=no, minimal-ui">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<link rel="shortcut icon" href="{{ favicon or ' /assets/erpnext/images/erpnext-favicon.svg' }}" type="image/x-icon">
|
||||
<link rel="icon" href="{{ favicon or ' /assets/erpnext/images/erpnext-favicon.svg' }}" type="image/x-icon">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Banking | {{ app_name }}</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>window.csrf_token = '{{ frappe.session.csrf_token }}';
|
||||
if (!window.frappe) window.frappe = {};
|
||||
|
||||
frappe.boot = JSON.parse({{ boot }});
|
||||
frappe.boot.layout_direction = "{{ layout_direction }}";
|
||||
|
||||
frappe._translations_loaded = fetch(
|
||||
`/api/method/frappe.translate.get_boot_translations?v=${frappe.boot.translations_version}&lang=${frappe.boot.lang}`,
|
||||
{
|
||||
credentials: "same-origin",
|
||||
headers: {
|
||||
"X-Frappe-CSRF-Token": frappe.csrf_token,
|
||||
"Accept": "application/json"
|
||||
}
|
||||
}
|
||||
).then(r => r.json()).then(data => {
|
||||
frappe._messages = data.message || {};
|
||||
}).catch(() => { });
|
||||
</script>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"name": "banking",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --base=/assets/erpnext/banking/ && yarn copy-html-entry",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"copy-html-entry": "cp ../erpnext/public/banking/index.html ../erpnext/www/banking.html"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"frappe-react-sdk": "^1.15.0",
|
||||
"fuse.js": "^7.3.0",
|
||||
"jotai": "^2.20.0",
|
||||
"jotai-family": "^1.0.1",
|
||||
"lodash.isplainobject": "^4.0.6",
|
||||
"lucide-react": "^1.14.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-currency-input-field": "^4.0.5",
|
||||
"react-day-picker": "9.14.0",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.75.0",
|
||||
"react-hotkeys-hook": "^5.3.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.48.0"
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const common_site_config = JSON.parse(
|
||||
readFileSync(new URL('../../../sites/common_site_config.json', import.meta.url), 'utf8')
|
||||
) as { webserver_port: string | number };
|
||||
const { webserver_port } = common_site_config;
|
||||
|
||||
export default {
|
||||
'^/(app|api|assets|files|private)': {
|
||||
target: `http://127.0.0.1:${webserver_port}`,
|
||||
ws: true,
|
||||
router: function (req) {
|
||||
const site_name = req.headers?.host?.split(':')[0];
|
||||
return `http://${site_name ?? 'localhost'}:${webserver_port}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,65 +0,0 @@
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer'
|
||||
import { TooltipProvider } from './components/ui/tooltip'
|
||||
import { LucideProvider } from 'lucide-react'
|
||||
import { ThemeProvider } from './components/ui/theme-provider'
|
||||
|
||||
const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter'))
|
||||
const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog'))
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
// Check if user is logged in by checking the Cookie "user_id"
|
||||
// In Frappe, unauthenticated users are "Guest"
|
||||
const userId = document.cookie?.split('; ').find(row => row.startsWith('user_id='))?.split('=')[1]?.trim()
|
||||
const isLoggedIn = userId !== 'Guest'
|
||||
|
||||
if (!isLoggedIn) {
|
||||
if (import.meta.env.DEV) {
|
||||
return
|
||||
}
|
||||
// Redirect to Frappe login page
|
||||
window.location.href = '/login?redirect-to=/banking'
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<LucideProvider
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<FrappeProvider
|
||||
swrConfig={{
|
||||
errorRetryCount: 2
|
||||
}}
|
||||
socketPort={import.meta.env.VITE_SOCKET_PORT}
|
||||
siteName={window.frappe?.boot?.sitename ?? import.meta.env.VITE_SITE_NAME}>
|
||||
<ThemeProvider
|
||||
defaultTheme={window.frappe?.boot?.desk_theme ?? "Automatic"}
|
||||
>
|
||||
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
|
||||
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
|
||||
<Routes>
|
||||
<Route index element={<BankReconciliation />} />
|
||||
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
|
||||
<Route index element={<BankStatementImporter />} />
|
||||
<Route path=":id" element={<ViewBankStatementImportLog />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
}
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
</FrappeProvider>
|
||||
</TooltipProvider>
|
||||
</LucideProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,228 +0,0 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useFrappeGetDocList } from "frappe-react-sdk"
|
||||
import Fuse from "fuse.js"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react"
|
||||
import { FormControl } from "../ui/form"
|
||||
|
||||
|
||||
export interface AccountsDropdownProps {
|
||||
root_type?: ('Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense')[],
|
||||
report_type?: 'Balance Sheet' | 'Profit and Loss',
|
||||
account_type?: string[],
|
||||
value?: string,
|
||||
onChange?: (value: string) => void,
|
||||
readOnly?: boolean,
|
||||
disabled?: boolean,
|
||||
company?: string,
|
||||
filterFunction?: (account: Account) => boolean,
|
||||
// If true, the component will be wrapped in a FormControl component
|
||||
useInForm?: boolean,
|
||||
buttonClassName?: string,
|
||||
size?: 'sm' | 'md' | 'lg',
|
||||
}
|
||||
/**
|
||||
* Component to select an account - supports fuzzy search
|
||||
* @param root_type - The root type of the account
|
||||
* @param report_type - The report type of the account
|
||||
* @param account_type - The type of the account
|
||||
* @param value - The value of the account field
|
||||
* @param onChange - The function to call when the value changes
|
||||
* @returns
|
||||
*/
|
||||
const AccountsDropdown = ({ root_type, report_type, account_type, value, onChange, readOnly, disabled, company, filterFunction, useInForm, buttonClassName, size = 'md' }: AccountsDropdownProps) => {
|
||||
|
||||
const { data } = useGetAccounts(root_type, report_type, account_type, company, filterFunction)
|
||||
|
||||
const groupedAccounts = useMemo(() => {
|
||||
if (!data) return []
|
||||
|
||||
const grouped: Record<string, Account[]> = data.reduce((acc, account) => {
|
||||
const parentAccount = account.parent_account
|
||||
if (!parentAccount) return acc
|
||||
|
||||
if (!acc[parentAccount]) {
|
||||
acc[parentAccount] = []
|
||||
}
|
||||
|
||||
acc[parentAccount].push(account)
|
||||
return acc
|
||||
}, {} as Record<string, Account[]>)
|
||||
|
||||
|
||||
return Object.entries(grouped).map(([parentAccount, accounts]) => ({
|
||||
// Remove the last abbreviation from the parent account name like "Assets - TCC" should be "Assets", and "Assets - USD - TCC" should be "Assets - USD"
|
||||
parentAccount: parentAccount.split(" - ").slice(0, -1).join(" - "),
|
||||
accounts
|
||||
}))
|
||||
|
||||
}, [data])
|
||||
|
||||
const searchIndex = useMemo(() => {
|
||||
|
||||
if (!data) {
|
||||
return null
|
||||
}
|
||||
|
||||
return new Fuse(data, {
|
||||
keys: ['name'],
|
||||
threshold: 0.5,
|
||||
includeScore: true
|
||||
})
|
||||
}, [data])
|
||||
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const recommendedAccounts = useMemo(() => {
|
||||
|
||||
if (!searchIndex || !search) {
|
||||
return []
|
||||
}
|
||||
|
||||
return searchIndex.search(search).map((result) => result.item)
|
||||
|
||||
}, [searchIndex, search])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (readOnly) return
|
||||
setOpen(open)
|
||||
// setSearch("")
|
||||
}
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
onChange?.(value)
|
||||
setOpen(false)
|
||||
setSearch(value)
|
||||
}
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [width, setWidth] = useState(320)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (buttonRef.current) {
|
||||
setWidth(buttonRef.current.getBoundingClientRect().width)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
{useInForm ? <FormControl>
|
||||
<Button
|
||||
variant="subtle"
|
||||
type='button'
|
||||
size={size}
|
||||
role="combobox"
|
||||
ref={buttonRef}
|
||||
tabIndex={0}
|
||||
disabled={disabled || readOnly}
|
||||
aria-readonly={readOnly}
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between font-normal",
|
||||
readOnly ? "bg-surface-gray-1 pointer-events-none" : ""
|
||||
, buttonClassName)}>
|
||||
{value || _('Select Account')}
|
||||
|
||||
<ChevronDownIcon className="ms-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
: <Button
|
||||
variant="subtle"
|
||||
size={size}
|
||||
type='button'
|
||||
role="combobox"
|
||||
ref={buttonRef}
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between font-normal",
|
||||
readOnly ? "bg-surface-gray-1" : ""
|
||||
)}>
|
||||
{value || _('Select Account')}
|
||||
|
||||
<ChevronDownIcon className="ms-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
|
||||
<Command shouldFilter={false} className="w-full">
|
||||
<CommandInput placeholder={_("Search account...")} onValueChange={setSearch} value={search} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{_("No accounts found.")}</CommandEmpty>
|
||||
|
||||
{recommendedAccounts.length > 0 && (
|
||||
<CommandGroup heading={_("Search Results")}>
|
||||
{recommendedAccounts.map((account) => (
|
||||
<CommandItem key={account.name} onSelect={() => onSelect(account.name)}>{account.name}</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{!search && groupedAccounts.map((group) => (
|
||||
<CommandGroup key={group.parentAccount} heading={group.parentAccount}>
|
||||
{group.accounts.map((account) => (
|
||||
<CommandItem key={account.name} onSelect={() => onSelect(account.name)}>{account.name}</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
interface Account {
|
||||
name: string
|
||||
root_type: 'Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense'
|
||||
report_type: 'Balance Sheet' | 'Profit and Loss'
|
||||
account_type: string
|
||||
account_currency: string
|
||||
parent_account: string
|
||||
}
|
||||
|
||||
export const useGetAccounts = (root_type?: ('Asset' | 'Liability' | 'Equity' | 'Income' | 'Expense')[], report_type?: 'Balance Sheet' | 'Profit and Loss', account_type?: string[], company?: string,
|
||||
filterFunction?: (account: Account) => boolean) => {
|
||||
|
||||
const currentCompany = useCurrentCompany()
|
||||
const { data, isLoading, error, mutate } = useFrappeGetDocList<Account>("Account", {
|
||||
fields: ["name", "root_type", "report_type", "account_type", "account_currency", "parent_account"],
|
||||
filters: [["is_group", "=", 0], ["disabled", "=", 0], ["company", "=", company ?? currentCompany]],
|
||||
limit: 1000,
|
||||
orderBy: {
|
||||
"field": "root_type",
|
||||
// @ts-expect-error - we can pass in additional fields to orderBy
|
||||
"order": "asc, account_number asc"
|
||||
}
|
||||
}, `accounts-${company ?? currentCompany}`, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
|
||||
return data?.filter((account) => {
|
||||
if (root_type && !root_type.includes(account.root_type)) return false
|
||||
if (report_type && account.report_type !== report_type) return false
|
||||
if (account_type && !account_type.includes(account.account_type)) return false
|
||||
|
||||
if (filterFunction) return filterFunction(account)
|
||||
return true
|
||||
}) ?? []
|
||||
|
||||
}, [data, root_type, report_type, account_type, filterFunction])
|
||||
|
||||
return { data: filteredData, isLoading, error, mutate }
|
||||
}
|
||||
|
||||
export default AccountsDropdown
|
||||
@@ -1,26 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SelectedBank } from '../features/BankReconciliation/bankRecAtoms'
|
||||
import { useTheme } from '../ui/theme-provider'
|
||||
import { Landmark } from 'lucide-react'
|
||||
import { H4 } from '../ui/typography'
|
||||
|
||||
const BankLogo = ({ bank, className, imageClassName, iconSize = '18px', iconClassName }: { bank?: SelectedBank | null, className?: string, imageClassName?: string, iconSize?: string, iconClassName?: string }) => {
|
||||
|
||||
const { themeValue } = useTheme()
|
||||
return (
|
||||
<div className={cn('h-6 flex items-center gap-1', className)}> {bank?.logo ? <img
|
||||
src={`/assets/erpnext/images/bank-logos/${themeValue === 'Dark' ? (bank.logoDark ?? bank.logo) : bank.logo}`}
|
||||
alt={bank.bank || bank.name || ''}
|
||||
className={cn("h-6 max-w-22 me-auto object-contain", imageClassName, {
|
||||
'dark:invert dark:brightness-0': bank.darkModeInvert
|
||||
}, bank.logoClassName)}
|
||||
/> : <>
|
||||
<Landmark size={iconSize} className={iconClassName} />
|
||||
<H4 className={cn("text-xs -mb-0.5", {
|
||||
})}>{bank?.bank ?? ''}</H4>
|
||||
</>
|
||||
}</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankLogo
|
||||
@@ -1,17 +0,0 @@
|
||||
import { CheckCircle } from 'lucide-react'
|
||||
import { Progress } from '../ui/progress'
|
||||
import _ from '@/lib/translate'
|
||||
|
||||
const FileUploadBanner = ({
|
||||
uploadProgress,
|
||||
}: { uploadProgress: number }) => {
|
||||
return <div className="flex items-center justify-center flex-col gap-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<CheckCircle size={48} className="text-ink-green-3" />
|
||||
<span className="text-ink-gray-8 text-p-base">{_("The document has been created and reconciled. Uploading attachments...")}</span>
|
||||
<Progress value={Math.round(uploadProgress * 100)} size="lg" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default FileUploadBanner
|
||||
@@ -1,301 +0,0 @@
|
||||
import { useDocType } from "@/hooks/useDocType";
|
||||
import { getSystemDefault, slug } from "@/lib/frappe";
|
||||
import { Filter, useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { canCreateDocument } from "@/lib/permissions";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
import { FormControl } from "../ui/form";
|
||||
import { ChevronDownIcon, ExternalLink } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "../ui/command";
|
||||
import _ from "@/lib/translate";
|
||||
import ErrorBanner from "../ui/error-banner";
|
||||
import MarkdownRenderer from "../ui/markdown";
|
||||
|
||||
export interface ResultItem {
|
||||
value: string,
|
||||
description: string,
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface LinkFieldComboboxProps {
|
||||
/** DocType to be fetched */
|
||||
doctype: string;
|
||||
/** Filters to be applied. Default: none */
|
||||
filters?: Filter[]
|
||||
/** Number of records to paginate with. Default: Comes from System Settings or 10 */
|
||||
limit?: number;
|
||||
/**
|
||||
* API to call to fetch records.
|
||||
*
|
||||
* Default: `frappe.desk.search.search_link`
|
||||
*
|
||||
* If you want to use a custom API, you can pass the path to the API here.
|
||||
*
|
||||
* The API should return a list of documents in the following format:
|
||||
* [{value: string, description: string, label?: string}] - where the value is the ID of the document.
|
||||
*
|
||||
* If the API sends a label, it will be used as the label in the dropdown.
|
||||
*/
|
||||
searchAPIPath?: string;
|
||||
/**
|
||||
* Field you want to search against in the doctype.
|
||||
*
|
||||
* Default: `name`
|
||||
*
|
||||
* If you want to search against a different field, you can pass the fieldname here.
|
||||
*
|
||||
* If you want to search against multiple fields, you can try using the `searchAPIPath` prop to call a custom API,
|
||||
* or use a custom query in the `customQuery` prop.
|
||||
*/
|
||||
searchfield?: string;
|
||||
/**
|
||||
* Custom query to be used to fetch records.
|
||||
*
|
||||
* If you want to use a custom query, you can pass the query here.
|
||||
*
|
||||
* The query should be in the following format:
|
||||
* {
|
||||
* query: string,
|
||||
* filters: {
|
||||
* fieldname: string,
|
||||
* operator: string,
|
||||
* value: string
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
customQuery?: {
|
||||
/** Path to function for the query.
|
||||
*
|
||||
* Refer: Item/Supplier query
|
||||
*/
|
||||
query: string,
|
||||
/** Filters are usually an object instead of an array in a custom query */
|
||||
filters?: Record<string, string | number | boolean>,
|
||||
},
|
||||
/**
|
||||
* Used for certain queries where a reference doctype is needed.
|
||||
*
|
||||
* For example when searching a supplier in a "Purchase Invoice", the reference_doctype is "Purchase Invoice"
|
||||
*/
|
||||
reference_doctype?: string,
|
||||
/** Placeholder for the dropdown. Default: `doctype` */
|
||||
placeholder?: string;
|
||||
/**
|
||||
* Should the field be read-only.
|
||||
*/
|
||||
readOnly?: boolean;
|
||||
/** Should the field be disabled. Default: false */
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Function to filter the options based on the input value/other criteria.
|
||||
*
|
||||
* For example, you might want to limit the companies shown in the dropdown since they have been already added (like in Cost Codes)
|
||||
*/
|
||||
filterFn?: (option: ResultItem, inputValue: string) => boolean,
|
||||
value?: string,
|
||||
onChange: (value: string) => void,
|
||||
/** If true, the component will be wrapped in a FormControl component */
|
||||
useInForm?: boolean,
|
||||
/** Button Class name */
|
||||
buttonClassName?: string,
|
||||
size?: 'sm' | 'md' | 'lg',
|
||||
}
|
||||
const LinkFieldCombobox = ({
|
||||
doctype,
|
||||
reference_doctype,
|
||||
filters = [],
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
disabled,
|
||||
filterFn,
|
||||
placeholder = `Select ${doctype}`,
|
||||
customQuery,
|
||||
searchfield,
|
||||
searchAPIPath = "frappe.desk.search.search_link",
|
||||
limit,
|
||||
useInForm,
|
||||
buttonClassName,
|
||||
size = 'md'
|
||||
}: LinkFieldComboboxProps) => {
|
||||
|
||||
const pageLimit = useMemo(() => limit || getSystemDefault('link_field_results_limit') || 20, [limit])
|
||||
|
||||
/** Load the Doctype meta so that we can determine the search fields + the name of the title field */
|
||||
const { data: meta } = useDocType(doctype)
|
||||
|
||||
const userCanCreate = useMemo(() => canCreateDocument(doctype), [doctype])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const [searchInput, setSearchInput] = useDebounceValue('', 400)
|
||||
|
||||
const { data: linkTitleData } = useFrappeGetCall('frappe.client.get_value', {
|
||||
doctype,
|
||||
filters: JSON.stringify({
|
||||
name: value
|
||||
}),
|
||||
fieldname: meta?.title_field
|
||||
}, (meta?.show_title_field_in_link ?? false) && (meta?.title_field) && value ? `link_title::${doctype}::${value}` : null, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const linkTitle = meta?.title_field && meta?.show_title_field_in_link ? (linkTitleData?.message?.[meta?.title_field] ?? value) : value
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const [width, setWidth] = useState(320)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (buttonRef.current) {
|
||||
setWidth(buttonRef.current.getBoundingClientRect().width)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const { data, error, isLoading } = useFrappeGetCall<{ message: ResultItem[] }>(searchAPIPath, {
|
||||
doctype,
|
||||
txt: searchInput,
|
||||
page_length: pageLimit,
|
||||
query: customQuery?.query,
|
||||
searchfield,
|
||||
filters: JSON.stringify(customQuery?.filters || filters || []),
|
||||
reference_doctype,
|
||||
}, () => {
|
||||
if (!open) {
|
||||
return null
|
||||
} else {
|
||||
let key = `${searchAPIPath}_${doctype}_${searchInput}`
|
||||
|
||||
if (pageLimit) {
|
||||
key += `_${pageLimit}`
|
||||
}
|
||||
|
||||
if (customQuery?.filters) {
|
||||
key += `_${JSON.stringify(customQuery.filters)}`
|
||||
} else if (filters) {
|
||||
key += `_${JSON.stringify(filters)}`
|
||||
}
|
||||
|
||||
if (customQuery && customQuery.query) {
|
||||
key += `_${customQuery.query}`
|
||||
}
|
||||
|
||||
if (reference_doctype) {
|
||||
key += `_${reference_doctype}`
|
||||
}
|
||||
|
||||
if (searchfield && searchfield !== 'name') {
|
||||
key += `_${searchfield}`
|
||||
}
|
||||
|
||||
return key
|
||||
|
||||
}
|
||||
}, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
shouldRetryOnError: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const onOpenChange = (open: boolean) => {
|
||||
if (readOnly) return
|
||||
setOpen(open)
|
||||
setSearchInput("")
|
||||
}
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
onChange?.(value)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const items = filterFn ? data?.message?.slice(0, 50).filter((item) => filterFn(item, searchInput)) : data?.message
|
||||
|
||||
const buttonProps = {
|
||||
variant: "subtle",
|
||||
type: 'button',
|
||||
size: size,
|
||||
role: "combobox",
|
||||
"data-state": open ? "open" : "closed",
|
||||
ref: buttonRef,
|
||||
tabIndex: 0,
|
||||
disabled: disabled || readOnly,
|
||||
"aria-expanded": open,
|
||||
"aria-readonly": readOnly,
|
||||
className: cn("w-full justify-between font-normal group border border-transparent outline-none",
|
||||
"data-[state=open]:bg-surface-white data-[state=open]:border-outline-gray-4 data-[state=open]:shadow-sm",
|
||||
readOnly ? "bg-surface-gray-1" : "",
|
||||
// Placeholder and value styling
|
||||
linkTitle ? "text-ink-gray-7" : "text-ink-gray-4",
|
||||
buttonClassName)
|
||||
} as const
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={onOpenChange} modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
{useInForm ? <FormControl>
|
||||
<Button {...buttonProps}>
|
||||
{linkTitle || placeholder}
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{value && <a href={`/desk/${slug(doctype)}/${value}`} target="_blank" className="group-hover:block hidden">
|
||||
<ExternalLink className="size-4 shrink-0 opacity-50" />
|
||||
</a>}
|
||||
<ChevronDownIcon className="ms-2 size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>
|
||||
</FormControl>
|
||||
: <Button {...buttonProps}>
|
||||
{linkTitle || placeholder}
|
||||
<div className="flex items-center gap-1">
|
||||
{value && <a href={`/desk/${slug(doctype)}/${value}`} target="_blank" className="group-hover:block hidden">
|
||||
<ExternalLink className="size-4 shrink-0 opacity-50" />
|
||||
</a>}
|
||||
<ChevronDownIcon className="ms-2 size-4 shrink-0" />
|
||||
</div>
|
||||
</Button>}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" style={{ minWidth: width }} align="start">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<Command shouldFilter={false} className="w-full">
|
||||
<CommandInput placeholder={placeholder} onValueChange={setSearchInput} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{isLoading ? _("Loading...") : _("No results found.")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{items?.map((result) => (
|
||||
<CommandItem key={result.value} onSelect={() => onSelect(result.value)} className="flex flex-col items-start gap-0.5">
|
||||
<span className="font-medium">
|
||||
{result.label || result.value}
|
||||
</span>
|
||||
{result.description && <span className="text-xs text-ink-gray-5">
|
||||
<MarkdownRenderer content={result.description} />
|
||||
</span>}
|
||||
</CommandItem>
|
||||
))}
|
||||
{userCanCreate && <CommandItem asChild>
|
||||
<a href={`/desk/${slug(doctype)}/new-${slug(doctype)}-1`}
|
||||
target="_blank"
|
||||
className="hover:underline underline-offset-4 cursor-pointer flex justify-between items-center">
|
||||
{_("Create New {0}", [doctype])}
|
||||
|
||||
<ExternalLink />
|
||||
</a>
|
||||
|
||||
</CommandItem>}
|
||||
</CommandGroup>
|
||||
|
||||
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default LinkFieldCombobox
|
||||
@@ -1,82 +0,0 @@
|
||||
import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select'
|
||||
import _ from '@/lib/translate'
|
||||
import { useFrappeGetDocList } from 'frappe-react-sdk'
|
||||
import { ComponentProps, useMemo } from 'react'
|
||||
import { FormControl } from '../ui/form'
|
||||
|
||||
export type PartyTypeDropdownProps = {
|
||||
value?: string,
|
||||
onChange?: (value: string) => void,
|
||||
readOnly?: boolean,
|
||||
disabled?: boolean,
|
||||
/** Set this to order the parties so that suggested types are shown first */
|
||||
type?: 'Receivable' | 'Payable'
|
||||
/** Set this to true if you want to hide other options by type. e.g. - if type is Receivable, Payable options like "Supplier" will be hidden */
|
||||
hideOptionsByType?: boolean,
|
||||
valueProps?: ComponentProps<typeof SelectValue>,
|
||||
triggerProps?: ComponentProps<typeof SelectTrigger>,
|
||||
// If true, the component will be wrapped in a FormControl component
|
||||
useInForm?: boolean
|
||||
}
|
||||
|
||||
const PartyTypeDropdown = ({ value, onChange, readOnly, disabled, type, hideOptionsByType, valueProps, triggerProps, useInForm }: PartyTypeDropdownProps) => {
|
||||
|
||||
const { data } = useFrappeGetDocList("Party Type", {
|
||||
fields: ['name', 'account_type'],
|
||||
orderBy: {
|
||||
field: 'creation',
|
||||
order: 'asc'
|
||||
}
|
||||
}, `party_types`, {
|
||||
revalidateIfStale: false,
|
||||
revalidateOnFocus: false,
|
||||
revalidateOnReconnect: false,
|
||||
})
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
|
||||
let options = data ?? [
|
||||
{ name: "Customer", account_type: "Receivable" },
|
||||
{ name: "Supplier", account_type: "Payable" },
|
||||
{ name: "Employee", account_type: "Payable" },
|
||||
{ name: "Shareholder", account_type: "Payable" },
|
||||
]
|
||||
|
||||
if (hideOptionsByType && type) {
|
||||
options = options.filter((option) => option.account_type === type)
|
||||
}
|
||||
|
||||
// Order by type if type is set
|
||||
if (type) {
|
||||
options = options.sort((a) => a.account_type === type ? -1 : 1)
|
||||
}
|
||||
|
||||
return options
|
||||
}, [data, type, hideOptionsByType])
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
if (!readOnly) {
|
||||
onChange?.(value)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Select onValueChange={onSelect} value={value} disabled={disabled}>
|
||||
{useInForm ? <FormControl>
|
||||
<SelectTrigger tabIndex={0} aria-readonly={readOnly} disabled={disabled || readOnly} {...triggerProps}>
|
||||
<SelectValue placeholder={_("Type")} aria-readonly={readOnly} {...valueProps} />
|
||||
</SelectTrigger>
|
||||
</FormControl> : <SelectTrigger tabIndex={0} {...triggerProps}>
|
||||
<SelectValue placeholder={_("Type")} aria-readonly={readOnly} {...valueProps} />
|
||||
</SelectTrigger>
|
||||
}
|
||||
<SelectContent>
|
||||
{filteredData.map((option) => (
|
||||
<SelectItem key={option.name} value={option.name}>{option.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
export default PartyTypeDropdown
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { HistoryIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import ActionLogDialog from './ActionLogDialog'
|
||||
|
||||
const ActionLog = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useHotkeys('meta+z', () => {
|
||||
setIsOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<HistoryIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Reconciliation History")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOpen && (
|
||||
<ActionLogDialog onClose={() => setIsOpen(false)} />
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionLog
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const ActionLogDialogBody = lazy(() => import('./ActionLogDialogBody'))
|
||||
|
||||
const ActionLogDialogFallback = () => (
|
||||
<div className="flex flex-1 items-center justify-center min-h-[40vh]">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ActionLogDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
return (
|
||||
<DialogContent className='min-w-[90vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
|
||||
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Suspense fallback={<ActionLogDialogFallback />}>
|
||||
<ActionLogDialogBody />
|
||||
</Suspense>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} size='md' onClick={onClose}>{_("Close")}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionLogDialog
|
||||
@@ -1,431 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
|
||||
import { useGetBankAccounts } from '../BankReconciliation/utils'
|
||||
import { getCompanyCurrency } from '@/lib/company'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import dayjs from 'dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { slug } from '@/lib/frappe'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { JournalEntry } from '@/types/Accounts/JournalEntry'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import { getErrorMessage } from '@/lib/frappe'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
const ActionLogDialogBody = () => {
|
||||
|
||||
const actionLog = useAtomValue(bankRecActionLog)
|
||||
|
||||
return <div className='flex flex-col gap-2'>
|
||||
{actionLog.map((action) => (
|
||||
<div key={action.timestamp} className='flex flex-col gap-1'>
|
||||
<ActionGroupHeader action={action} />
|
||||
<div>
|
||||
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
|
||||
<div className='ms-5'>
|
||||
{action.items.map((item, index) => (
|
||||
<Row
|
||||
item={item}
|
||||
key={item.bankTransaction.name}
|
||||
index={index}
|
||||
action={action}
|
||||
isLast={index === action.items.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{actionLog.length === 0 && <Empty>
|
||||
<EmptyMedia>
|
||||
<HistoryIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
|
||||
|
||||
const label = useMemo(() => {
|
||||
switch (action.type) {
|
||||
case 'match':
|
||||
return _("Matched")
|
||||
case 'payment':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Payment")
|
||||
}
|
||||
return _("Payment")
|
||||
|
||||
case 'transfer':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Transfer")
|
||||
}
|
||||
return _("Transfer")
|
||||
|
||||
case 'bank_entry':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Bank Entry")
|
||||
}
|
||||
return _("Bank Entry")
|
||||
|
||||
default:
|
||||
return _("Action")
|
||||
}
|
||||
}, [action])
|
||||
|
||||
return <div className='flex items-center gap-2 text-ink-gray-5'>
|
||||
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
|
||||
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
|
||||
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
|
||||
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
|
||||
<span className='flex items-center gap-2 text-sm'>
|
||||
{label} - {dayjs(action.timestamp).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
|
||||
|
||||
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (item.bankTransaction.bank_account) {
|
||||
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [item.bankTransaction.bank_account, banks])
|
||||
|
||||
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
|
||||
|
||||
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
|
||||
|
||||
return <div className='flex items-center gap-2 group'>
|
||||
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-p-base'>{item.bankTransaction.description}</p>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
|
||||
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
|
||||
<CalendarIcon className='w-4 h-4' />
|
||||
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<div>
|
||||
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end items-center gap-2'>
|
||||
<div className='text-end flex flex-col gap-2'>
|
||||
<a
|
||||
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
|
||||
target='_blank'
|
||||
className='underline underline-offset-4 text-base'>
|
||||
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
|
||||
</a>
|
||||
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
|
||||
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-10 h-10 flex items-center justify-center'>
|
||||
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||
|
||||
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
|
||||
<WalletIcon className='w-4 h-4' />
|
||||
<JournalEntryAccountsTable item={item} bank={bank} />
|
||||
</div>
|
||||
}
|
||||
|
||||
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||
|
||||
const accounts = useMemo(() => {
|
||||
|
||||
const allAccounts = (item.voucher.doc as JournalEntry).accounts
|
||||
|
||||
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
|
||||
|
||||
}, [item, bank])
|
||||
|
||||
return <>
|
||||
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='w-full p-2' align='end'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Debit")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.account}>
|
||||
<TableCell>{account.account}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
}</>
|
||||
}
|
||||
|
||||
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
|
||||
return <TransferDetails item={item} className={className} />
|
||||
}
|
||||
|
||||
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
|
||||
|
||||
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
|
||||
|
||||
return <div className='flex items-center gap-3'>
|
||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<UserIcon className='w-4 h-4' />
|
||||
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<ReceiptTextIcon className='w-4 h-4' />
|
||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='w-full p-2' align='end'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{invoices.map((invoice) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Document")}</TableHead>
|
||||
<TableHead>{_("Invoice No")}</TableHead>
|
||||
<TableHead>{_("Due Date")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Allocated")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
|
||||
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
|
||||
<TableCell>{formatDate(invoice.due_date)}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
|
||||
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
|
||||
|
||||
let transferAccount = ""
|
||||
|
||||
if (isWithdrawal) {
|
||||
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
|
||||
} else {
|
||||
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
|
||||
}
|
||||
|
||||
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
|
||||
|
||||
return transferBankAccount
|
||||
|
||||
}, [banks, item])
|
||||
|
||||
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
|
||||
<span className='text-sm'>{bank?.account}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ACTION_TYPE_MAP = {
|
||||
'bank_entry': _("Bank Entry"),
|
||||
'payment': _("Payment"),
|
||||
'transfer': _("Transfer"),
|
||||
'match': _("Match"),
|
||||
}
|
||||
|
||||
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
|
||||
const { mutate } = useSWRConfig()
|
||||
const actionLog = useSetAtom(bankRecActionLog)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const matchFilters = useAtomValue(bankRecMatchFilters)
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const onUndo = () => {
|
||||
call({
|
||||
bank_transaction_id: item.bankTransaction.name,
|
||||
voucher_type: item.voucher.reference_doctype,
|
||||
voucher_id: item.voucher.reference_name,
|
||||
}).then(() => {
|
||||
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
|
||||
|
||||
if (selectedBank?.name === item.bankTransaction.bank_account) {
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
||||
// Update the matching vouchers for the selected transaction
|
||||
mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
actionLog((prev) => {
|
||||
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
|
||||
const action = prev.find((action) => action.timestamp === timestamp)
|
||||
|
||||
if (action) {
|
||||
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
|
||||
}
|
||||
// If the action is empty, remove the action from the array
|
||||
if (action && action.items.length === 0) {
|
||||
return prev.filter((a) => a.timestamp !== timestamp)
|
||||
} else {
|
||||
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
|
||||
setIsOpen(false)
|
||||
|
||||
}).catch((error) => {
|
||||
toast.error(_("There was an error while performing the action."), {
|
||||
duration: 5000,
|
||||
description: getErrorMessage(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
isIconButton
|
||||
theme='red'
|
||||
title={_("Cancel")}
|
||||
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
|
||||
<CircleXIcon className='w-8 h-8' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Cancel")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogContent className='min-w-3xl'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<SelectedTransactionDetails transaction={item.bankTransaction} />
|
||||
<Table>
|
||||
<TableRow>
|
||||
<TableHead>{_("Action Type")}</TableHead>
|
||||
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Voucher Type")}</TableHead>
|
||||
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Voucher Name")}</TableHead>
|
||||
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Posting Date")}</TableHead>
|
||||
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
|
||||
</TableRow>
|
||||
{type === 'transfer' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Transfer Account")}</TableHead>
|
||||
<TableCell>
|
||||
<TransferDetails item={item} className='text-ink-gray-8' />
|
||||
</TableCell>
|
||||
</TableRow>}
|
||||
{type === 'payment' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Payment Details")}</TableHead>
|
||||
<TableCell>
|
||||
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
|
||||
</TableCell>
|
||||
</TableRow>}
|
||||
{type === 'bank_entry' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
|
||||
</TableRow>}
|
||||
</Table>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>
|
||||
{_("Close")}
|
||||
</AlertDialogCancel>
|
||||
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
|
||||
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
|
||||
export default ActionLogDialogBody
|
||||
@@ -1,334 +0,0 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecClosingBalanceAtom, bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetDocCount, useFrappeGetDocList, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { useGetAccountClosingBalance, useGetAccountClosingBalanceAsPerStatement, useGetAccountOpeningBalance, useGetUnreconciledTransactions } from "./utils"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
|
||||
import { Edit, Info, Trash2 } from "lucide-react"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import _ from "@/lib/translate"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { CurrencyFormField } from "@/components/ui/form-elements"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useContext, useState } from "react"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { BankAccountBalance } from "@/types/Accounts/BankAccountBalance"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
|
||||
const BankBalance = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-between">
|
||||
<div className="w-[80%] flex flex-wrap justify-between gap-2 pe-8 border-e-border border-e">
|
||||
<OpeningBalance />
|
||||
<ClosingBalance />
|
||||
<ClosingBalanceAsPerStatement />
|
||||
<Difference />
|
||||
</div>
|
||||
|
||||
<ReconcileProgress />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OpeningBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const { data, isLoading } = useGetAccountOpeningBalance()
|
||||
|
||||
return <StatContainer className="min-w-48">
|
||||
<StatLabel>{_("Opening Balance")}</StatLabel>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
</StatContainer>
|
||||
}
|
||||
|
||||
const ClosingBalance = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
return (
|
||||
<StatContainer className="min-w-48">
|
||||
<div className="flex items-start gap-1">
|
||||
<StatLabel>
|
||||
{_("Closing Balance as per system")}
|
||||
</StatLabel>
|
||||
<HoverCard openDelay={100}>
|
||||
<HoverCardTrigger>
|
||||
<Info className="size-3.5 text-ink-gray-6 -mt-px" />
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-96" align="start" side="right">
|
||||
<H4 className="text-base">{_("Closing balance as per system")}</H4>
|
||||
<Paragraph className="mt-2 text-p-sm">
|
||||
{_("This is what the system expects the closing balance to be in your bank statement.")}
|
||||
<br />
|
||||
{_("It takes into account all the transactions that have been posted and subtracts the transactions that have not cleared yet.")}
|
||||
<br />
|
||||
{_("If your bank statement shows a different closing balance, it is because all transactions have not reconciled yet.")}
|
||||
<br /><br />
|
||||
For more information, click on the <strong>Bank Reconciliation Statement</strong> tab below.
|
||||
</Paragraph>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
</div>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
</StatContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const Difference = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalance()
|
||||
|
||||
const value = useAtomValue(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const difference = flt(value.value - (data?.message ?? 0))
|
||||
|
||||
const isError = difference !== 0
|
||||
|
||||
return <StatContainer className="w-fit text-end sm:min-w-56">
|
||||
<StatLabel className="text-end">{_("Difference")}</StatLabel>
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 self-end rounded-sm" /> : <StatValue className={isError ? 'text-ink-red-3 font-numeric' : 'font-numeric'}>
|
||||
{formatCurrency(difference,
|
||||
bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))
|
||||
}</StatValue>}
|
||||
</StatContainer>
|
||||
}
|
||||
|
||||
const ReconcileProgress = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const { data: totalCount } = useFrappeGetDocCount<BankTransaction>('Bank Transaction', [
|
||||
["bank_account", "=", bankAccount?.name ?? ''],
|
||||
['docstatus', '=', 1],
|
||||
['date', '<=', dates?.toDate],
|
||||
['date', '>=', dates?.fromDate]
|
||||
], false, undefined, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
|
||||
const { data: unreconciledTransactions, } = useGetUnreconciledTransactions()
|
||||
|
||||
const reconciledCount = (totalCount ?? 0) - (unreconciledTransactions?.message?.length ?? 0)
|
||||
|
||||
const progress = (totalCount ? reconciledCount / totalCount : 0) * 100
|
||||
|
||||
return <div className="w-[18%] flex flex-col gap-1 items-end">
|
||||
<div className="w-full">
|
||||
<Progress
|
||||
value={progress}
|
||||
max={100}
|
||||
size="md"
|
||||
label="Progress"
|
||||
hint
|
||||
hintText={`${reconciledCount} / ${totalCount} ${_("reconciled")}`} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ClosingBalanceAsPerStatement = () => {
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const { data, isLoading } = useGetAccountClosingBalanceAsPerStatement({
|
||||
onSuccess: (data) => {
|
||||
if (data?.message && data?.message?.balance) {
|
||||
setValue({
|
||||
value: data?.message?.balance,
|
||||
stringValue: data?.message?.balance.toString()
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const isDateSame = data?.message?.date === dates.toDate
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
|
||||
return <StatContainer className="min-w-48">
|
||||
<StatLabel>{_("Closing Balance as per statement")}</StatLabel>
|
||||
<div className="flex flex-col gap-2 items-start">
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center gap-4 underline cursor-pointer underline-offset-6" role="button">
|
||||
{isLoading ? <Skeleton className="w-[150px] h-5 rounded-sm" /> : <StatValue className="font-numeric">{formatCurrency(flt(data?.message?.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</StatValue>}
|
||||
<Edit className="w-4 h-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Click to set the closing balance as per statement")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="min-w-xl">
|
||||
<ClosingBalanceForm
|
||||
defaultBalance={data?.message?.balance ?? 0}
|
||||
date={dates.toDate}
|
||||
bankAccount={bankAccount}
|
||||
onClose={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{!isDateSame && data?.message.date && <span className="text-xs font-medium text-ink-red-3">{_("As of {0}", [formatDate(data?.message?.date ?? '', 'Do MMM YYYY')])}</span>}
|
||||
</div>
|
||||
</StatContainer>
|
||||
|
||||
}
|
||||
|
||||
const ClosingBalanceForm = ({ defaultBalance, date, bankAccount, onClose }: { defaultBalance: number, date: string, bankAccount: SelectedBank | null, onClose: VoidFunction }) => {
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const form = useForm<{ balance: number }>({
|
||||
defaultValues: {
|
||||
balance: defaultBalance
|
||||
}
|
||||
})
|
||||
|
||||
const setValue = useSetAtom(bankRecClosingBalanceAtom(bankAccount?.name ?? ''))
|
||||
|
||||
const { call, loading, error } = useFrappePostCall("erpnext.accounts.doctype.bank_account.bank_account.set_closing_balance_as_per_statement")
|
||||
|
||||
const onSubmit = (data: { balance: number }) => {
|
||||
if (data.balance) {
|
||||
call({
|
||||
bank_account: bankAccount?.name ?? '',
|
||||
date: date,
|
||||
balance: data.balance
|
||||
})
|
||||
.then(() => {
|
||||
// Mutate the closing balance as per statement
|
||||
mutate(`bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${date}`)
|
||||
setValue({
|
||||
value: data.balance,
|
||||
stringValue: data.balance.toString()
|
||||
})
|
||||
toast.success(_("Closing balance set."))
|
||||
onClose()
|
||||
|
||||
|
||||
})
|
||||
} else {
|
||||
toast.error(_("Closing balance is required."))
|
||||
}
|
||||
}
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Set closing balance as per bank statement")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Enter the closing balance you see in your bank statement for {0} as of the {1}", [bankAccount?.account_name ?? bankAccount?.name ?? '', formatDate(date, 'Do MMM YYYY')])}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <div className="py-2"><ErrorBanner error={error} /></div>}
|
||||
<div className="py-4">
|
||||
<CurrencyFormField
|
||||
name="balance"
|
||||
label={_("Closing balance on bank statement as of {0}", [formatDate(date, 'Do MMM YYYY')])}
|
||||
isRequired
|
||||
currency={currency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} size='md' disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button type='submit' size='md' disabled={loading}>{_("Save")}</Button>
|
||||
</DialogFooter>
|
||||
|
||||
<ClosingBalancesList bankAccount={bankAccount} date={date} />
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
const ClosingBalancesList = ({ bankAccount, date }: { bankAccount: SelectedBank | null, date: string }) => {
|
||||
|
||||
const { data, mutate } = useFrappeGetDocList<BankAccountBalance>("Bank Account Balance", {
|
||||
filters: [["bank_account", "=", bankAccount?.name ?? ''], ["date", "<=", date]],
|
||||
orderBy: {
|
||||
field: "date",
|
||||
order: "desc"
|
||||
},
|
||||
fields: ["date", "balance", "name"],
|
||||
limit: 10
|
||||
})
|
||||
|
||||
const { db } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const onDelete = (name: string) => {
|
||||
toast.promise(db.deleteDoc("Bank Account Balance", name).then(() => {
|
||||
mutate()
|
||||
}), {
|
||||
loading: _("Deleting closing balance..."),
|
||||
success: _("Closing balance deleted."),
|
||||
error: _("Failed to delete closing balance.")
|
||||
})
|
||||
}
|
||||
|
||||
if (data?.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div>
|
||||
<Separator className="my-8" />
|
||||
<p className="text-sm text-center">{_("Balances as per bank statement before {0}", [formatDate(date, 'Do MMM YYYY')])}</p>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Date")}</TableHead>
|
||||
<TableHead className="text-end">{_("Balance")}</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data?.map((item) => (
|
||||
<TableRow key={item.name}>
|
||||
<TableCell>{formatDate(item.date, 'Do MMM YYYY')}</TableCell>
|
||||
<TableCell className="text-end">{formatCurrency(flt(item.balance, 2), bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ''))}</TableCell>
|
||||
<TableCell className="text-end">
|
||||
<Button
|
||||
title={_("Delete")}
|
||||
type='button' isIconButton variant='ghost' onClick={() => onDelete(item.name)}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
export default BankBalance
|
||||
@@ -1,355 +0,0 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { QueryReportReturnType } from "@/types/custom/Reports"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { Table, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import { CheckCircle2, ReceiptTextIcon, XCircle } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import _ from "@/lib/translate"
|
||||
import { useCopyToClipboard } from "usehooks-ts"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { toast } from "sonner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { DateField } from "@/components/ui/form-elements"
|
||||
import { Empty, EmptyMedia, EmptyHeader, EmptyTitle, EmptyDescription } from "@/components/ui/empty"
|
||||
|
||||
const BankClearanceSummary = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return <MissingFiltersBanner text={_("Please select a bank account to view the bank clearance summary.")} />
|
||||
}
|
||||
|
||||
if (!dates) {
|
||||
return <MissingFiltersBanner text={_("Please select dates to view the bank clearance summary.")} />
|
||||
}
|
||||
|
||||
return <BankClearanceSummaryView />
|
||||
}
|
||||
interface BankClearanceSummaryEntry {
|
||||
payment_document_type: string
|
||||
payment_entry: string
|
||||
posting_date: string,
|
||||
cheque_no?: string,
|
||||
amount: number,
|
||||
against: string,
|
||||
clearance_date: string,
|
||||
}
|
||||
|
||||
const BankClearanceSummaryView = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
account: bankAccount?.account,
|
||||
from_date: dates.fromDate,
|
||||
to_date: dates.toDate
|
||||
})
|
||||
}, [bankAccount, dates])
|
||||
|
||||
const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType<BankClearanceSummaryEntry> }>('frappe.desk.query_report.run', {
|
||||
report_name: 'Bank Clearance Summary',
|
||||
filters,
|
||||
ignore_prepared_report: 1,
|
||||
are_default_filters: false,
|
||||
}, `Report-Bank Clearance Summary-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
|
||||
|
||||
const formattedFromDate = formatDate(dates.fromDate)
|
||||
const formattedToDate = formatDate(dates.toDate)
|
||||
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const onCopy = useCallback(
|
||||
(text: string) => {
|
||||
copyToClipboard(text).then(() => {
|
||||
toast.success(_("Copied to clipboard"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
() => bankAccount?.account_currency ?? getCompanyCurrency(companyID),
|
||||
[bankAccount?.account_currency, companyID],
|
||||
)
|
||||
|
||||
const clearanceColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "payment_document_type",
|
||||
header: _("Document Type"),
|
||||
size: 140,
|
||||
cell: ({ row }) => _(row.original.payment_document_type),
|
||||
},
|
||||
{
|
||||
id: "payment_entry",
|
||||
header: _("Payment Document"),
|
||||
size: 160,
|
||||
meta: {
|
||||
getTooltipText: (r) => {
|
||||
const x = r as BankClearanceSummaryEntry
|
||||
return [x.payment_document_type, x.payment_entry].filter(Boolean).join(" · ") || undefined
|
||||
},
|
||||
} satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
|
||||
href={`/desk/${slug(row.original.payment_document_type)}/${row.original.payment_entry}`}
|
||||
>
|
||||
{row.original.payment_entry}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "posting_date",
|
||||
header: _("Posting Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.posting_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "cheque_no",
|
||||
header: _("Cheque/Reference Number"),
|
||||
size: 160,
|
||||
cell: ({ row }) => {
|
||||
const ref = row.original.cheque_no ?? ""
|
||||
return (
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-ink-gray-8 hover:underline min-w-0 w-full cursor-pointer truncate text-start underline-offset-4"
|
||||
onClick={() => onCopy(ref)}
|
||||
>
|
||||
{ref}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{ref}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "clearance_date",
|
||||
header: _("Clearance Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "against",
|
||||
header: _("Against Account"),
|
||||
size: 250,
|
||||
},
|
||||
{
|
||||
accessorKey: "amount",
|
||||
header: _("Amount"),
|
||||
size: 150,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.amount, accountCurrency)}</span>,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: _("Status"),
|
||||
size: 200,
|
||||
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original
|
||||
return r.clearance_date ? (
|
||||
<Badge theme="green">
|
||||
<CheckCircle2 />
|
||||
{_("Cleared")}
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<Badge theme="red">
|
||||
<XCircle />
|
||||
{_("Not Cleared")}
|
||||
</Badge>
|
||||
<SetClearanceDateButton
|
||||
voucher={r}
|
||||
bankAccount={bankAccount}
|
||||
companyID={companyID}
|
||||
mutate={mutate}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
[accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||
)
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all accounting entries posted against the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.message.result.length > 0 ? (
|
||||
<ListView
|
||||
data={data.message.result}
|
||||
columns={clearanceColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data && data.message.result.length == 0 &&
|
||||
<Empty>
|
||||
<EmptyMedia>
|
||||
<ReceiptTextIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No entries found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("There are no accounting entries in the system for the selected account and dates.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const SetClearanceDateButton = ({ voucher, bankAccount, companyID, mutate }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank | null, companyID: string, mutate: VoidFunction }) => {
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const onClose = () => {
|
||||
setOpen(false)
|
||||
mutate()
|
||||
}
|
||||
|
||||
return <Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger disabled={!bankAccount}>
|
||||
<Tooltip delayDuration={500}>
|
||||
<TooltipTrigger>
|
||||
<Button variant='link' size="sm" className="px-0" theme="red">{_("Force Clear")}</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align='start'>
|
||||
{_("Set the clearance date for this voucher without reconciling with a bank transaction.")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="min-w-2xl">
|
||||
{bankAccount && <ForceClearVoucherForm voucher={voucher} bankAccount={bankAccount} companyID={companyID} onClose={onClose} />}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
const ForceClearVoucherForm = ({ voucher, bankAccount, companyID, onClose }: { voucher: BankClearanceSummaryEntry, bankAccount: SelectedBank, companyID: string, onClose: () => void }) => {
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const form = useForm<{ clearance_date: string }>({
|
||||
defaultValues: {
|
||||
clearance_date: voucher.posting_date,
|
||||
}
|
||||
})
|
||||
|
||||
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.update_clearance_date')
|
||||
|
||||
const onSubmit = (data: { clearance_date: string }) => {
|
||||
call({
|
||||
payment_document: voucher.payment_document_type,
|
||||
payment_entry: voucher.payment_entry,
|
||||
account: bankAccount.account,
|
||||
clearance_date: data.clearance_date,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(_("Clearance date updated"))
|
||||
onClose()
|
||||
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
|
||||
})
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Force Clear Voucher")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Set the clearance date for this voucher without reconciling with a bank transaction.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Payment Document")}</TableHead>
|
||||
<TableCell><a target="_blank" className="underline underline-offset-4"
|
||||
href={`/desk/${slug(voucher.payment_document_type)}/${voucher.payment_entry}`}>{_(voucher.payment_document_type)} : {voucher.payment_entry}</a></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Posting Date")}</TableHead>
|
||||
<TableCell>{formatDate(voucher.posting_date)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Cheque/Reference Number")}</TableHead>
|
||||
<TableCell title={voucher.cheque_no}>{voucher.cheque_no?.slice(0, 40)}{voucher.cheque_no?.length && voucher.cheque_no?.length > 40 ? "..." : ""}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Amount")}</TableHead>
|
||||
<TableCell className="text-end">{formatCurrency(voucher.amount, bankAccount?.account_currency ?? getCompanyCurrency(companyID))}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Against Account")}</TableHead>
|
||||
<TableCell><a target="_blank" className="underline underline-offset-4" href={`/desk/account/${voucher.against}`}>{voucher.against}</a></TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</Table>
|
||||
</div>
|
||||
<DateField
|
||||
name='clearance_date'
|
||||
label={_("Clearance Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: true }}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} disabled={loading} size='md'>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button type='submit' disabled={loading} size='md'>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
export default BankClearanceSummary
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
|
||||
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
|
||||
import _ from "@/lib/translate"
|
||||
import { lazy, Suspense } from "react"
|
||||
|
||||
const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent'))
|
||||
|
||||
const BankEntryModal = () => {
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<RecordBankEntryModalContent />
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankEntryModal
|
||||
@@ -1,811 +0,0 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { DialogFooter, DialogClose } from "@/components/ui/dialog"
|
||||
import _ from "@/lib/translate"
|
||||
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
|
||||
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
|
||||
import { JournalEntry } from "@/types/Accounts/JournalEntry"
|
||||
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
|
||||
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from "react"
|
||||
import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import FileUploadBanner from "@/components/common/FileUploadBanner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||
import { useGetAccounts } from "@/components/common/AccountsDropdown"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
const RecordBankEntryModalContent = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
|
||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
||||
return <div className='p-4'>
|
||||
<span className='text-center'>{_("No transaction selected")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (selectedTransaction.length === 1) {
|
||||
return <BankEntryForm
|
||||
selectedTransaction={selectedTransaction[0]} />
|
||||
}
|
||||
|
||||
return <BulkBankEntryForm
|
||||
selectedTransactions={selectedTransaction}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const form = useForm<{
|
||||
account: string
|
||||
}>({
|
||||
defaultValues: {
|
||||
account: ''
|
||||
}
|
||||
})
|
||||
|
||||
const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile')
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const onSubmit = (data: { account: string }) => {
|
||||
|
||||
call({
|
||||
bank_transactions: selectedTransactions.map(transaction => transaction.name),
|
||||
account: data.account
|
||||
}).then(({ message }) => {
|
||||
|
||||
addToActionLog({
|
||||
type: 'bank_entry',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: true,
|
||||
items: message.map((item) => ({
|
||||
bankTransaction: item.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Journal Entry",
|
||||
reference_name: item.journal_entry.name,
|
||||
doc: item.journal_entry,
|
||||
posting_date: item.journal_entry.posting_date,
|
||||
}
|
||||
})),
|
||||
bulkCommonData: {
|
||||
account: data.account,
|
||||
}
|
||||
})
|
||||
|
||||
toast.success(_("Bank Entries Created"), {
|
||||
duration: 4000,
|
||||
})
|
||||
|
||||
// Set this to the last selected transaction
|
||||
onReconcile(selectedTransactions[selectedTransactions.length - 1])
|
||||
setIsOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<AccountFormField
|
||||
name='account'
|
||||
filterFunction={(acc) => {
|
||||
// Do not allow payable and receivable accounts
|
||||
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
|
||||
}}
|
||||
label={_('Account')}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
|
||||
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
|
||||
entries: JournalEntry['accounts']
|
||||
}
|
||||
|
||||
|
||||
const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const defaultAccounts = useMemo(() => {
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const accounts: Partial<JournalEntryAccount>[] = [
|
||||
{
|
||||
account: selectedBankAccount?.account ?? '',
|
||||
bank_account: selectedTransaction.bank_account,
|
||||
// Bank is debited if it's a deposit
|
||||
debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
party_type: '',
|
||||
party: '',
|
||||
cost_center: ''
|
||||
}]
|
||||
|
||||
// If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse
|
||||
if (!rule) {
|
||||
accounts.push(
|
||||
{
|
||||
account: '',
|
||||
// Amounts will be the reverse of the bank account transaction
|
||||
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Rule exists, so we need to check the type of rule
|
||||
if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") {
|
||||
// Only a single account needs to be added
|
||||
accounts.push({
|
||||
account: rule.account ?? '',
|
||||
// Amounts will be the reverse of the bank account transaction
|
||||
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
})
|
||||
} else {
|
||||
// For multiple accounts, we need to loop over and add entries for each
|
||||
// The last row will just be the remaining amount
|
||||
let hasTotallyEmptyRowEarlier = false;
|
||||
|
||||
let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0
|
||||
let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0
|
||||
|
||||
for (let i = 0; i < (rule.accounts?.length ?? 0); i++) {
|
||||
|
||||
const acc = rule.accounts?.[i]
|
||||
// If it's the last row, add the difference amount
|
||||
if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) {
|
||||
|
||||
const differenceAmount = flt(totalDebits - totalCredits, 2)
|
||||
accounts.push({
|
||||
account: acc?.account ?? '',
|
||||
debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount),
|
||||
credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
user_remark: acc?.user_remark ?? '',
|
||||
})
|
||||
} else {
|
||||
|
||||
/**
|
||||
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
|
||||
* So we need to compute the value of the expression
|
||||
* We can use the eval function to do this. But we need to expose certain variables to the expression.
|
||||
* One of them is transaction_amount which is the unallocated amount of the selected transaction
|
||||
* @param expression - The expression to compute
|
||||
* @returns The computed value
|
||||
*/
|
||||
const computeExpression = (expression: string) => {
|
||||
|
||||
const script = `
|
||||
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
|
||||
${expression};
|
||||
`
|
||||
|
||||
let value = 0;
|
||||
|
||||
try {
|
||||
value = window.eval(script);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
if (!acc?.debit && !acc?.credit) {
|
||||
hasTotallyEmptyRowEarlier = true;
|
||||
}
|
||||
|
||||
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
|
||||
|
||||
totalDebits = flt(totalDebits + computedDebit, 2)
|
||||
totalCredits = flt(totalCredits + computedCredit, 2)
|
||||
accounts.push({
|
||||
account: acc?.account ?? '',
|
||||
debit: computedDebit,
|
||||
credit: computedCredit,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
user_remark: acc?.user_remark ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
|
||||
}, [rule, selectedTransaction, selectedBankAccount])
|
||||
|
||||
const form = useForm<BankEntryFormData>({
|
||||
defaultValues: {
|
||||
voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry',
|
||||
cheque_date: selectedTransaction.date,
|
||||
posting_date: selectedTransaction.date,
|
||||
cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
|
||||
user_remark: selectedTransaction.description,
|
||||
entries: defaultAccounts,
|
||||
}
|
||||
})
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
|
||||
const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile')
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onSubmit = (data: BankEntryFormData) => {
|
||||
|
||||
createBankEntry({
|
||||
bank_transaction_name: selectedTransaction.name,
|
||||
...data
|
||||
}).then(async ({ message }) => {
|
||||
|
||||
addToActionLog({
|
||||
type: 'bank_entry',
|
||||
isBulk: false,
|
||||
timestamp: (new Date()).getTime(),
|
||||
items: [
|
||||
{
|
||||
bankTransaction: message.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Journal Entry",
|
||||
reference_name: message.journal_entry.name,
|
||||
reference_no: message.journal_entry.cheque_no,
|
||||
reference_date: message.journal_entry.cheque_date,
|
||||
posting_date: message.journal_entry.posting_date,
|
||||
doc: message.journal_entry,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
toast.success(_("Bank Entry Created"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true)
|
||||
startTracking(files.length)
|
||||
|
||||
const uploadPromises = files.map((f, fileIndex) => {
|
||||
return frappeFile.uploadFile(f, {
|
||||
isPrivate: true,
|
||||
doctype: "Journal Entry",
|
||||
docname: message.journal_entry.name,
|
||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
||||
updateFileProgress(fileIndex, progress?.progress ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
toast.error(_("Error uploading attachments"), {
|
||||
duration: 4000,
|
||||
})
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
}).then(() => {
|
||||
onReconcile(selectedTransaction)
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
if (isUploading && isCompleted) {
|
||||
return <FileUploadBanner uploadProgress={uploadProgress} />
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<DateField
|
||||
name='posting_date'
|
||||
label={_("Posting Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
<DateField
|
||||
name='cheque_date'
|
||||
label={_("Reference Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
rules={{
|
||||
required: _("Reference Date is required"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
|
||||
rules={{
|
||||
required: _("Reference is required"),
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SmallTextField
|
||||
name='user_remark'
|
||||
label={_("Remarks")}
|
||||
/>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label>{_("Attachments")}</Label>
|
||||
<FileDropzone files={files} setFiles={setFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
}
|
||||
|
||||
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
|
||||
|
||||
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const partyMapRef = useRef<Record<string, string>>({})
|
||||
|
||||
const onPartyChange = (value: string, index: number) => {
|
||||
// Get the account for the party type
|
||||
if (value) {
|
||||
if (partyMapRef.current[value]) {
|
||||
setValue(`entries.${index}.account`, partyMapRef.current[value])
|
||||
} else {
|
||||
call.get('erpnext.accounts.party.get_party_account', {
|
||||
party: value,
|
||||
party_type: getValues(`entries.${index}.party_type`),
|
||||
company: company
|
||||
}).then((result: { message: string }) => {
|
||||
setValue(`entries.${index}.account`, result.message)
|
||||
partyMapRef.current[value] = result.message
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setValue(`entries.${index}.account`, '')
|
||||
}
|
||||
}
|
||||
|
||||
const { data: accounts } = useGetAccounts()
|
||||
|
||||
const onAccountChange = (value: string, index: number) => {
|
||||
// If it's an income or expense account, get the default cost center
|
||||
if (value) {
|
||||
const account = accounts?.find((acc) => acc.name === value)
|
||||
if (account && account.report_type === "Profit and Loss") {
|
||||
// Set the default company cost center
|
||||
setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setValue(`entries.${index}.cost_center`, '')
|
||||
}
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: control,
|
||||
name: 'entries'
|
||||
})
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
const existingEntries = getValues('entries')
|
||||
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
|
||||
const remainingAmount = flt(totalDebits - totalCredits, 2)
|
||||
|
||||
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
|
||||
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
|
||||
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
|
||||
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: debitAmount,
|
||||
credit: creditAmount,
|
||||
cost_center: getCompanyCostCenter(company) ?? ''
|
||||
} as JournalEntryAccount, {
|
||||
focusName: `entries.${existingEntries.length}.account`
|
||||
})
|
||||
}, [company, append, getValues])
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([])
|
||||
|
||||
const onSelectRow = useCallback((index: number) => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.includes(index)) {
|
||||
return prev.filter(i => i !== index)
|
||||
}
|
||||
return [...prev, index]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.length === fields.length) {
|
||||
return []
|
||||
}
|
||||
return [...fields.map((_, index) => index)]
|
||||
})
|
||||
}, [fields])
|
||||
|
||||
const onRemove = useCallback(() => {
|
||||
// Do not remove the first row
|
||||
remove(selectedRows.filter(index => index !== 0))
|
||||
setSelectedRows([])
|
||||
}, [remove, selectedRows])
|
||||
|
||||
/**
|
||||
* When add difference is clicked, check if the last row has nothing filled in.
|
||||
* If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount.
|
||||
*/
|
||||
const onAddDifferenceClicked = () => {
|
||||
|
||||
const existingEntries = getValues('entries')
|
||||
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
|
||||
const lastIndex = existingEntries.length - 1
|
||||
|
||||
const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined)
|
||||
|
||||
const remainingAmount = flt(totalDebits - totalCredits, 2)
|
||||
|
||||
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
|
||||
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
|
||||
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
|
||||
|
||||
if (isLastRowEmpty) {
|
||||
setValue(`entries.${lastIndex}.debit`, debitAmount)
|
||||
setValue(`entries.${lastIndex}.credit`, creditAmount)
|
||||
} else {
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: debitAmount,
|
||||
credit: creditAmount,
|
||||
cost_center: getCompanyCostCenter(company) ?? ''
|
||||
} as JournalEntryAccount, {
|
||||
focusName: `entries.${existingEntries.length}.account`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return <div className="flex flex-col gap-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead><Checkbox
|
||||
disabled={fields.length === 0}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select all")}
|
||||
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
|
||||
onCheckedChange={onSelectAll} /></TableHead>
|
||||
<TableHead>{_("Party")}</TableHead>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead>{_("Cost Center")}</TableHead>
|
||||
<TableHead>{_("Remarks")}</TableHead>
|
||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fields.map((field, index) => (
|
||||
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(index)}
|
||||
onCheckedChange={() => onSelectRow(index)}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select row {0}", [String(index + 1)])}
|
||||
disabled={index === 0}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="align-top">
|
||||
<div className="flex">
|
||||
<PartyTypeFormField
|
||||
name={`entries.${index}.party_type`}
|
||||
label={_("Party Type")}
|
||||
isRequired
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
inputProps={{
|
||||
type: isWithdrawal ? 'Payable' : 'Receivable',
|
||||
triggerProps: {
|
||||
className: 'rounded-e-none',
|
||||
tabIndex: -1
|
||||
},
|
||||
readOnly: index === 0,
|
||||
}} />
|
||||
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
|
||||
</div>
|
||||
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AccountFormField
|
||||
name={`entries.${index}.account`}
|
||||
label={_("Account")}
|
||||
rules={{
|
||||
required: _("Account is required"),
|
||||
onChange: (event) => {
|
||||
onAccountChange(event.target.value, index)
|
||||
}
|
||||
}}
|
||||
buttonClassName="min-w-64"
|
||||
readOnly={index === 0}
|
||||
isRequired
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<LinkFormField
|
||||
doctype="Cost Center"
|
||||
name={`entries.${index}.cost_center`}
|
||||
label={_("Cost Center")}
|
||||
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
|
||||
buttonClassName="min-w-48"
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<DataField
|
||||
name={`entries.${index}.user_remark`}
|
||||
label={_("Remarks")}
|
||||
readOnly={index === 0}
|
||||
inputProps={{
|
||||
placeholder: _("e.g. Bank Charges"),
|
||||
className: 'min-w-64',
|
||||
readOnly: index === 0
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end align-top")}>
|
||||
<CurrencyFormField
|
||||
name={`entries.${index}.debit`}
|
||||
label={_("Debit")}
|
||||
isRequired
|
||||
hideLabel
|
||||
readOnly={index === 0}
|
||||
style={index === 0 ? !isWithdrawal ? {
|
||||
color: "var(--color-ink-gray-8)",
|
||||
} : {} : {}}
|
||||
currency={currency}
|
||||
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
|
||||
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
|
||||
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
|
||||
</Tooltip> : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end align-top")}>
|
||||
<CurrencyFormField
|
||||
name={`entries.${index}.credit`}
|
||||
style={index === 0 && isWithdrawal ? {
|
||||
color: "var(--color-ink-gray-8)",
|
||||
} : {}}
|
||||
label={_("Credit")}
|
||||
isRequired
|
||||
hideLabel
|
||||
readOnly={index === 0}
|
||||
currency={currency}
|
||||
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
|
||||
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
|
||||
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
|
||||
</Tooltip> : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div>
|
||||
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
|
||||
</div>
|
||||
{selectedRows.length > 0 && <div>
|
||||
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
<Summary currency={currency} addRow={onAddDifferenceClicked} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
|
||||
|
||||
const { control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const party_type = useWatch({
|
||||
control,
|
||||
name: `entries.${index}.party_type`
|
||||
})
|
||||
|
||||
if (!party_type) {
|
||||
return <DataField
|
||||
name={`entries.${index}.party`}
|
||||
label={_("Party")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
disabled: true,
|
||||
className: 'rounded-s-none border-s-0 min-w-64'
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
}
|
||||
|
||||
return <LinkFormField
|
||||
name={`entries.${index}.party`}
|
||||
label={_("Party")}
|
||||
rules={{
|
||||
onChange: (event) => {
|
||||
onChange(event.target.value, index)
|
||||
},
|
||||
}}
|
||||
hideLabel
|
||||
readOnly={readOnly}
|
||||
buttonClassName="rounded-s-none border-s-0 min-w-64"
|
||||
doctype={party_type}
|
||||
|
||||
/>
|
||||
}
|
||||
|
||||
const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => {
|
||||
|
||||
const { control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const entries = useWatch({ control, name: 'entries' })
|
||||
|
||||
const { total, totalCredits, totalDebits } = useMemo(() => {
|
||||
// Do a total debits - total credits
|
||||
const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits }
|
||||
}, [entries])
|
||||
|
||||
const onAddRow = useCallback(() => {
|
||||
addRow()
|
||||
}, [addRow])
|
||||
|
||||
const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => {
|
||||
return <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-2 items-end">
|
||||
<div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Total Debit")}</TextComponent>
|
||||
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Total Credit")}</TextComponent>
|
||||
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
|
||||
</div>
|
||||
{total !== 0 && <div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Difference")}</TextComponent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
|
||||
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Add a row with the difference amount")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default RecordBankEntryModalContent
|
||||
@@ -1,134 +0,0 @@
|
||||
import { useAtom } from "jotai"
|
||||
import { SelectedBank, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCallback } from "react"
|
||||
import { useGetBankAccounts, useGetUnreconciledTransactions } from "./utils"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getTimeago } from "@/lib/date"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import _ from "@/lib/translate"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useTheme } from "@/components/ui/theme-provider"
|
||||
import BankLogo from "@/components/common/BankLogo"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
import { LandmarkIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
|
||||
const BankPicker = ({ className }: { className?: string }) => {
|
||||
|
||||
const [selectedBank, setSelectedBank] = useAtom(selectedBankAccountAtom)
|
||||
|
||||
const onLoadingSuccess = useCallback((data?: SelectedBank[]) => {
|
||||
// If the bank is already selected, then don't set it again
|
||||
if (selectedBank) {
|
||||
// Check if selected bank is in the data
|
||||
if (data?.some((bank: SelectedBank) => bank.name === selectedBank.name)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (!data) return
|
||||
if (data.length === 1) {
|
||||
setSelectedBank(data[0])
|
||||
} else if (data.length > 1) {
|
||||
const defaultBank = data.find((bank: SelectedBank) => bank.is_default)
|
||||
if (defaultBank) {
|
||||
setSelectedBank(defaultBank)
|
||||
} else {
|
||||
// Select the first available bank account
|
||||
setSelectedBank(data[0])
|
||||
}
|
||||
}
|
||||
}, [setSelectedBank, selectedBank])
|
||||
|
||||
const selectedCompany = useCurrentCompany()
|
||||
|
||||
const { banks, isLoading, error } = useGetBankAccounts(onLoadingSuccess)
|
||||
|
||||
const { themeValue } = useTheme()
|
||||
|
||||
if (isLoading) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
|
||||
if (banks?.length === 0) {
|
||||
return <Empty>
|
||||
<EmptyMedia>
|
||||
<LandmarkIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No bank accounts found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("You have not added any bank accounts to your company.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button asChild>
|
||||
<a href={`/desk/bank-account?company=${encodeURIComponent(selectedCompany)}&is_company_account=1`}>
|
||||
{_("Configure Bank Accounts")}
|
||||
</a>
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={cn("flex gap-3 items-stretch w-full overflow-x-auto pe-4",
|
||||
banks?.length > 4 ? 'pb-2' : '', className,
|
||||
)}
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: themeValue === 'Dark' ? 'var(--surface-gray-2) var(--surface-gray-1)' : 'rgb(209 213 219) rgb(243 244 246)',
|
||||
}}
|
||||
>
|
||||
{
|
||||
banks?.map((bank) => (
|
||||
<BankPickerItem key={bank.name} bank={bank} />
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const BankPickerItem = ({ bank }: { bank: SelectedBank }) => {
|
||||
|
||||
const [selectedBank, setSelectedBank] = useAtom(selectedBankAccountAtom)
|
||||
|
||||
const isSelected = selectedBank?.name === bank.name
|
||||
|
||||
const { mutate } = useGetUnreconciledTransactions()
|
||||
|
||||
const onSelect = () => {
|
||||
setSelectedBank(bank)
|
||||
mutate()
|
||||
}
|
||||
|
||||
return <div
|
||||
role="button"
|
||||
title={`Select ${bank.account_name}`}
|
||||
onClick={onSelect}
|
||||
className={cn('rounded-md border border-outline-gray-1 max-w-60 min-w-60 p-2 overflow-hidden cursor-pointer',
|
||||
isSelected ? 'border-outline-gray-5 bg-surface-gray-1' : 'hover:bg-surface-gray-1'
|
||||
)}
|
||||
>
|
||||
|
||||
|
||||
<BankLogo bank={bank} className="mb-2" />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className={cn("text-sm font-medium line-clamp-1 text-ink-gray-8")}>{bank.account_name}</span>
|
||||
{bank.account_type && <Badge variant='subtle' size='sm' theme='gray'>
|
||||
{bank.account_type?.slice(0, 24)}
|
||||
</Badge>}
|
||||
</div>
|
||||
|
||||
<span title={_("GL Account")} className={cn("text-ellipsis line-clamp-1 text-sm text-ink-gray-6")}>{bank.account}</span>
|
||||
{bank.last_integration_date && <span className="text-xs text-ink-gray-5">{_("Last Synced Transaction")}: {getTimeago(bank.last_integration_date)}</span>}
|
||||
</div>
|
||||
|
||||
</div >
|
||||
}
|
||||
|
||||
export default BankPicker
|
||||
@@ -1,275 +0,0 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { bankRecDateAtom } from './bankRecAtoms'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { AVAILABLE_TIME_PERIODS, formatDate, getDatesForTimePeriod, TimePeriod } from '@/lib/date'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { ChevronDownIcon, ChevronLeftIcon, ChevronRight } from 'lucide-react'
|
||||
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { parse } from "chrono-node"
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import useFiscalYear from '@/hooks/useFiscalYear'
|
||||
import dayjs from 'dayjs'
|
||||
import _ from '@/lib/translate'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
|
||||
const BankRecDateFilter = () => {
|
||||
|
||||
const [bankRecDate, setBankRecDate] = useAtom(bankRecDateAtom)
|
||||
|
||||
const { data: fiscalYear } = useFiscalYear()
|
||||
|
||||
const timePeriodOptions = useMemo(() => {
|
||||
const standardOptions = AVAILABLE_TIME_PERIODS.map((period) => {
|
||||
const dates = getDatesForTimePeriod(period)
|
||||
return {
|
||||
label: period,
|
||||
fromDate: dates.fromDate,
|
||||
toDate: dates.toDate,
|
||||
format: dates.format,
|
||||
translatedLabel: dates.translatedLabel
|
||||
}
|
||||
})
|
||||
|
||||
if (fiscalYear?.message) {
|
||||
// For a fiscal year, we need to replace "Last Year", "This Year", and add options for quarters
|
||||
const fiscalYearStart = fiscalYear.message.year_start_date
|
||||
const fiscalYearEnd = fiscalYear.message.year_end_date
|
||||
|
||||
const q1 = {
|
||||
label: `Q1: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q1")}: ${fiscalYear.message.name}`,
|
||||
fromDate: fiscalYearStart,
|
||||
toDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q2 = {
|
||||
label: `Q2: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q2")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(3, 'month').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q3 = {
|
||||
label: `Q3: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q3")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(6, 'month').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const q4 = {
|
||||
label: `Q4: ${fiscalYear.message.name}`,
|
||||
translatedLabel: `${_("Q4")}: ${fiscalYear.message.name}`,
|
||||
fromDate: dayjs(fiscalYearStart).add(9, 'month').format('YYYY-MM-DD'),
|
||||
toDate: fiscalYearEnd,
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const thisYear = {
|
||||
label: `This Fiscal Year`,
|
||||
translatedLabel: `${_("This Fiscal Year")}`,
|
||||
fromDate: fiscalYearStart,
|
||||
toDate: fiscalYearEnd,
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
|
||||
const lastYear = {
|
||||
label: `Last Fiscal Year`,
|
||||
translatedLabel: `${_("Last Fiscal Year")}`,
|
||||
fromDate: dayjs(fiscalYearStart).subtract(1, 'year').format('YYYY-MM-DD'),
|
||||
toDate: dayjs(fiscalYearEnd).subtract(1, 'year').format('YYYY-MM-DD'),
|
||||
format: 'MMM YYYY'
|
||||
}
|
||||
// Sort the options so that we get "This Month", "Last Month", quarters, fiscal year, then the rest of the standard options
|
||||
|
||||
const topRankedItems = standardOptions.filter((option) => {
|
||||
return option.label === "This Month" || option.label === "Last Month"
|
||||
})
|
||||
|
||||
const bottomRankedItems = standardOptions.filter((option) => {
|
||||
return option.label !== "This Month" && option.label !== "Last Month"
|
||||
})
|
||||
|
||||
return [...topRankedItems, q1, q2, q3, q4, thisYear, lastYear, ...bottomRankedItems]
|
||||
}
|
||||
|
||||
return standardOptions
|
||||
}, [fiscalYear])
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState("")
|
||||
|
||||
const timePeriod: TimePeriod | string = useMemo(() => {
|
||||
if (bankRecDate.fromDate && bankRecDate.toDate) {
|
||||
// Check if the from and to dates match any predefined time period
|
||||
for (const period of timePeriodOptions) {
|
||||
if (period.fromDate === bankRecDate.fromDate && period.toDate === bankRecDate.toDate) {
|
||||
return period.label;
|
||||
}
|
||||
}
|
||||
return "Date Range";
|
||||
} else {
|
||||
return "Date Range";
|
||||
}
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate, timePeriodOptions]);
|
||||
|
||||
const handleTimePeriodChange = (fromDate: string, toDate: string) => {
|
||||
setBankRecDate({ fromDate, toDate })
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const dateObj = useMemo(() => {
|
||||
return {
|
||||
from: new Date(bankRecDate.fromDate),
|
||||
to: new Date(bankRecDate.toDate)
|
||||
}
|
||||
}, [bankRecDate.fromDate, bankRecDate.toDate])
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
|
||||
|
||||
return <div className='flex items-center'>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={'outline'}
|
||||
aria-expanded={open}
|
||||
size='md'
|
||||
className='rounded-e-none border-e-0'
|
||||
role="combobox">
|
||||
{timePeriodOptions.find((period) => period.label === timePeriod)?.translatedLabel ?? _(timePeriod)}
|
||||
|
||||
<ChevronDownIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent className="w-84 p-1" align='start'>
|
||||
<Command>
|
||||
|
||||
<CommandInput placeholder="e.g. Last 3 weeks" onValueChange={setValue} value={value} />
|
||||
<CommandList className='max-h-fit'>
|
||||
<CommandEmpty className='text-start p-2 hover:bg-surface-gray-1'>
|
||||
<EmptyState onSelect={handleTimePeriodChange} value={value} />
|
||||
</CommandEmpty>
|
||||
{timePeriodOptions.map((period) => (
|
||||
<CommandItem key={period.label} className='flex justify-between' onSelect={() => handleTimePeriodChange(period.fromDate, period.toDate)}>
|
||||
<span>
|
||||
{period.translatedLabel ?? _(period.label)}
|
||||
</span>
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1 text-end whitespace-nowrap'>
|
||||
{formatDate(period.fromDate, period.format)} {direction === 'ltr' ? <ChevronRight className='text-[12px] text-ink-gray-5/70' /> : <ChevronLeftIcon className='text-[12px] text-ink-gray-5/70' />} {formatDate(period.toDate, period.format)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandList>
|
||||
</Command>
|
||||
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant={'outline'} className='rounded-s-none' size='md'>
|
||||
{formatDate(bankRecDate.fromDate)} - {formatDate(bankRecDate.toDate)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className='w-auto overflow-hidden p-0' align='end'>
|
||||
<Calendar
|
||||
mode='range'
|
||||
captionLayout='dropdown'
|
||||
selected={{
|
||||
from: dateObj.from,
|
||||
to: dateObj.to
|
||||
}}
|
||||
numberOfMonths={2}
|
||||
defaultMonth={dateObj.from}
|
||||
onSelect={(date) => {
|
||||
if (date) {
|
||||
setBankRecDate({ fromDate: formatDate(date.from, 'YYYY-MM-DD'), toDate: formatDate(date.to, 'YYYY-MM-DD') })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
}
|
||||
|
||||
const referentialKeywords = ["last", "this", "next", "previous"]
|
||||
const EmptyState = ({ onSelect, value }: { onSelect: (fromDate: string, toDate: string) => void, value: string }) => {
|
||||
|
||||
const dates = useMemo(() => {
|
||||
if (value) {
|
||||
// Try parsing the value
|
||||
const parsedDate = parse(value, undefined, { forwardDate: false })
|
||||
|
||||
if (parsedDate && parsedDate.length > 0) {
|
||||
const startDate = parsedDate[0].start.date()
|
||||
const endDate = parsedDate[0].end?.date()
|
||||
|
||||
if (!endDate) {
|
||||
const today = new Date()
|
||||
// If today is greater than the start date, use today as the end date
|
||||
if (startDate.getTime() > today.getTime()) {
|
||||
return { fromDate: today, toDate: startDate }
|
||||
} else {
|
||||
// Check if the user only wants a specific month like "May 2025"
|
||||
// If the "known values" just has month and year, then we need to get the first day of the month and the last day of the month
|
||||
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
|
||||
if (parsedDate[0].start.knownValues?.month && !parsedDate[0].start.knownValues?.day) {
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: dayjs(startDate).endOf('month').toDate()
|
||||
}
|
||||
// @ts-expect-error - "Known Values" is available in the start "ParsingComponents"
|
||||
} else if (parsedDate[0].start.knownValues?.month && parsedDate[0].start.knownValues?.day && !referentialKeywords.some(keyword => value.toLowerCase().includes(keyword))) {
|
||||
// If month and day is known, then we should not assume that the user wants to get everything until today
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: startDate,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fromDate: startDate,
|
||||
toDate: today
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { fromDate: startDate, toDate: endDate }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}, [value])
|
||||
|
||||
const onClick = (fromDate: Date, toDate: Date) => {
|
||||
onSelect(formatDate(fromDate, 'YYYY-MM-DD'), formatDate(toDate, 'YYYY-MM-DD'))
|
||||
}
|
||||
|
||||
const isEqual = dates?.fromDate && dates?.toDate && dayjs(dates.fromDate).isSame(dates.toDate, 'date')
|
||||
|
||||
return <div>
|
||||
{dates ?
|
||||
<div className='flex gap-2 items-center justify-between cursor-pointer' onClick={() => onClick(dates.fromDate, dates.toDate)}>
|
||||
<span className='text-sm text-ink-gray-5 max-w-[30%]'>
|
||||
{value}
|
||||
</span>
|
||||
{isEqual ? <span className='text-xs text-ink-gray-5 text-balance flex items-center gap-1'>
|
||||
{formatDate(dates.fromDate, 'Do MMM YYYY')}
|
||||
</span> :
|
||||
<span className='text-xs text-ink-gray-5 flex items-center gap-1'>
|
||||
{formatDate(dates.fromDate, 'Do MMM YY')} <ChevronRight size='16' className='text-ink-gray-5' /> {formatDate(dates.toDate, 'Do MMM YY')}
|
||||
</span>}
|
||||
</div> :
|
||||
<span className='text-sm text-ink-gray-5'>
|
||||
No results found
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BankRecDateFilter
|
||||
@@ -1,315 +0,0 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useFrappeGetCall } from "frappe-react-sdk"
|
||||
import { QueryReportReturnType } from "@/types/custom/Reports"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import { ScrollTextIcon } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { StatContainer, StatLabel, StatValue } from "@/components/ui/stats"
|
||||
import _ from "@/lib/translate"
|
||||
import { toast } from "sonner"
|
||||
import { useCopyToClipboard } from "usehooks-ts"
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
|
||||
const BankReconciliationStatement = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
if (!bankAccount) {
|
||||
return <MissingFiltersBanner text={_("Please select a bank account to view the bank reconciliation statement.")} />
|
||||
}
|
||||
|
||||
if (!dates) {
|
||||
return <MissingFiltersBanner text={_("Please select dates to view the bank reconciliation statement.")} />
|
||||
}
|
||||
|
||||
return <BankReconciliationStatementView />
|
||||
}
|
||||
interface BankClearanceSummaryEntry {
|
||||
payment_document: string
|
||||
payment_entry: string
|
||||
posting_date: string,
|
||||
reference_no: string,
|
||||
credit: number,
|
||||
debit: number,
|
||||
against_account: string,
|
||||
ref_date: string,
|
||||
account_currency: string,
|
||||
clearance_date: string
|
||||
}
|
||||
|
||||
const BankReconciliationStatementView = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
account: bankAccount?.account,
|
||||
report_date: dates.toDate,
|
||||
company: companyID
|
||||
})
|
||||
}, [bankAccount, dates, companyID])
|
||||
|
||||
const { data, error } = useFrappeGetCall<{ message: QueryReportReturnType }>('frappe.desk.query_report.run', {
|
||||
report_name: 'Bank Reconciliation Statement',
|
||||
filters,
|
||||
ignore_prepared_report: 1,
|
||||
are_default_filters: false,
|
||||
}, `Report-Bank Reconciliation Statement-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
|
||||
|
||||
const [, copyToClipboard] = useCopyToClipboard()
|
||||
|
||||
const onCopy = useCallback(
|
||||
(text: string) => {
|
||||
copyToClipboard(text).then(() => {
|
||||
toast.success(_("Copied to clipboard"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard],
|
||||
)
|
||||
|
||||
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "posting_date",
|
||||
header: _("Posting Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.posting_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "payment_document",
|
||||
header: _("Document Type"),
|
||||
size: 140,
|
||||
cell: ({ row }) => _(row.original.payment_document),
|
||||
},
|
||||
{
|
||||
id: "payment_entry",
|
||||
header: _("Payment Document"),
|
||||
size: 300,
|
||||
meta: {
|
||||
getTooltipText: (r) => {
|
||||
const x = r as BankClearanceSummaryEntry
|
||||
const parts = [x.payment_document, x.payment_entry].filter(Boolean)
|
||||
return parts.length ? parts.join(" · ") : undefined
|
||||
},
|
||||
} satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => {
|
||||
const { payment_document, payment_entry } = row.original
|
||||
return payment_document ? (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
|
||||
href={`/desk/${slug(payment_document)}/${payment_entry}`}
|
||||
>
|
||||
{payment_entry}
|
||||
</a>
|
||||
) : (
|
||||
payment_entry
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "debit",
|
||||
header: _("Debit"),
|
||||
size: 112,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.debit, row.original.account_currency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "credit",
|
||||
header: _("Credit"),
|
||||
size: 112,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.credit, row.original.account_currency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "against_account",
|
||||
header: _("Against Account"),
|
||||
meta: { gridWidth: "minmax(0,1.25fr)" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
|
||||
href={`/desk/account/${row.original.against_account}`}
|
||||
>
|
||||
{row.original.against_account}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "reference_no",
|
||||
header: _("Reference #"),
|
||||
cell: ({ row }) => {
|
||||
const ref = row.original.reference_no
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-ink-gray-8 hover:underline min-w-0 w-full cursor-pointer truncate text-start underline-offset-4"
|
||||
onClick={() => onCopy(ref)}
|
||||
>
|
||||
{ref}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "ref_date",
|
||||
header: _("Reference Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.ref_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "clearance_date",
|
||||
header: _("Clearance Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
],
|
||||
[onCopy],
|
||||
)
|
||||
|
||||
const statementRows = useMemo(() => {
|
||||
if (!data?.message.result) return []
|
||||
return data.message.result.filter((row: BankClearanceSummaryEntry) => Boolean(row.payment_entry))
|
||||
}, [data])
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all entries posted against the bank account {0} which have not been cleared till {1}.", [`<strong>${bankAccount?.account}</strong>`, `<strong>${formatDate(dates.toDate)}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && <SummarySection data={data} />}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Bank Reconciliation Statement")}</p>
|
||||
<ListView
|
||||
data={statementRows}
|
||||
columns={statementColumns}
|
||||
getRowId={(row) => row.payment_entry}
|
||||
maxHeight="min(70vh, 640px)"
|
||||
emptyState={_("No entries with a payment document in this list.")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.message.result.length === 0 &&
|
||||
<Empty>
|
||||
<EmptyMedia>
|
||||
<ScrollTextIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No entries found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("There are no accounting entries in the system for the selected account and dates.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const SummarySection = ({ data }: { data: { message: QueryReportReturnType } }) => {
|
||||
|
||||
const company = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { bankStatementBalanceAsPerGL, outstandingChecksDebit, outstandingChecksCredit, incorrectlyClearedEntriesDebit, incorrectlyClearedEntriesCredit, calculatedBankStatementBalance } = useMemo(() => {
|
||||
|
||||
// Loop over the results and find the corresponding rows
|
||||
|
||||
let bankStatementBalanceAsPerGL = 0
|
||||
|
||||
let outstandingChecksDebit = 0
|
||||
let outstandingChecksCredit = 0
|
||||
|
||||
let incorrectlyClearedEntriesDebit = 0
|
||||
let incorrectlyClearedEntriesCredit = 0
|
||||
|
||||
let calculatedBankStatementBalance = 0
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
data?.message.result.forEach((r: any) => {
|
||||
if (r.payment_entry === 'Bank Statement balance as per General Ledger') {
|
||||
bankStatementBalanceAsPerGL = r.debit - r.credit
|
||||
}
|
||||
|
||||
if (r.payment_entry === 'Outstanding Checks and Deposits to clear') {
|
||||
outstandingChecksDebit = r.debit
|
||||
outstandingChecksCredit = r.credit
|
||||
}
|
||||
|
||||
if (r.payment_entry === 'Checks and Deposits incorrectly cleared') {
|
||||
incorrectlyClearedEntriesDebit = r.debit
|
||||
incorrectlyClearedEntriesCredit = r.credit
|
||||
}
|
||||
|
||||
if (r.payment_entry === 'Calculated Bank Statement balance') {
|
||||
calculatedBankStatementBalance = r.debit - r.credit
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
bankStatementBalanceAsPerGL,
|
||||
outstandingChecksDebit,
|
||||
outstandingChecksCredit,
|
||||
incorrectlyClearedEntriesDebit,
|
||||
incorrectlyClearedEntriesCredit,
|
||||
calculatedBankStatementBalance
|
||||
}
|
||||
|
||||
}, [data])
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(company)
|
||||
|
||||
return <div className="flex gap-4 items-start justify-between">
|
||||
<StatContainer>
|
||||
<StatLabel>{_("Bank Statement Balance as per General Ledger")}</StatLabel>
|
||||
<StatValue className="font-numeric">{formatCurrency(bankStatementBalanceAsPerGL, currency)}</StatValue>
|
||||
</StatContainer>
|
||||
|
||||
<StatContainer>
|
||||
<StatLabel>{_("Outstanding Checks and Deposits to clear")}</StatLabel>
|
||||
<StatValue className="font-numeric">{formatCurrency(outstandingChecksDebit - outstandingChecksCredit, currency)}</StatValue>
|
||||
</StatContainer>
|
||||
|
||||
{(incorrectlyClearedEntriesDebit > 0 || incorrectlyClearedEntriesCredit > 0) && <StatContainer>
|
||||
<StatLabel className="text-ink-red-3">{_("Checks and Deposits incorrectly cleared")}</StatLabel>
|
||||
<StatValue className="text-ink-red-3 font-numeric">{formatCurrency(incorrectlyClearedEntriesDebit - incorrectlyClearedEntriesCredit)}</StatValue>
|
||||
{/* <div className="" divider={<StackDivider height='20px' />}>
|
||||
{incorrectlyClearedEntriesDebit !== 0 && <StatHelpText>Debit: {formatCurrency(incorrectlyClearedEntriesDebit)}</StatHelpText>}
|
||||
{incorrectlyClearedEntriesCredit !== 0 && <StatHelpText>Credit: {formatCurrency(incorrectlyClearedEntriesCredit)}</StatHelpText>}
|
||||
</div> */}
|
||||
</StatContainer>}
|
||||
<StatContainer>
|
||||
<StatLabel>{_("Calculated Bank Statement Balance")}</StatLabel>
|
||||
<StatValue className="font-numeric">{formatCurrency(calculatedBankStatementBalance)}</StatValue>
|
||||
</StatContainer>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BankReconciliationStatement
|
||||
@@ -1,422 +0,0 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import { ArrowDownRight, ArrowUpRight, CheckCircle2, ChevronDown, DollarSign, ExternalLink, ImportIcon, ListIcon, Search, Undo2, XCircle } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { useGetBankTransactions } from "./utils"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import _ from "@/lib/translate"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import CurrencyInput from "react-currency-input-field"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { getCurrencySymbol } from "@/lib/currency"
|
||||
import { useDebounceValue } from "usehooks-ts"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo, useState } from "react"
|
||||
import { Link } from "react-router"
|
||||
import { Empty, EmptyTitle, EmptyHeader, EmptyMedia, EmptyDescription, EmptyContent } from "@/components/ui/empty"
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group"
|
||||
|
||||
const BankTransactions = () => {
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
if (!selectedBank || !dates) {
|
||||
return <MissingFiltersBanner text={_("Please select a bank and set the date range")} />
|
||||
}
|
||||
|
||||
return <>
|
||||
<BankTransactionListView />
|
||||
</>
|
||||
}
|
||||
|
||||
const BankTransactionListView = () => {
|
||||
|
||||
const { data, error } = useGetBankTransactions()
|
||||
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const formattedFromDate = formatDate(dates.fromDate)
|
||||
const formattedToDate = formatDate(dates.toDate)
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onUndo = useCallback(
|
||||
(transaction: BankTransaction) => {
|
||||
setBankRecUnreconcileModalAtom(transaction.name)
|
||||
},
|
||||
[setBankRecUnreconcileModalAtom],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
() => bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? ""),
|
||||
[bankAccount?.account_currency, bankAccount?.company],
|
||||
)
|
||||
|
||||
const transactionColumns = useMemo<ColumnDef<BankTransaction, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "date",
|
||||
header: _("Date"),
|
||||
size: 112,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.date),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: _("Description"),
|
||||
size: 250,
|
||||
// meta: { gridWidth: "minmax(0,2fr)" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => row.original.description,
|
||||
},
|
||||
{
|
||||
accessorKey: "reference_number",
|
||||
header: _("Reference #"),
|
||||
size: 128,
|
||||
cell: ({ row }) => row.original.reference_number,
|
||||
},
|
||||
{
|
||||
accessorKey: "withdrawal",
|
||||
header: _("Withdrawal"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.withdrawal, accountCurrency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "deposit",
|
||||
header: _("Deposit"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.deposit, accountCurrency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "unallocated_amount",
|
||||
header: _("Unallocated"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => <span className="font-numeric">{formatCurrency(row.original.unallocated_amount, accountCurrency)}</span>,
|
||||
},
|
||||
{
|
||||
accessorKey: "transaction_type",
|
||||
header: _("Type"),
|
||||
size: 112,
|
||||
cell: ({ row }) =>
|
||||
row.original.transaction_type ? <Badge>{row.original.transaction_type}</Badge> : null,
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: _("Status"),
|
||||
size: 168,
|
||||
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => {
|
||||
const tx = row.original
|
||||
if (!tx.allocated_amount || (tx.allocated_amount && tx.allocated_amount === 0)) {
|
||||
return (
|
||||
<Badge theme="red">
|
||||
<XCircle />
|
||||
{_("Not Reconciled")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
if (tx.allocated_amount && tx.allocated_amount > 0 && tx.unallocated_amount !== 0) {
|
||||
return (
|
||||
<Badge theme="orange">
|
||||
<CheckCircle2 />
|
||||
{_("Partially Reconciled")}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Badge theme="green">
|
||||
<CheckCircle2 />
|
||||
{_("Reconciled")}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: _("Actions"),
|
||||
size: 200,
|
||||
enableResizing: false,
|
||||
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex gap-2 ps-0.5 items-center">
|
||||
<Button variant="ghost" asChild size='sm'>
|
||||
<a
|
||||
href={`/desk/bank-transaction/${row.original.name}`}
|
||||
target="_blank"
|
||||
|
||||
rel="noreferrer"
|
||||
// className="text-ink-gray-8 underline underline-offset-4 inline-flex gap-2"
|
||||
>
|
||||
{_("View")} <ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</Button>
|
||||
{row.original.allocated_amount && row.original.allocated_amount > 0 ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onUndo(row.original)}
|
||||
size="sm"
|
||||
theme='red'
|
||||
>
|
||||
<Undo2 />
|
||||
{_("Undo")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[accountCurrency, onUndo],
|
||||
)
|
||||
|
||||
const [search, setSearch] = useDebounceValue('', 250)
|
||||
const [amountFilter, setAmountFilter] = useState<{ value: number, stringValue?: string | number }>({ value: 0, stringValue: '0.00' })
|
||||
const [typeFilter, setTypeFilter] = useState('All')
|
||||
const [status, setStatus] = useState<'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled'>('All')
|
||||
|
||||
const onSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value)
|
||||
}
|
||||
|
||||
const filteredResults = useMemo(() => {
|
||||
if (!data) {
|
||||
return []
|
||||
}
|
||||
|
||||
return data.message.filter((transaction) => {
|
||||
|
||||
if (search && !transaction.description?.toLowerCase().includes(search.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (typeFilter !== 'All') {
|
||||
if (typeFilter === 'Debits' && transaction.deposit && transaction.deposit > 0) {
|
||||
return false
|
||||
}
|
||||
if (typeFilter === 'Credits' && transaction.withdrawal && transaction.withdrawal > 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (status !== 'All') {
|
||||
if (status === 'Reconciled' && transaction.status !== 'Reconciled') {
|
||||
return false
|
||||
}
|
||||
if (status === 'Unreconciled') {
|
||||
if (transaction.status === 'Reconciled') {
|
||||
return false
|
||||
}
|
||||
// Filter out partially reconciled transactions
|
||||
if (transaction.allocated_amount && transaction.allocated_amount > 0 && transaction.unallocated_amount !== 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if (status === 'Partially Reconciled') {
|
||||
|
||||
if (transaction.status === 'Reconciled') {
|
||||
return false
|
||||
}
|
||||
if ((transaction.allocated_amount ?? 0) === 0) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (amountFilter.value > 0 && transaction.withdrawal !== amountFilter.value && transaction.deposit !== amountFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
}, [data, search, amountFilter, typeFilter, status])
|
||||
|
||||
return <div className="space-y-2 py-2">
|
||||
|
||||
<div className="flex gap-2 justify-between items-center">
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Below is a list of all bank transactions imported in the system for the bank account {0} between {1} and {2}.", [`<strong>${bankAccount?.account_name}</strong>`, `<strong>${formattedFromDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
</Paragraph>
|
||||
|
||||
<Button size='md' variant='subtle' asChild>
|
||||
<Link to="/statement-importer">
|
||||
<ImportIcon />
|
||||
{_("Import Bank Statement")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<Filters
|
||||
onSearchChange={onSearchChange}
|
||||
search={search}
|
||||
results={filteredResults}
|
||||
setAmountFilter={setAmountFilter}
|
||||
amountFilter={amountFilter}
|
||||
onTypeFilterChange={setTypeFilter}
|
||||
typeFilter={typeFilter}
|
||||
status={status}
|
||||
setStatus={setStatus}
|
||||
/>
|
||||
|
||||
<ListView
|
||||
data={filteredResults}
|
||||
columns={transactionColumns}
|
||||
getRowId={(row) => row.name}
|
||||
maxHeight="calc(100vh - 200px)"
|
||||
scrollAreaClassName="min-h-[calc(100vh-200px)]"
|
||||
emptyState={<Empty>
|
||||
<EmptyMedia>
|
||||
<ListIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No bank transactions found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("There are no transactions in the system for the selected bank account and dates that match the filters.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{data && data.message.length === 0 ? <EmptyContent>
|
||||
<Button type='button' asChild variant='outline'>
|
||||
<Link to="/statement-importer">
|
||||
{_("Import Bank Statement")}
|
||||
</Link>
|
||||
</Button>
|
||||
</EmptyContent> : null}
|
||||
</Empty>}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
interface FilterProps {
|
||||
onSearchChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
search: string
|
||||
results: BankTransaction[]
|
||||
setAmountFilter: (value: { value: number, stringValue?: string | number }) => void
|
||||
amountFilter: { value: number, stringValue?: string | number }
|
||||
onTypeFilterChange: (type: string) => void
|
||||
typeFilter: string
|
||||
status: 'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled'
|
||||
setStatus: (status: 'Reconciled' | 'Unreconciled' | 'All' | 'Partially Reconciled') => void
|
||||
}
|
||||
|
||||
|
||||
const Filters = ({
|
||||
onSearchChange,
|
||||
search,
|
||||
results,
|
||||
setAmountFilter,
|
||||
amountFilter,
|
||||
onTypeFilterChange,
|
||||
typeFilter,
|
||||
status,
|
||||
setStatus,
|
||||
|
||||
}: FilterProps) => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const currency = bankAccount?.account_currency ?? getCompanyCurrency(bankAccount?.company ?? '')
|
||||
const currencySymbol = getCurrencySymbol(currency)
|
||||
const formatInfo = getCurrencyFormatInfo(currency)
|
||||
const groupSeparator = formatInfo.group_sep || ","
|
||||
const decimalSeparator = formatInfo.decimal_str || "."
|
||||
|
||||
return <div className="flex py-2 w-full gap-2">
|
||||
<InputGroup variant='outline'>
|
||||
<label className="sr-only">{_("Search transactions")}</label>
|
||||
<InputGroupAddon>
|
||||
<Search className="w-4 h-4 text-ink-gray-5" />
|
||||
</InputGroupAddon>
|
||||
<Input
|
||||
placeholder={_("Search")} type='search' onChange={onSearchChange} variant='outline' defaultValue={search}
|
||||
className="border-none px-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0" />
|
||||
<InputGroupAddon align='inline-end'>
|
||||
<span className="text-sm text-ink-gray-5 text-nowrap whitespace-nowrap">{results?.length} {_(results?.length === 1 ? "result" : "results")}</span>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
|
||||
<div className="w-[25%]">
|
||||
<label className="sr-only">{_("Filter by amount")}</label>
|
||||
<CurrencyInput
|
||||
groupSeparator={groupSeparator}
|
||||
decimalSeparator={decimalSeparator}
|
||||
placeholder={`${currencySymbol}0${decimalSeparator}00`}
|
||||
decimalsLimit={2}
|
||||
value={amountFilter.stringValue}
|
||||
maxLength={12}
|
||||
decimalScale={2}
|
||||
prefix={currencySymbol}
|
||||
onValueChange={(v, _n, values) => {
|
||||
// If the input ends with a decimal or a decimal with trailing zeroes, store the string since we need the user to be able to type the decimals.
|
||||
// When the user eventually types the decimals or blurs out, the value is formatted anyway.
|
||||
// Otherwise store the float value
|
||||
// Check if the value ends with a decimal or a decimal with trailing zeroes
|
||||
const isDecimal = v?.endsWith(decimalSeparator) || v?.endsWith(decimalSeparator + '0')
|
||||
const newValue = isDecimal ? v : values?.float ?? ''
|
||||
setAmountFilter({
|
||||
value: Number(newValue),
|
||||
stringValue: newValue
|
||||
})
|
||||
}}
|
||||
// @ts-expect-error - CurrencyInputProps doesn't have a variant prop but Input does
|
||||
variant={"outline"}
|
||||
customInput={Input}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[25%]">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size='md' className="min-w-32 w-full text-start justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
{typeFilter === 'All' ? <DollarSign className="w-4 h-4 text-ink-gray-5" /> : typeFilter === 'Debits' ? <ArrowUpRight className="w-4 h-4 text-ink-red-3" /> : <ArrowDownRight className="w-4 h-4 text-ink-green-3" />}
|
||||
{_(typeFilter)}
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('All')}><DollarSign /> {_("All")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('Debits')}><ArrowUpRight className="text-ink-red-3" /> {_("Debits")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onTypeFilterChange('Credits')}><ArrowDownRight className="text-ink-green-3" /> {_("Credits")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="w-[25%]">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size='md' className="min-w-32 w-full text-start justify-between">
|
||||
<div className="flex gap-2 items-center">
|
||||
{status === 'All' ? <ListIcon className="w-4 h-4 text-ink-gray-5" /> :
|
||||
status === 'Reconciled' ? <CheckCircle2 className="w-4 h-4 text-ink-green-3" /> :
|
||||
status === 'Unreconciled' ? <XCircle className="w-4 h-4 text-ink-red-3" /> :
|
||||
<CheckCircle2 className="w-4 h-4 text-yellow-500" />}
|
||||
{_(status)}
|
||||
</div>
|
||||
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setStatus('All')}>{<ListIcon className="w-4 h-4 text-ink-gray-5" />} {_("All")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus('Reconciled')}>{<CheckCircle2 className="w-4 h-4 text-ink-green-3" />} {_("Reconciled")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus('Unreconciled')}>{<XCircle className="w-4 h-4 text-ink-red-3" />} {_("Unreconciled")}</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setStatus('Partially Reconciled')}>{<CheckCircle2 className="w-4 h-4 text-yellow-500" />} {_("Partially Reconciled")}</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default BankTransactions
|
||||
@@ -1,52 +0,0 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useAtom } from "jotai"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { lazy, Suspense } from "react"
|
||||
import { bankRecUnreconcileModalAtom } from "./bankRecAtoms"
|
||||
import _ from "@/lib/translate"
|
||||
|
||||
const BankTransactionUnreconcileModalBody = lazy(() => import('./BankTransactionUnreconcileModalBody'))
|
||||
|
||||
const BankTransactionUnreconcileModalFallback = () => (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const BankTransactionUnreconcileModal = () => {
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onOpenChange = (v: boolean) => {
|
||||
if (!v) {
|
||||
setBankRecUnreconcileModal('')
|
||||
}
|
||||
}
|
||||
|
||||
if (!unreconcileModal) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="min-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{_("Are you sure you want to unreconcile this transaction?")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<Suspense fallback={<BankTransactionUnreconcileModalFallback />}>
|
||||
<BankTransactionUnreconcileModalBody />
|
||||
</Suspense>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankTransactionUnreconcileModal
|
||||
@@ -1,109 +0,0 @@
|
||||
import { AlertDialogAction, AlertDialogCancel, AlertDialogFooter } from "@/components/ui/alert-dialog"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useMemo } from "react"
|
||||
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import _ from "@/lib/translate"
|
||||
|
||||
const BankTransactionUnreconcileModalBody = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const { data: transaction, error, isLoading } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
|
||||
|
||||
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
|
||||
|
||||
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
call({
|
||||
transaction_name: unreconcileModal
|
||||
}).then(() => {
|
||||
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
|
||||
toast.success(_("Transaction Unreconciled"))
|
||||
setBankRecUnreconcileModal('')
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const vouchersWhichWillBeCancelled = useMemo(() => {
|
||||
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
|
||||
}, [transaction])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
{unreconcileError && <ErrorBanner error={unreconcileError} />}
|
||||
{transaction && <SelectedTransactionDetails transaction={transaction} />}
|
||||
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Document")}</TableHead>
|
||||
<TableHead>{_("Amount")}</TableHead>
|
||||
<TableHead>{_("Reconciliation Type")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transaction?.payment_entries?.map((voucher) => {
|
||||
return (
|
||||
<TableRow key={voucher.name}>
|
||||
<TableCell>
|
||||
<a
|
||||
className="underline underline-offset-4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
|
||||
>
|
||||
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
|
||||
<TableCell>
|
||||
{voucher.reconciliation_type === 'Voucher Created' ?
|
||||
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
|
||||
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="py-4">
|
||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
|
||||
<span>The following documents will be <strong>cancelled</strong>:</span>
|
||||
)}
|
||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
|
||||
<ol className="ms-6 list-disc [&>li]:mt-2">
|
||||
{vouchersWhichWillBeCancelled?.map((voucher) => {
|
||||
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading || isLoading}>
|
||||
{_("Unreconcile")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankTransactionUnreconcileModalBody
|
||||
@@ -1,92 +0,0 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { selectedCompanyAtom, useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { useSetAtom } from "jotai"
|
||||
import { Building2, Check, ChevronDown } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import _ from "@/lib/translate"
|
||||
import { selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
|
||||
const CompanySelector = ({ onChange }: { onChange?: (company: string) => void }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const options = window.frappe?.boot?.docs?.filter((doc: Record<string, any>) => doc.doctype === ":Company").map((company: Record<string, any>) => company.name) || []
|
||||
|
||||
const setSelectedCompany = useSetAtom(selectedCompanyAtom)
|
||||
const setSelectedBankAccount = useSetAtom(selectedBankAccountAtom)
|
||||
const selectedCompany = useCurrentCompany()
|
||||
|
||||
const handleSelectCompany = (company: string) => {
|
||||
setSelectedCompany(company)
|
||||
setSearchQuery("")
|
||||
setOpen(false)
|
||||
// Only reset bank account if the company is changed
|
||||
if (selectedCompany !== company) {
|
||||
setSelectedBankAccount(null)
|
||||
onChange?.(company)
|
||||
}
|
||||
}
|
||||
|
||||
return (<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
type='button'
|
||||
role="combobox"
|
||||
size='md'
|
||||
aria-expanded={open}
|
||||
className="justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 />
|
||||
{selectedCompany}
|
||||
</div>
|
||||
<ChevronDown className="text-ink-gray-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-56 w-fit p-0">
|
||||
<Command value={selectedCompany}>
|
||||
{options.length > 5 && <CommandInput placeholder={_("Search company...")} className="h-9" />}
|
||||
<CommandList>
|
||||
<CommandEmpty>{_("No company found.")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option: string) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={(currentValue) => {
|
||||
handleSelectCompany(currentValue)
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
<Check
|
||||
className={cn(
|
||||
"ms-auto",
|
||||
searchQuery === option ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>)
|
||||
}
|
||||
|
||||
export default CompanySelector
|
||||
@@ -1,229 +0,0 @@
|
||||
import { useAtomValue } from "jotai"
|
||||
import { MissingFiltersBanner } from "./MissingFiltersBanner"
|
||||
import { bankRecDateAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { QueryReportReturnType } from "@/types/custom/Reports"
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { ListView, type ListViewColumnMeta } from "@/components/ui/list-view"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { getCompanyCurrency } from "@/lib/company"
|
||||
import { getErrorMessage, slug } from "@/lib/frappe"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { toast } from "sonner"
|
||||
import { PartyPopper } from "lucide-react"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import _ from "@/lib/translate"
|
||||
import { Empty, EmptyTitle, EmptyDescription, EmptyMedia, EmptyHeader } from "@/components/ui/empty"
|
||||
|
||||
const IncorrectlyClearedEntries = () => {
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
if (!companyID || !bankAccount || !dates) {
|
||||
const missingFields = []
|
||||
if (!companyID) {
|
||||
missingFields.push('Company')
|
||||
}
|
||||
if (!bankAccount) {
|
||||
missingFields.push('Bank Account')
|
||||
}
|
||||
if (!dates) {
|
||||
missingFields.push('Dates')
|
||||
}
|
||||
return <MissingFiltersBanner text={`Please select ${missingFields.join(', ')} to view the incorrectly cleared entries.`} />
|
||||
}
|
||||
|
||||
return <IncorrectlyClearedEntriesView />
|
||||
}
|
||||
|
||||
interface IncorrectlyClearedEntry {
|
||||
payment_document: string
|
||||
payment_entry: string
|
||||
debit: number
|
||||
credit: number
|
||||
posting_date: string,
|
||||
clearance_date: string,
|
||||
}
|
||||
|
||||
const IncorrectlyClearedEntriesView = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const filters = useMemo(() => {
|
||||
return JSON.stringify({
|
||||
company: companyID,
|
||||
account: bankAccount?.account,
|
||||
report_date: dates.toDate
|
||||
})
|
||||
}, [companyID, bankAccount, dates])
|
||||
|
||||
const { data, error, mutate } = useFrappeGetCall<{ message: QueryReportReturnType<IncorrectlyClearedEntry> }>('frappe.desk.query_report.run', {
|
||||
report_name: 'Cheques and Deposits Incorrectly cleared',
|
||||
filters,
|
||||
ignore_prepared_report: 1,
|
||||
are_default_filters: false,
|
||||
}, `Report-Cheques and Deposits Incorrectly cleared-${filters}`, { keepPreviousData: true, revalidateOnFocus: false }, 'POST')
|
||||
|
||||
const formattedToDate = formatDate(dates.toDate)
|
||||
|
||||
const { call: clearClearingDate } = useFrappePostCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.clear_clearing_date')
|
||||
|
||||
const onClearClick = useCallback(
|
||||
(voucher_type: string, voucher_name: string) => {
|
||||
clearClearingDate({ voucher_type, voucher_name })
|
||||
.then(() => {
|
||||
toast.success(_("Cleared"), {
|
||||
duration: 1000,
|
||||
})
|
||||
mutate()
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(_("There was an error while performing the action."), {
|
||||
description: getErrorMessage(e),
|
||||
duration: 5000,
|
||||
})
|
||||
})
|
||||
},
|
||||
[clearClearingDate, mutate],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
() => bankAccount?.account_currency ?? getCompanyCurrency(companyID),
|
||||
[bankAccount?.account_currency, companyID],
|
||||
)
|
||||
|
||||
const incorrectlyClearedColumns = useMemo<ColumnDef<IncorrectlyClearedEntry, unknown>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "payment_document",
|
||||
header: _("Document Type"),
|
||||
size: 128,
|
||||
cell: ({ row }) => _(row.original.payment_document),
|
||||
},
|
||||
{
|
||||
id: "payment_entry",
|
||||
header: _("Payment Document"),
|
||||
size: 160,
|
||||
meta: {
|
||||
getTooltipText: (r) => {
|
||||
const x = r as IncorrectlyClearedEntry
|
||||
return [x.payment_document, x.payment_entry].filter(Boolean).join(" · ") || undefined
|
||||
},
|
||||
} satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-ink-gray-8 block min-w-0 w-full underline underline-offset-4"
|
||||
href={`/desk/${slug(row.original.payment_document)}/${row.original.payment_entry}`}
|
||||
>
|
||||
{row.original.payment_entry}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "debit",
|
||||
header: _("Debit"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatCurrency(row.original.debit, accountCurrency),
|
||||
},
|
||||
{
|
||||
accessorKey: "credit",
|
||||
header: _("Credit"),
|
||||
size: 120,
|
||||
meta: { align: "right" } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatCurrency(row.original.credit, accountCurrency),
|
||||
},
|
||||
{
|
||||
accessorKey: "posting_date",
|
||||
header: _("Posting Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.posting_date),
|
||||
},
|
||||
{
|
||||
accessorKey: "clearance_date",
|
||||
header: _("Clearance Date"),
|
||||
size: 118,
|
||||
meta: { tabularNums: true } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: _("Actions"),
|
||||
size: 180,
|
||||
enableResizing: false,
|
||||
meta: { truncate: false, truncateTooltip: false } satisfies ListViewColumnMeta,
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="text-ink-red-3 px-0"
|
||||
onClick={() => onClearClick(row.original.payment_document, row.original.payment_entry)}
|
||||
>
|
||||
{_("Reset Clearing Date")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[accountCurrency, onClearClick],
|
||||
)
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
<div>
|
||||
<Paragraph className="text-sm">
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("This report shows all entries in the system where the <strong>clearance date is before the posting date</strong> which is incorrect.")
|
||||
}} />
|
||||
<br />
|
||||
{data && data.message.result.length > 0 && <span>
|
||||
<span dangerouslySetInnerHTML={{
|
||||
__html: _("Entries below have a posting date after {0} but the clearance date is before {1}.", [`<strong>${formattedToDate}</strong>`, `<strong>${formattedToDate}</strong>`])
|
||||
}} />
|
||||
<br />
|
||||
{_("You can reset the clearing dates of these entries here.")}
|
||||
</span>}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
{data && data.message.result.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-ink-gray-5 text-sm">{_("Incorrectly cleared entries as per the report.")}</p>
|
||||
<ListView
|
||||
data={data.message.result}
|
||||
columns={incorrectlyClearedColumns}
|
||||
getRowId={(row) => `${row.payment_entry}-${row.posting_date}`}
|
||||
maxHeight="min(70vh, 640px)"
|
||||
emptyState={_("No rows to display.")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && data.message.result.length === 0 &&
|
||||
<Empty>
|
||||
<EmptyMedia>
|
||||
<PartyPopper />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("It's all good!")}</EmptyTitle>
|
||||
<EmptyDescription>{_("There are no entries in the system where the clearance date is before the posting date.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
}
|
||||
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
export default IncorrectlyClearedEntries
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,93 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { FilterIcon } from 'lucide-react'
|
||||
import { bankRecMatchFilters } from './bankRecAtoms'
|
||||
import { useAtom } from 'jotai'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { useFrappeGetCall } from 'frappe-react-sdk'
|
||||
import { scrub } from '@/lib/frappe'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const MatchFilters = () => {
|
||||
return (
|
||||
<Popover>
|
||||
<Tooltip>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size='md' isIconButton variant='outline' aria-label={_("Configure match filters for vouchers")}>
|
||||
<FilterIcon />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<TooltipContent>
|
||||
{_("Configure match filters for vouchers")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent>
|
||||
<div className="flex flex-col gap-4">
|
||||
<ToggleSwitch label={_("Show Only Exact Amount")} id="exact_match" />
|
||||
<Separator />
|
||||
<MatchFiltersContent />
|
||||
<ToggleSwitch label={_("Bank Transaction")} id="bank_transaction" />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
const MatchFiltersContent = () => {
|
||||
|
||||
const { data } = useFrappeGetCall<{ message: string[] }>("erpnext.accounts.doctype.bank_transaction.bank_transaction.get_doctypes_for_bank_reconciliation", undefined,
|
||||
"bank_rec_doctypes", {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
revalidateOnReconnect: false,
|
||||
}
|
||||
)
|
||||
|
||||
const doctypes = useMemo(() => {
|
||||
const STANDARD_DOCTYPES = ["Payment Entry", "Journal Entry", "Purchase Invoice", "Sales Invoice"]
|
||||
if (data) {
|
||||
return data.message.map(doctype => ({
|
||||
label: doctype,
|
||||
id: scrub(doctype),
|
||||
}))
|
||||
|
||||
} else {
|
||||
return STANDARD_DOCTYPES.map(doctype => ({
|
||||
label: doctype,
|
||||
id: scrub(doctype),
|
||||
}))
|
||||
}
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{doctypes.map((doctype) => (
|
||||
<ToggleSwitch key={doctype.id} label={doctype.label} id={doctype.id} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ToggleSwitch = ({ label, id }: { label: string, id: string }) => {
|
||||
|
||||
const [matchFilters, setMatchFilters] = useAtom(bankRecMatchFilters)
|
||||
|
||||
return <div className="flex items-center space-x-2">
|
||||
<Switch id={id} checked={matchFilters.includes(id)} onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setMatchFilters([...matchFilters, id])
|
||||
} else {
|
||||
setMatchFilters(matchFilters.filter(filter => filter !== id))
|
||||
}
|
||||
}} />
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default MatchFilters
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Paragraph } from "@/components/ui/typography"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ReactNode } from "react"
|
||||
|
||||
|
||||
export const MissingFiltersBanner = ({ text, className }: { text: ReactNode, className?: string }) => {
|
||||
return <div className={cn("min-h-[50vh] flex items-center justify-center", className)}>
|
||||
<Paragraph>{text}</Paragraph>
|
||||
</div>
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useAtom } from "jotai"
|
||||
import { bankRecRecordPaymentModalAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
|
||||
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
|
||||
import _ from "@/lib/translate"
|
||||
import { lazy, Suspense } from "react"
|
||||
|
||||
const RecordPaymentModalContent = lazy(() => import('./RecordPaymentModalContent'))
|
||||
|
||||
const RecordPaymentModal = () => {
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordPaymentModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Record Payment")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record a payment entry against a customer or supplier")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<RecordPaymentModalContent />
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default RecordPaymentModal
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useCurrentCompany } from "@/hooks/useCurrentCompany"
|
||||
import _ from "@/lib/translate"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
import { useFrappeCreateDoc } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import { RuleForm } from "./RuleForm"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { SettingsPanelHeader, SettingsPanelDescription, SettingsPanelTitle, SettingsPanelContent } from "@/components/ui/settings-dialog"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
type Props = {
|
||||
onCreate: VoidFunction
|
||||
}
|
||||
|
||||
const CreateNewRule = ({ onCreate }: Props) => {
|
||||
|
||||
const currentCompany = useCurrentCompany()
|
||||
|
||||
const form = useForm<BankTransactionRule>({
|
||||
defaultValues: {
|
||||
rule_name: "",
|
||||
company: currentCompany,
|
||||
rule_description: "",
|
||||
transaction_type: "Any",
|
||||
classify_as: 'Bank Entry',
|
||||
bank_entry_type: "Single Account",
|
||||
description_rules: [{
|
||||
check: "Contains",
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const { createDoc, loading, error } = useFrappeCreateDoc<BankTransactionRule>()
|
||||
|
||||
const onSubmit = (data: BankTransactionRule) => {
|
||||
createDoc("Bank Transaction Rule", data)
|
||||
.then(() => {
|
||||
toast.success(_("Rule created successfully"))
|
||||
onCreate()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsPanelHeader
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant='outline' size='md' type='button' onClick={() => onCreate()}>{_("Cancel")}</Button>
|
||||
<Button type='submit' form='rule-form' size='md' disabled={loading}>
|
||||
{_("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsPanelTitle>
|
||||
{_("New Rule")}
|
||||
</SettingsPanelTitle>
|
||||
<SettingsPanelDescription>
|
||||
{_("Create a new rule to automatically classify transactions.")}
|
||||
</SettingsPanelDescription>
|
||||
</SettingsPanelHeader>
|
||||
<SettingsPanelContent className="px-0">
|
||||
<Form {...form}>
|
||||
<form id='rule-form' onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col justify-between h-full overflow-y-auto px-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<RuleForm />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</SettingsPanelContent>
|
||||
</>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateNewRule
|
||||
@@ -1,101 +0,0 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import _ from "@/lib/translate"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
import { FrappeError, useFrappeGetDoc, useFrappeUpdateDoc } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import { RuleForm } from "./RuleForm"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { SettingsPanelContent, SettingsPanelDescription, SettingsPanelHeader, SettingsPanelTitle } from "@/components/ui/settings-dialog"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
|
||||
type Props = {
|
||||
onClose: VoidFunction,
|
||||
ruleID: string
|
||||
}
|
||||
|
||||
const EditRule = ({ onClose, ruleID }: Props) => {
|
||||
|
||||
const { data: rule, isValidating, error, mutate } = useFrappeGetDoc<BankTransactionRule>("Bank Transaction Rule", ruleID, undefined, {
|
||||
revalidateOnMount: true
|
||||
})
|
||||
|
||||
const { updateDoc, loading, error: updateError } = useFrappeUpdateDoc<BankTransactionRule>()
|
||||
|
||||
const onSubmit = (data: BankTransactionRule) => {
|
||||
updateDoc("Bank Transaction Rule", ruleID, data)
|
||||
.then(() => {
|
||||
toast.success(_("Rule updated."))
|
||||
mutate()
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
return <>
|
||||
<SettingsPanelHeader
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant='outline' size='md' type='button' onClick={() => onClose()}>{_("Cancel")}</Button>
|
||||
<Button type='submit' form='rule-form' size='md' disabled={isValidating || loading}>
|
||||
{_("Save")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsPanelTitle>
|
||||
{rule?.rule_name}
|
||||
</SettingsPanelTitle>
|
||||
<SettingsPanelDescription className="sr-only">
|
||||
{_("Edit this rule")}
|
||||
</SettingsPanelDescription>
|
||||
</SettingsPanelHeader>
|
||||
<SettingsPanelContent className="px-0">
|
||||
{isValidating && <div className="px-4 flex flex-col gap-4 h-full">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
<Skeleton className="h-10 w-full" />
|
||||
</div>}
|
||||
|
||||
{error && <div className="px-4 flex flex-col gap-4 h-full">
|
||||
<ErrorBanner error={error} />
|
||||
</div>}
|
||||
{rule && <EditRuleForm rule={rule} onSubmit={onSubmit} error={updateError} />}
|
||||
</SettingsPanelContent>
|
||||
</>
|
||||
|
||||
|
||||
}
|
||||
|
||||
const EditRuleForm = ({ rule, onSubmit, error }: { rule: BankTransactionRule, onSubmit: (data: BankTransactionRule) => void, error?: FrappeError | null }) => {
|
||||
|
||||
const form = useForm<BankTransactionRule>({
|
||||
defaultValues: {
|
||||
...rule,
|
||||
}
|
||||
})
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id='rule-form' onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col justify-between h-full overflow-y-auto px-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<RuleForm isEdit />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditRule
|
||||
@@ -1,799 +0,0 @@
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Dialog, DialogTitle, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"
|
||||
import { FormField, FormItem, FormLabel, FormControl } from "@/components/ui/form"
|
||||
import { AccountFormField, CurrencyFormField, DataField, LinkFormField, PartyTypeFormField, SelectFormField, SmallTextField } from "@/components/ui/form-elements"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||
import { SelectItem } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { H4, Paragraph } from "@/components/ui/typography"
|
||||
import { today } from "@/lib/date"
|
||||
import _ from "@/lib/translate"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { BankTransactionRule } from "@/types/Accounts/BankTransactionRule"
|
||||
import { BankTransactionRuleAccounts } from "@/types/Accounts/BankTransactionRuleAccounts"
|
||||
import { FrappeConfig, FrappeContext } from "frappe-react-sdk"
|
||||
import { ArrowDownRight, ArrowDownUp, ArrowRightLeftIcon, ArrowUpRight, LandmarkIcon, Plus, PlusCircleIcon, ReceiptIcon, Settings, Trash2 } from "lucide-react"
|
||||
import { ChangeEvent, useCallback, useContext, useMemo, useRef, useState } from "react"
|
||||
import { useFieldArray, useFormContext, useWatch } from "react-hook-form"
|
||||
|
||||
export const RuleForm = ({ isEdit = false }: { isEdit?: boolean }) => {
|
||||
|
||||
return <div className="flex flex-col gap-4">
|
||||
<DataField
|
||||
name='rule_name'
|
||||
label={_("Rule Name")}
|
||||
disabled={isEdit}
|
||||
isRequired
|
||||
inputProps={{
|
||||
maxLength: 140,
|
||||
disabled: isEdit,
|
||||
placeholder: _("Bank Charges, Salary, etc."),
|
||||
autoFocus: true,
|
||||
className: "dark:disabled:bg-surface-gray-2"
|
||||
}}
|
||||
rules={{
|
||||
required: _("Rule name is required")
|
||||
}}
|
||||
/>
|
||||
|
||||
<CompanySelector />
|
||||
|
||||
<SmallTextField
|
||||
name='rule_description'
|
||||
label={_("Rule Description")}
|
||||
inputProps={{
|
||||
placeholder: _("Any debit transaction with the keyword 'Bank Fee'.")
|
||||
}}
|
||||
/>
|
||||
|
||||
<TransactionTypeSelector />
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 pt-1">
|
||||
<CurrencyFormField
|
||||
name='min_amount'
|
||||
label={_("Minimum Amount")}
|
||||
/>
|
||||
|
||||
<CurrencyFormField
|
||||
name='max_amount'
|
||||
label={_("Maximum Amount")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DescriptionRules />
|
||||
|
||||
<Separator />
|
||||
|
||||
<RuleAction />
|
||||
</div>
|
||||
}
|
||||
|
||||
const CompanySelector = () => {
|
||||
|
||||
const { setValue } = useFormContext<BankTransactionRule>()
|
||||
|
||||
return <LinkFormField
|
||||
name='company'
|
||||
label={_("Company")}
|
||||
doctype="Company"
|
||||
isRequired
|
||||
rules={{
|
||||
required: _("Company is required"),
|
||||
onChange: () => {
|
||||
setValue('account', '')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
/** Component to render a radio group as a toggle group with options for All, Withdrawal, Deposit */
|
||||
const TransactionTypeSelector = () => {
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
return (
|
||||
<FormField
|
||||
control={control}
|
||||
name='transaction_type'
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-1">
|
||||
<FormLabel className="text-sm font-medium">
|
||||
{_("Transaction Type")}<span className="text-ink-red-3">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
className="grid grid-cols-3 gap-2 w-full"
|
||||
>
|
||||
<FormItem className="flex items-center">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="Any"
|
||||
className="peer sr-only hidden"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
|
||||
"peer-data-[state=checked]:bg-surface-gray-7 peer-data-[state=checked]:text-ink-white peer-data-[state=checked]:border-outline-gray-5 peer-data-[state=checked]:hover:bg-surface-gray-7 peer-data-[state=checked]:hover:text-ink-white"
|
||||
)}
|
||||
>
|
||||
<ArrowDownUp className="w-5 h-5" />
|
||||
{_("All")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="Withdrawal"
|
||||
className="peer sr-only hidden"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
|
||||
"peer-data-[state=checked]:bg-surface-red-5 peer-data-[state=checked]:text-white peer-data-[state=checked]:border-bg-surface-red-5 peer-data-[state=checked]:hover:bg-surface-red-5 peer-data-[state=checked]:hover:text-white"
|
||||
)}
|
||||
>
|
||||
<ArrowUpRight className="w-5 h-5 peer-data-[state=checked]:text-ink-red-3" />
|
||||
{_("Withdrawal")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
<FormItem className="flex items-center">
|
||||
<FormControl>
|
||||
<RadioGroupItem
|
||||
value="Deposit"
|
||||
className="peer sr-only hidden"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel
|
||||
className={cn(
|
||||
"w-full flex items-center justify-center gap-2 px-4 py-2 text-sm font-medium rounded-md border cursor-pointer transition-all hover:bg-surface-gray-1 hover:text-ink-gray-8",
|
||||
"peer-data-[state=checked]:bg-surface-green-5 peer-data-[state=checked]:text-white peer-data-[state=checked]:border-surface-green-5 peer-data-[state=checked]:hover:bg-surface-green-5 peer-data-[state=checked]:hover:text-white"
|
||||
)}
|
||||
>
|
||||
<ArrowDownRight className="w-5 h-5 peer-data-[state=checked]:text-white" />
|
||||
{_("Deposit")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DescriptionRules = () => {
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: "description_rules"
|
||||
})
|
||||
|
||||
const addRow = () => {
|
||||
// @ts-expect-error - we don't need all fields here
|
||||
append({ check: "Contains" })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 pt-1">
|
||||
<span className="text-sm font-medium">{_("Rules to match against the transaction description")} <span className="text-ink-red-3">*</span></span>
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="flex w-full items-center gap-2">
|
||||
<div className="min-w-36">
|
||||
<SelectFormField
|
||||
label={_("Type of check")}
|
||||
hideLabel
|
||||
name={`description_rules.${index}.check`}
|
||||
rules={{
|
||||
required: _("This is required")
|
||||
}}>
|
||||
<SelectItem value="Contains">{_("Contains")}</SelectItem>
|
||||
<SelectItem value="Starts With">{_("Starts with")}</SelectItem>
|
||||
<SelectItem value="Ends With">{_("Ends with")}</SelectItem>
|
||||
<SelectItem value="Regex">{_("Regex")}</SelectItem>
|
||||
</SelectFormField>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<DataField
|
||||
name={`description_rules.${index}.value`}
|
||||
label={_("Value")}
|
||||
hideLabel
|
||||
inputProps={{
|
||||
placeholder: _("Bank Fee, Salary, etc."),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="ghost" theme='red' type='button' isIconButton onClick={() => remove(index)} disabled={fields.length === 1}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div>
|
||||
<Button variant="outline" type='button' onClick={addRow}>
|
||||
<PlusCircleIcon />
|
||||
{_("Add Rule")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RuleAction = () => {
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const classify_as = useWatch({ control, name: "classify_as" })
|
||||
const party_type = useWatch({ control, name: "party_type" })
|
||||
const bank_entry_type = useWatch({ control, name: "bank_entry_type" })
|
||||
|
||||
const accountType = useMemo(() => {
|
||||
if (classify_as === "Payment Entry") {
|
||||
return party_type === "Supplier" ? ["Payable"] : ["Receivable"]
|
||||
}
|
||||
|
||||
if (classify_as === "Transfer") {
|
||||
return ["Bank", "Cash", "Temporary"]
|
||||
}
|
||||
|
||||
return undefined
|
||||
|
||||
}, [classify_as, party_type])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<H4 className="text-base text-ink-gray-7">{_("If rule matches, then:")}</H4>
|
||||
|
||||
<SelectFormField
|
||||
name='classify_as'
|
||||
isRequired
|
||||
label={_("Suggest creating a")}
|
||||
formDescription={_("This will just suggest creating a new entry, and will not automatically create it.")}
|
||||
rules={{
|
||||
required: _("This is required")
|
||||
}}
|
||||
>
|
||||
<SelectItem value="Bank Entry"><LandmarkIcon /> {_("Bank Entry")}</SelectItem>
|
||||
<SelectItem value="Payment Entry"><ReceiptIcon /> {_("Payment Entry")}</SelectItem>
|
||||
<SelectItem value="Transfer"><ArrowRightLeftIcon /> {_("Transfer")}</SelectItem>
|
||||
</SelectFormField>
|
||||
|
||||
{classify_as === "Bank Entry" && (<SelectFormField
|
||||
name='bank_entry_type'
|
||||
isRequired
|
||||
label={_("Create Bank Entry against")}
|
||||
rules={{
|
||||
required: _("This is required")
|
||||
}}
|
||||
>
|
||||
<SelectItem value="Single Account">{_("Single Account")}</SelectItem>
|
||||
<SelectItem value="Multiple Accounts">{_("Multiple Accounts (Journal Template)")}</SelectItem>
|
||||
</SelectFormField>)}
|
||||
|
||||
|
||||
{classify_as === "Payment Entry" && (
|
||||
<div className='grid grid-cols-4 gap-4'>
|
||||
<div className="col-span-1">
|
||||
<PartyTypeFormField
|
||||
name='party_type'
|
||||
label={_("Party Type")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
triggerProps: {
|
||||
className: 'w-full'
|
||||
},
|
||||
}}
|
||||
rules={{
|
||||
required: "Party Type is required"
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<PartyField />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(((bank_entry_type === "Single Account" || !bank_entry_type) && classify_as === "Bank Entry") || classify_as !== "Bank Entry") && (<AccountFormField
|
||||
name='account'
|
||||
label={_("Account")}
|
||||
isRequired
|
||||
rules={{
|
||||
required: _("Account is required")
|
||||
}}
|
||||
account_type={accountType}
|
||||
/>)}
|
||||
|
||||
{bank_entry_type === "Multiple Accounts" && classify_as === "Bank Entry" && <MultipleAccountsSelection />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PartyField = () => {
|
||||
|
||||
const { control, setValue } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const party_type = useWatch({
|
||||
control,
|
||||
name: `party_type`
|
||||
})
|
||||
|
||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const company = useWatch({ control, name: 'company' })
|
||||
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
// Fetch the party and account
|
||||
if (event.target.value) {
|
||||
call.get('erpnext.accounts.doctype.payment_entry.payment_entry.get_party_details', {
|
||||
company: company,
|
||||
party_type: party_type,
|
||||
party: event.target.value,
|
||||
date: today()
|
||||
}).then((res) => {
|
||||
setValue('account', res.message.party_account)
|
||||
})
|
||||
} else {
|
||||
// Clear the account
|
||||
setValue('account', '')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (!party_type) {
|
||||
return <DataField
|
||||
name={`party`}
|
||||
label={_("Party")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
disabled: true,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
return <LinkFormField
|
||||
name={`party`}
|
||||
label={_("Party")}
|
||||
rules={{
|
||||
onChange
|
||||
}}
|
||||
doctype={party_type}
|
||||
|
||||
/>
|
||||
}
|
||||
|
||||
const MultipleAccountsSelection = () => {
|
||||
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const accounts = useWatch({
|
||||
control,
|
||||
name: 'accounts'
|
||||
}) ?? []
|
||||
|
||||
const [isConfigureAccountsModalOpen, setIsConfigureAccountsModalOpen] = useState(false)
|
||||
|
||||
|
||||
|
||||
return <div className="flex flex-col gap-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<Label>{_("Journal Template Accounts")}<span className="text-ink-red-3">*</span></Label>
|
||||
<Button variant="outline" type="button" onClick={() => setIsConfigureAccountsModalOpen(true)}><Settings /> {_("Configure Accounts")}</Button>
|
||||
</div>
|
||||
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center">
|
||||
<div className="py-2 flex flex-col gap-2 items-center">
|
||||
<span>{_("No accounts configured")}</span>
|
||||
<Button variant="subtle" type="button" onClick={() => setIsConfigureAccountsModalOpen(true)}>{_("Configure Accounts")}</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{accounts.map((account, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>{account.account}</TableCell>
|
||||
{index === accounts.length - 1 ? <TableCell className="text-end bg-surface-gray-1" colSpan={2}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-ink-gray-5">{_("This is auto computed to balance the journal entry.")}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Based on the above entries, the balance amount (debit or credit) will be set for the last row to balance the journal entry.")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell> : <>
|
||||
<TableCell className="font-numeric text-end"><AmountFormulaRenderer value={account.debit} /></TableCell>
|
||||
<TableCell className="font-numeric text-end"><AmountFormulaRenderer value={account.credit} /></TableCell>
|
||||
</>}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<ConfigureAccountsModal open={isConfigureAccountsModalOpen} onClose={() => setIsConfigureAccountsModalOpen(false)} />
|
||||
</div>
|
||||
}
|
||||
|
||||
const AmountFormulaRenderer = ({ value }: { value?: string }) => {
|
||||
|
||||
// If it's a string and cannot be a number, then show it as a formula
|
||||
|
||||
if (isNaN(Number(value))) {
|
||||
|
||||
let calculatedValue = "";
|
||||
|
||||
try {
|
||||
calculatedValue = window.eval(`const transaction_amount = 200; ${value}`);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
calculatedValue = "Error";
|
||||
}
|
||||
|
||||
const isComputationValid = !isNaN(Number(calculatedValue)) && calculatedValue !== undefined && calculatedValue !== null;
|
||||
|
||||
return <Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("font-numeric text-end tabular-nums underline underline-offset-4", isComputationValid ? "" : "text-ink-red-3")}>{value}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={isComputationValid ? "" : "bg-surface-red-5"} arrowClassName={isComputationValid ? "" : "bg-surface-red-5 fill-surface-red-5"}>
|
||||
<p className="text-sm">
|
||||
{isComputationValid ? _("This is a formula based value.") : _("This is not a valid formula. Check the variable used in the formula.")}
|
||||
<br /><br />
|
||||
{_("Example: If the transaction amount is 200, then this will be calculated as {} = {}", [value ?? "", calculatedValue])}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
return <span className="font-numeric text-end tabular-nums">{value}</span>
|
||||
}
|
||||
|
||||
const ConfigureAccountsModal = ({ open, onClose }: { open: boolean, onClose: () => void }) => {
|
||||
|
||||
|
||||
return <Dialog
|
||||
open={open}
|
||||
onOpenChange={onClose}
|
||||
>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<ConfigureAccountsModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
}
|
||||
|
||||
const ConfigureAccountsModalContent = () => {
|
||||
|
||||
const { control, getValues, setValue } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
// const costCenterMapRef = useRef<Record<string, string>>({})
|
||||
|
||||
const partyMapRef = useRef<Record<string, string>>({})
|
||||
|
||||
const onPartyChange = (value: string, index: number) => {
|
||||
// Get the account for the party type
|
||||
if (value) {
|
||||
if (partyMapRef.current[value]) {
|
||||
setValue(`accounts.${index}.account`, partyMapRef.current[value])
|
||||
} else {
|
||||
call.get('erpnext.accounts.party.get_party_account', {
|
||||
party: value,
|
||||
party_type: getValues(`accounts.${index}.party_type`),
|
||||
company: company
|
||||
}).then((result: { message: string }) => {
|
||||
setValue(`accounts.${index}.account`, result.message)
|
||||
partyMapRef.current[value] = result.message
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setValue(`accounts.${index}.account`, '')
|
||||
}
|
||||
}
|
||||
|
||||
const transaction_type = useWatch({
|
||||
name: 'transaction_type',
|
||||
control,
|
||||
})
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
name: 'accounts'
|
||||
})
|
||||
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([])
|
||||
|
||||
const onSelectRow = useCallback((index: number) => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.includes(index)) {
|
||||
return prev.filter(i => i !== index)
|
||||
}
|
||||
return [...prev, index]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.length === fields.length) {
|
||||
return []
|
||||
}
|
||||
return [...fields.map((_, index) => index)]
|
||||
})
|
||||
}, [fields])
|
||||
|
||||
const onAdd = () => {
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: '',
|
||||
credit: '',
|
||||
user_remark: ''
|
||||
} as BankTransactionRuleAccounts, {
|
||||
focusName: `accounts.${fields.length}.account`
|
||||
})
|
||||
}
|
||||
|
||||
const onRemove = useCallback(() => {
|
||||
remove(selectedRows)
|
||||
setSelectedRows([])
|
||||
}, [remove, selectedRows])
|
||||
|
||||
const isWithdrawal = transaction_type === 'Withdrawal'
|
||||
|
||||
const company = useWatch({
|
||||
name: 'company',
|
||||
control,
|
||||
})
|
||||
|
||||
return <>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Configure Accounts for Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>{_("Add all accounts that you want to split the transaction into.")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead><Checkbox
|
||||
disabled={fields.length === 0}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select all")}
|
||||
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
|
||||
onCheckedChange={onSelectAll} /></TableHead>
|
||||
<TableHead>{_("Party")}</TableHead>
|
||||
<TableHead>{_("Account")} <span className="text-ink-red-3">*</span></TableHead>
|
||||
{/* <TableHead>{_("Cost Center")}</TableHead> */}
|
||||
<TableHead>{_("Remarks")}</TableHead>
|
||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className="bg-surface-gray-1 cursor-not-allowed" title={_("This is the row for the bank account. It will be auto populated based on the bank transaction.")}>
|
||||
<TableCell>
|
||||
<Checkbox disabled />
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-ink-gray-5">
|
||||
<span className="px-2">
|
||||
Bank GL Account
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
</TableCell>
|
||||
|
||||
<TableCell className={"align-top text-end"}>
|
||||
<span className="text-ink-gray-5 text-sm">
|
||||
{transaction_type === "Withdrawal" || transaction_type === "Any" ? _("Will be auto-populated") : ""}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className={"text-end align-top"}>
|
||||
<span className="text-ink-gray-5 text-sm">
|
||||
{transaction_type === "Deposit" || transaction_type === "Any" ? _("Will be auto-populated") : ""}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{fields.map((field, index) => (
|
||||
<TableRow key={field.id}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(index)}
|
||||
onCheckedChange={() => onSelectRow(index)}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select row {0}", [String(index + 1)])}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="align-top">
|
||||
<div className="flex">
|
||||
<PartyTypeFormField
|
||||
name={`accounts.${index}.party_type`}
|
||||
label={_("Party Type")}
|
||||
isRequired
|
||||
hideLabel
|
||||
inputProps={{
|
||||
type: isWithdrawal ? 'Payable' : 'Receivable',
|
||||
triggerProps: {
|
||||
className: 'rounded-e-none',
|
||||
tabIndex: -1
|
||||
},
|
||||
}} />
|
||||
<PartyRowField index={index} onChange={onPartyChange} />
|
||||
</div>
|
||||
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AccountFormField
|
||||
name={`accounts.${index}.account`}
|
||||
label={_("Account")}
|
||||
rules={{
|
||||
required: _("Account is required"),
|
||||
// onChange: (event) => {
|
||||
// onAccountChange(event.target.value, index)
|
||||
// }
|
||||
}}
|
||||
buttonClassName="min-w-64"
|
||||
isRequired
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
{/* <TableCell className="align-top">
|
||||
<LinkFormField
|
||||
doctype="Cost Center"
|
||||
name={`accounts.${index}.cost_center`}
|
||||
label={_("Cost Center")}
|
||||
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
|
||||
buttonClassName="min-w-48"
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell> */}
|
||||
<TableCell className="align-top">
|
||||
<DataField
|
||||
name={`accounts.${index}.user_remark`}
|
||||
label={_("Remarks")}
|
||||
inputProps={{
|
||||
placeholder: _("e.g. Bank Charges"),
|
||||
className: 'min-w-64',
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn("text-end align-top", index === fields.length - 1 ? "cursor-not-allowed" : "")}
|
||||
title={index === fields.length - 1 ? _("This is the last row. It will be auto populated based on the bank transaction.") : ""}>
|
||||
<DataField
|
||||
name={`accounts.${index}.debit`}
|
||||
label={_("Debit")}
|
||||
disabled={index === fields.length - 1}
|
||||
inputProps={{
|
||||
className: 'text-end',
|
||||
placeholder: _("0.00"),
|
||||
disabled: index === fields.length - 1
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className={cn("text-end align-top", index === fields.length - 1 ? "cursor-not-allowed" : "")}
|
||||
title={index === fields.length - 1 ? _("This is the last row. It will be auto populated based on the bank transaction.") : ""}>
|
||||
<DataField
|
||||
name={`accounts.${index}.credit`}
|
||||
label={_("Credit")}
|
||||
disabled={index === fields.length - 1}
|
||||
inputProps={{
|
||||
className: 'text-end',
|
||||
placeholder: _("0.00"),
|
||||
disabled: index === fields.length - 1
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div>
|
||||
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
|
||||
</div>
|
||||
{selectedRows.length > 0 && <div>
|
||||
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-4">
|
||||
<Separator />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<H4 className="text-base text-ink-gray-7">{_("Help")}</H4>
|
||||
|
||||
<Paragraph className="text-p-sm">{(_("You can set up the rule to split the transaction across multiple accounts."))}
|
||||
<br />{_("You can also add credit or debit values to pre-fill - these support both static values (like 200) or formulas (like transaction_amount * 0.25).")}
|
||||
<br />
|
||||
<br />
|
||||
<span className="font-medium">{_("Example")}:</span>
|
||||
<br />
|
||||
<span className="font-numeric text-sm">
|
||||
transaction_amount * 0.25
|
||||
</span>
|
||||
<br />
|
||||
<span>
|
||||
{_("In this case, the amount will be calculated as 25% of the transaction amount. If the transaction amount is 200, then this will be calculated as 200 * 0.25 = 50.")}
|
||||
</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
const PartyRowField = ({ index, onChange }: { index: number, onChange: (value: string, index: number) => void }) => {
|
||||
|
||||
const { control } = useFormContext<BankTransactionRule>()
|
||||
|
||||
const party_type = useWatch({
|
||||
control,
|
||||
name: `accounts.${index}.party_type`
|
||||
})
|
||||
|
||||
if (!party_type) {
|
||||
return <DataField
|
||||
name={`accounts.${index}.party`}
|
||||
label={_("Party")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
disabled: true,
|
||||
className: 'rounded-s-none border-s-0 min-w-64'
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
}
|
||||
|
||||
return <LinkFormField
|
||||
name={`accounts.${index}.party`}
|
||||
label={_("Party")}
|
||||
rules={{
|
||||
onChange: (event) => {
|
||||
onChange(event.target.value, index)
|
||||
},
|
||||
}}
|
||||
hideLabel
|
||||
buttonClassName="rounded-s-none border-s-0 min-w-64"
|
||||
doctype={party_type}
|
||||
|
||||
/>
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { ArrowDownRight, ArrowUpRight, Calendar } from 'lucide-react'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { UnreconciledTransaction, useGetBankAccounts } from './utils'
|
||||
import { getCompanyCurrency } from '@/lib/company'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
import _ from '@/lib/translate'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
type Props = {
|
||||
transaction: UnreconciledTransaction,
|
||||
showAccount?: boolean,
|
||||
account?: string
|
||||
}
|
||||
|
||||
const SelectedTransactionDetails = ({ transaction, showAccount = false, account }: Props) => {
|
||||
|
||||
const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (transaction.bank_account) {
|
||||
return banks?.find((bank) => bank.name === transaction.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [transaction.bank_account, banks])
|
||||
|
||||
const amount = transaction.withdrawal ? transaction.withdrawal : transaction.deposit
|
||||
|
||||
const currency = transaction.currency || getCompanyCurrency(transaction.company ?? '')
|
||||
|
||||
return (
|
||||
<Card className='py-4'>
|
||||
<CardContent className='px-4'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex justify-between'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<BankLogo bank={bank} iconSize='30px' imageClassName='h-10 max-w-20' />
|
||||
<span className='font-medium text-sm'>{transaction.bank_account}</span>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar size='16px' />
|
||||
<span className='text-sm'>{formatDate(transaction.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
|
||||
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
|
||||
)}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Spent') : _('Received')}</span>
|
||||
</div>
|
||||
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
|
||||
{transaction.unallocated_amount && transaction.unallocated_amount !== amount ? <span className='text-ink-gray-5'>{_("Unallocated")}: {formatCurrency(transaction.unallocated_amount)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-sm'>{transaction.description}</span>
|
||||
{transaction.reference_number ? <span className='text-sm text-ink-gray-5'>{_("Ref")}: {transaction.reference_number}</span> : null}
|
||||
{showAccount && account ? <span className='text-sm text-ink-gray-5'>{_("GL Account")}: {account}</span> : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</CardContent >
|
||||
</Card >
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectedTransactionDetails
|
||||
@@ -1,47 +0,0 @@
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import _ from '@/lib/translate'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { bankRecSelectedTransactionAtom, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { ArrowDownRight, ArrowUpRight } from 'lucide-react'
|
||||
|
||||
const SelectedTransactionsTable = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const transactions = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
{_("Date")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{_("Description")}
|
||||
</TableHead>
|
||||
<TableHead className="text-end">
|
||||
{_("Amount")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction) => (
|
||||
<TableRow key={transaction.name}>
|
||||
<TableCell>{formatDate(transaction.date)}</TableCell>
|
||||
<TableCell className="max-w-96 text-ellipsis overflow-hidden" title={transaction.description}>{transaction.description}</TableCell>
|
||||
<TableCell className="text-end flex items-center justify-end gap-1">
|
||||
{transaction.withdrawal && transaction.withdrawal > 0 ? <ArrowUpRight className="w-4 h-4 text-ink-red-3" /> : <ArrowDownRight className="w-4 h-4 text-ink-green-3" />}
|
||||
<span className="font-numeric font-medium">
|
||||
{formatCurrency(transaction.unallocated_amount, transaction.currency ?? '')}
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
}
|
||||
|
||||
export default SelectedTransactionsTable
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ModalContentFallback } from '@/components/ui/modal-content-fallback'
|
||||
import _ from '@/lib/translate'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { bankRecTransferModalAtom } from './bankRecAtoms'
|
||||
|
||||
const TransferModalContent = lazy(() => import('./TransferModalContent'))
|
||||
|
||||
const TransferModal = () => {
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Transfer")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<TransferModalContent />
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransferModal
|
||||
@@ -1,530 +0,0 @@
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { DialogFooter, DialogClose } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SelectedTransactionDetails from './SelectedTransactionDetails'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { useForm, useFormContext, useWatch } from 'react-hook-form'
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { H4 } from '@/components/ui/typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Form } from '@/components/ui/form'
|
||||
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
|
||||
import SelectedTransactionsTable from './SelectedTransactionsTable'
|
||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
||||
import { useMultiFileUploadProgress } from '@/hooks/useMultiFileUploadProgress'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { useContext, useMemo, useState } from 'react'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone'
|
||||
import FileUploadBanner from '@/components/common/FileUploadBanner'
|
||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
const TransferModalContent = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
|
||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
||||
return <div className='p-4'>
|
||||
<span className='text-center'>{_("No transaction selected")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (selectedTransaction.length === 1) {
|
||||
return <InternalTransferForm
|
||||
selectedBankAccount={selectedBankAccount}
|
||||
selectedTransaction={selectedTransaction[0]} />
|
||||
}
|
||||
|
||||
return <BulkInternalTransferForm transactions={selectedTransaction} />
|
||||
|
||||
}
|
||||
|
||||
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const form = useForm<{
|
||||
bank_account: string
|
||||
}>()
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
|
||||
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const onSubmit = (data: { bank_account: string }) => {
|
||||
|
||||
createPaymentEntry({
|
||||
bank_transaction_names: transactions.map((transaction) => transaction.name),
|
||||
bank_account: data.bank_account
|
||||
}).then(({ message }) => {
|
||||
addToActionLog({
|
||||
type: 'transfer',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: true,
|
||||
items: message.map((item) => ({
|
||||
bankTransaction: item.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Payment Entry",
|
||||
reference_name: item.payment_entry.name,
|
||||
posting_date: item.payment_entry.posting_date,
|
||||
doc: item.payment_entry,
|
||||
}
|
||||
})),
|
||||
bulkCommonData: {
|
||||
bank_account: data.bank_account,
|
||||
}
|
||||
})
|
||||
toast.success(_("Transfer Recorded"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
})
|
||||
onReconcile(transactions[transactions.length - 1])
|
||||
setIsOpen(false)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const onAccountChange = (account: string) => {
|
||||
form.setValue('bank_account', account)
|
||||
}
|
||||
|
||||
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
|
||||
|
||||
const currentCompany = useCurrentCompany()
|
||||
|
||||
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</Form>
|
||||
}
|
||||
|
||||
interface InternalTransferFormFields extends PaymentEntry {
|
||||
mirror_transaction_name?: string
|
||||
}
|
||||
|
||||
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
|
||||
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const form = useForm<InternalTransferFormFields>({
|
||||
defaultValues: {
|
||||
payment_type: 'Internal Transfer',
|
||||
company: selectedTransaction?.company,
|
||||
// If the transaction is a withdrawal, set the paid from to the selected bank account
|
||||
paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
|
||||
// If the transaction is a deposit, set the paid to to the selected bank account
|
||||
paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
|
||||
// Set the amount to the amount of the selected transaction
|
||||
paid_amount: selectedTransaction.unallocated_amount,
|
||||
received_amount: selectedTransaction.unallocated_amount,
|
||||
reference_date: selectedTransaction.date,
|
||||
posting_date: selectedTransaction.date,
|
||||
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
|
||||
}
|
||||
})
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
|
||||
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onSubmit = (data: InternalTransferFormFields) => {
|
||||
|
||||
createPaymentEntry({
|
||||
bank_transaction_name: selectedTransaction.name,
|
||||
...data,
|
||||
custom_remarks: data.remarks ? true : false,
|
||||
// Pass this to reconcile both at the same time
|
||||
mirror_transaction_name: data.mirror_transaction_name
|
||||
}).then(async ({ message }) => {
|
||||
addToActionLog({
|
||||
type: 'transfer',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: false,
|
||||
items: [
|
||||
{
|
||||
bankTransaction: message.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Payment Entry",
|
||||
reference_name: message.payment_entry.name,
|
||||
reference_no: message.payment_entry.reference_no,
|
||||
reference_date: message.payment_entry.reference_date,
|
||||
posting_date: message.payment_entry.posting_date,
|
||||
doc: message.payment_entry,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
toast.success(_("Transfer Recorded"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true)
|
||||
startTracking(files.length)
|
||||
|
||||
const uploadPromises = files.map((f, fileIndex) => {
|
||||
return frappeFile.uploadFile(f, {
|
||||
isPrivate: true,
|
||||
doctype: "Payment Entry",
|
||||
docname: message.payment_entry.name,
|
||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
||||
updateFileProgress(fileIndex, progress?.progress ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
onReconcile(selectedTransaction)
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
const onAccountChange = (account: string, is_mirror: boolean = false) => {
|
||||
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
|
||||
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
|
||||
form.setValue('paid_to', account)
|
||||
} else {
|
||||
form.setValue('paid_from', account)
|
||||
}
|
||||
|
||||
if (!is_mirror) {
|
||||
// Reset the mirror transaction name
|
||||
form.setValue('mirror_transaction_name', '')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
if (isUploading && isCompleted) {
|
||||
return <FileUploadBanner uploadProgress={uploadProgress} />
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<DateField
|
||||
name='posting_date'
|
||||
label={_("Posting Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
<DateField
|
||||
name='reference_date'
|
||||
label={_("Reference Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
</div>
|
||||
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
|
||||
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
|
||||
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 py-2'>
|
||||
<div className='flex items-end justify-between gap-4'>
|
||||
<div className='flex-1'>
|
||||
<AccountFormField
|
||||
name="paid_from"
|
||||
label={_("Paid From")}
|
||||
account_type={['Bank', 'Cash']}
|
||||
readOnly={isWithdrawal}
|
||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='pb-2'>
|
||||
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<AccountFormField
|
||||
name="paid_to"
|
||||
label={_("Paid To")}
|
||||
account_type={['Bank', 'Cash']}
|
||||
isRequired
|
||||
readOnly={!isWithdrawal}
|
||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
|
||||
|
||||
<SmallTextField
|
||||
name='remarks'
|
||||
label={_("Custom Remarks")}
|
||||
formDescription={_("This will be auto-populated if not set.")}
|
||||
/>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label>{_("Attachments")}</Label>
|
||||
<FileDropzone files={files} setFiles={setFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
|
||||
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company?: string }) => {
|
||||
|
||||
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
|
||||
|
||||
return <div className='grid grid-cols-4 gap-4'>
|
||||
{banks.map((bank) => (
|
||||
<button
|
||||
className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||
)}
|
||||
type='button'
|
||||
key={bank.account}
|
||||
onClick={() => onAccountChange(bank.account ?? '')}
|
||||
>
|
||||
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
|
||||
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
|
||||
|
||||
const { data } = useFrappeGetCall('frappe.client.get_value', {
|
||||
doctype: 'Company',
|
||||
filters: company,
|
||||
fieldname: 'default_cash_account'
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
})
|
||||
|
||||
const account = data?.message?.default_cash_account
|
||||
|
||||
if (account) {
|
||||
return <button className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||
)}
|
||||
type='button'
|
||||
onClick={() => setSelectedAccount(account ?? '')}
|
||||
>
|
||||
<div className='flex items-center justify-center h-10 w-10'>
|
||||
<Banknote size='24px' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='font-semibold text-sm'>Cash</span>
|
||||
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
|
||||
|
||||
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
|
||||
|
||||
const mirrorTransactionName = watch('mirror_transaction_name')
|
||||
const paid_from = watch('paid_from')
|
||||
const paid_to = watch('paid_to')
|
||||
|
||||
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
|
||||
transaction_id: transaction.name
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
})
|
||||
|
||||
// Get bank accounts to find the logo
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (data?.message?.bank_account && banks) {
|
||||
return banks.find(bank => bank.name === data.message.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [data?.message?.bank_account, banks])
|
||||
|
||||
const selectTransaction = () => {
|
||||
if (data?.message) {
|
||||
setValue('mirror_transaction_name', data.message.name)
|
||||
onAccountChange(data.message.account, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.message) {
|
||||
|
||||
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
|
||||
|
||||
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
|
||||
const currency = data.message.currency
|
||||
|
||||
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
|
||||
|
||||
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
|
||||
|
||||
return (<div className='pb-2'>
|
||||
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
|
||||
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
|
||||
<div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className={cn("flex items-center gap-2 shrink-0",
|
||||
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
|
||||
)}>
|
||||
<BadgeCheck className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
|
||||
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar size='16px' />
|
||||
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
|
||||
<div className="flex items-center gap-2">
|
||||
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
|
||||
</div>
|
||||
<div className='flex gap-1'>
|
||||
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
|
||||
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
|
||||
)}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
|
||||
<div className='pt-1'>
|
||||
<Button
|
||||
onClick={selectTransaction}
|
||||
theme={isSuggested ? "green" : "violet"}
|
||||
size="md"
|
||||
type='button'
|
||||
>
|
||||
{isSuggested ? <CheckCircle /> : <CheckIcon />}
|
||||
{isSuggested ? _("Accepted") : _("Use Suggestion")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default TransferModalContent
|
||||
@@ -1,85 +0,0 @@
|
||||
import { BankAccount } from "@/types/Accounts/BankAccount";
|
||||
import { getDatesForTimePeriod } from "@/lib/date";
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { atomFamily } from 'jotai-family'
|
||||
import { UnreconciledTransaction } from "./utils";
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction";
|
||||
import { PaymentEntry } from "@/types/Accounts/PaymentEntry";
|
||||
import { JournalEntry } from "@/types/Accounts/JournalEntry";
|
||||
|
||||
export interface SelectedBank extends Pick<BankAccount, 'name' | 'bank' | 'is_credit_card' | 'company' | 'account_name' | 'bank_account_no' | 'account' | 'account_type' | 'integration_id' | 'is_default' | 'last_integration_date'> {
|
||||
logo?: string,
|
||||
logoDark?: string,
|
||||
darkModeInvert?: boolean,
|
||||
logoClassName?: string,
|
||||
account_currency?: string
|
||||
}
|
||||
export const selectedBankAccountAtom = atomWithStorage<SelectedBank | null>('bank-rec-selected-bank', null, undefined, {
|
||||
getOnInit: true
|
||||
})
|
||||
|
||||
export const bankRecDateAtom = atomWithStorage<{ fromDate: string, toDate: string }>("bank-rec-date", {
|
||||
fromDate: getDatesForTimePeriod('This Month').fromDate,
|
||||
toDate: getDatesForTimePeriod('This Month').toDate
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const bankRecClosingBalanceAtom = atomFamily((_id: string) => {
|
||||
return atom<{ value: number, stringValue: string | number | undefined }>({
|
||||
value: 0,
|
||||
stringValue: '0.00'
|
||||
})
|
||||
})
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const bankRecSelectedTransactionAtom = atomFamily((_id: string) => {
|
||||
return atom<UnreconciledTransaction[]>([])
|
||||
})
|
||||
|
||||
/** Action Modals */
|
||||
export const bankRecTransferModalAtom = atom(false)
|
||||
export const bankRecRecordPaymentModalAtom = atom(false)
|
||||
export const bankRecRecordJournalEntryModalAtom = atom(false)
|
||||
|
||||
export const bankRecUnreconcileModalAtom = atom<string>('')
|
||||
|
||||
export const bankRecMatchFilters = atomWithStorage<string[]>('bank-rec-match-filters', ['payment_entry', 'journal_entry'])
|
||||
|
||||
export const bankRecSearchText = atom<string>('')
|
||||
export const bankRecAmountFilter = atom<{ value: number, stringValue?: string | number }>({
|
||||
value: 0,
|
||||
stringValue: '0.00'
|
||||
})
|
||||
export const bankRecTransactionTypeFilter = atom<string>('All')
|
||||
|
||||
export interface ActionLog {
|
||||
type: 'match' | 'payment' | 'transfer' | 'bank_entry'
|
||||
isBulk: boolean
|
||||
timestamp: number,
|
||||
items: ActionLogItem[],
|
||||
bulkCommonData?: {
|
||||
party_type?: string,
|
||||
party?: string,
|
||||
account?: string,
|
||||
bank_account?: string,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActionLogItem {
|
||||
bankTransaction: BankTransaction,
|
||||
voucher: {
|
||||
reference_doctype: string,
|
||||
reference_name: string,
|
||||
reference_no?: string,
|
||||
reference_date?: string,
|
||||
posting_date: string,
|
||||
doc?: PaymentEntry | JournalEntry
|
||||
},
|
||||
}
|
||||
|
||||
const actionLogStorage = createJSONStorage<ActionLog[]>(() => sessionStorage)
|
||||
|
||||
export const bankRecActionLog = atomWithStorage<ActionLog[]>('bank-rec-action-log', [], actionLogStorage, {
|
||||
getOnInit: true,
|
||||
})
|
||||
@@ -1,410 +0,0 @@
|
||||
export const BANK_LOGOS: { keywords: string[], logo: string, locale?: string[], logoDark?: string, darkModeInvert?: boolean, logoClassName?: string }[] = [
|
||||
// United States + International
|
||||
{
|
||||
keywords: ['American Express', 'Amex'],
|
||||
logo: 'Amex.svg',
|
||||
locale: ['Global', 'United States']
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of America', 'BOA'],
|
||||
logo: 'Bank_of_America.png',
|
||||
darkModeInvert: true,
|
||||
locale: ['United States']
|
||||
},
|
||||
{
|
||||
keywords: ['Barclays'],
|
||||
logo: 'Barclays.svg',
|
||||
locale: ['Global', 'United Kingdom'],
|
||||
logoClassName: 'h-12',
|
||||
},
|
||||
{
|
||||
keywords: ['BNP Paribas'],
|
||||
logo: 'BNP_Paribas.svg',
|
||||
logoDark: 'BNP_Paribas-Dark.svg',
|
||||
locale: ['Global', 'France'],
|
||||
logoClassName: 'max-w-24'
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of New York Mellon', 'BNY Mellon', 'BNY'],
|
||||
logo: 'BNY_Mellon.svg',
|
||||
locale: ['Global', 'United States'],
|
||||
logoDark: 'BNY_Mellon-Dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['Capital One'],
|
||||
logo: 'Capital_One.png',
|
||||
locale: ['United States'],
|
||||
darkModeInvert: true
|
||||
},
|
||||
{
|
||||
keywords: ['Charles Schwab', 'Schwab'],
|
||||
logo: 'Charles_Schwab.svg',
|
||||
locale: ['United States'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['Chase'],
|
||||
logo: 'chase.svg',
|
||||
locale: ['Global', 'United States'],
|
||||
logoDark: 'chase-Dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['Citi', 'Citibank', 'Citi Group', 'Citi Financial Services'],
|
||||
logo: 'Citi.svg',
|
||||
locale: ['Global', 'United States']
|
||||
},
|
||||
{
|
||||
keywords: ['Deutsche Bank'],
|
||||
logo: 'Deutsche_Bank.svg',
|
||||
locale: ['Global', 'Germany'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['Goldman Sachs'],
|
||||
logo: 'Goldman_Sachs.svg',
|
||||
locale: ['Global', 'United States'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['HSBC'],
|
||||
logo: 'HSBC.svg',
|
||||
locale: ['Global', 'United Kingdom'],
|
||||
logoDark: 'HSBC-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['JPMorgan Chase', 'JPMorgan', 'JP Morgan', 'JP Morgan Chase', 'JPMorgan Chase & Co', 'JPM', 'JPMC'],
|
||||
logo: 'jpmc.svg',
|
||||
locale: ['Global', 'United States'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['Morgan Stanley'],
|
||||
logo: 'Morgan_Stanley.png',
|
||||
locale: ['Global', 'United States'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['PNC', 'PNC Financial Services Group', 'PNC Financial Services', 'Pittsburgh National Corporation'],
|
||||
logo: 'PNC.png',
|
||||
locale: ['United States']
|
||||
},
|
||||
{
|
||||
keywords: ['Santander'],
|
||||
logo: 'Santander.svg',
|
||||
locale: ['Global']
|
||||
},
|
||||
{
|
||||
keywords: ['TD Bank', 'Toronto Dominion Bank'],
|
||||
logo: 'Toronto_Dominion_Bank.png',
|
||||
locale: ['Canada']
|
||||
},
|
||||
{
|
||||
keywords: ['Truist'],
|
||||
logo: 'Truist.svg',
|
||||
locale: ['United States'],
|
||||
darkModeInvert: true,
|
||||
logoClassName: 'h-8'
|
||||
},
|
||||
{
|
||||
keywords: ['UBS'],
|
||||
logo: 'UBS.svg',
|
||||
locale: ['Global', 'Switzerland'],
|
||||
logoDark: 'UBS-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['US Bank', 'USBank', 'U.S. Bank', 'U.S. Bancorp'],
|
||||
logo: 'USBank.svg',
|
||||
locale: ['United States'],
|
||||
logoDark: 'USBank-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['Wells Fargo', 'Wells Fargo'],
|
||||
logo: 'Wells_Fargo.svg',
|
||||
locale: ['United States'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['OakStar', 'Oakstar', 'Oakstar'],
|
||||
logo: 'Oakstar.png',
|
||||
logoDark: 'Oakstar-dark.webp',
|
||||
locale: ['United States'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['PlainsCapital', 'Plains Capital'],
|
||||
logo: 'PlainsCapitalBank.png',
|
||||
locale: ['United States'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ["Standard Chartered"],
|
||||
logo: 'Standard_Chartered.png',
|
||||
logoDark: 'Standard_Chartered-dark.png',
|
||||
locale: ['Global'],
|
||||
},
|
||||
// India
|
||||
{
|
||||
keywords: ['HDFC Bank', 'HDFC'],
|
||||
logo: 'HDFC.svg',
|
||||
locale: ['India'],
|
||||
},
|
||||
{
|
||||
keywords: ['ICICI Bank', 'ICICI'],
|
||||
logo: 'ICICI.svg',
|
||||
logoDark: 'ICICI-dark.svg',
|
||||
locale: ['India'],
|
||||
},
|
||||
{
|
||||
keywords: ['SBI', 'State Bank of India'],
|
||||
logo: 'State_Bank_of_India.svg',
|
||||
logoDark: 'State_bank_of_India-Dark.svg',
|
||||
locale: ['India'],
|
||||
logoClassName: 'h-4.5'
|
||||
},
|
||||
{
|
||||
keywords: ['Punjab National Bank', 'PNB'],
|
||||
logo: 'Punjab_National_Bank.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Union Bank of India', 'Union Bank'],
|
||||
logo: 'Union_Bank_of_India.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Yes Bank', 'Yes'],
|
||||
logo: 'Yes_Bank.svg',
|
||||
locale: ['India'],
|
||||
logoDark: 'Yes_Bank-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['RBL Bank', 'RBL'],
|
||||
logo: 'RBL_Bank.svg',
|
||||
locale: ['India'],
|
||||
logoDark: 'RBL_Bank-dark.svg',
|
||||
},
|
||||
{
|
||||
keywords: ['Axis Bank', 'Axis'],
|
||||
logo: 'Axis_Bank.svg',
|
||||
locale: ['India'],
|
||||
darkModeInvert: true
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of Baroda', 'BOB'],
|
||||
logo: 'Bank_of_Baroda.svg',
|
||||
locale: ['India', 'Kenya'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of India', 'BOI'],
|
||||
logo: 'Bank_of_India.png',
|
||||
locale: ['India'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ['Bank of Maharashtra', 'BOM'],
|
||||
logo: 'Bank_of_Maharashtra.png',
|
||||
locale: ['India'],
|
||||
logoClassName: 'min-w-24'
|
||||
},
|
||||
{
|
||||
keywords: ['Kotak Mahindra Bank', 'Kotak'],
|
||||
logo: 'Kotak_Mahindra.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['IndusInd Bank', 'IndusInd'],
|
||||
logo: 'IndusInd_Bank.svg',
|
||||
locale: ['India'],
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ['IDBI Bank', 'IDBI'],
|
||||
logo: 'IDBI_Bank.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['IDFC First Bank', 'IDFC First'],
|
||||
logo: 'IDFC_First_Bank.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Federal Bank'],
|
||||
logo: 'Federal_Bank.png',
|
||||
logoDark: 'Federal_Bank-dark.png',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Fi Bank'],
|
||||
logo: 'Fi_Bank.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['RazorpayX', 'Razorpay'],
|
||||
logo: 'Razorpay.svg',
|
||||
logoDark: 'Razorpay-dark.svg',
|
||||
locale: ['India']
|
||||
},
|
||||
{
|
||||
keywords: ['Revolut'],
|
||||
logo: 'Revolut.png',
|
||||
locale: ['Global'],
|
||||
darkModeInvert: true
|
||||
},
|
||||
{
|
||||
keywords: ['Starling Bank'],
|
||||
logo: 'Starling_Bank.png',
|
||||
logoDark: 'Starling_Bank-dark.png',
|
||||
locale: ['Global', 'UK'],
|
||||
logoClassName: 'h-10'
|
||||
},
|
||||
// Australia and New Zealand
|
||||
{
|
||||
keywords: ["Commonwealth Bank", "CBA"],
|
||||
logo: "Commonwealth_Bank.svg",
|
||||
locale: ['Australia', 'New Zealand'],
|
||||
},
|
||||
{
|
||||
keywords: ["Airwallex"],
|
||||
logo: "Airwallex.png",
|
||||
logoDark: "Airwallex-dark.png",
|
||||
locale: ['Global']
|
||||
},
|
||||
{
|
||||
keywords: ["Judo Bank"],
|
||||
logo: "Judo_Bank.svg",
|
||||
logoDark: "Judo_Bank-dark.svg",
|
||||
locale: ['Australia', 'New Zealand']
|
||||
},
|
||||
{
|
||||
keywords: ["Alpha"], // This might conflict with Alpha Bank in Greece
|
||||
logo: "Alpha_Bank.svg",
|
||||
darkModeInvert: true,
|
||||
logoClassName: 'h-4.5',
|
||||
locale: ['Australia', 'New Zealand']
|
||||
},
|
||||
{
|
||||
keywords: ["Australian Tax Office", "Australian Taxation Office"],
|
||||
logo: "Australian_Tax_Office.png",
|
||||
darkModeInvert: true,
|
||||
locale: ['Australia']
|
||||
},
|
||||
{
|
||||
keywords: ["Westpac"],
|
||||
logo: "Westpac.svg",
|
||||
locale: ['Australia']
|
||||
},
|
||||
{
|
||||
keywords: ["ANZ", "ANZ Bank", "Australia and New Zealand Banking Group"],
|
||||
logo: "ANZ.png",
|
||||
locale: ['Australia', 'New Zealand']
|
||||
},
|
||||
{
|
||||
keywords: ["Macquarie Group", "Macquarie Bank"],
|
||||
logo: "Macquarie.svg",
|
||||
darkModeInvert: true,
|
||||
locale: ['Australia']
|
||||
},
|
||||
// Nicaragua
|
||||
{
|
||||
keywords: ["Banco Atlantida", "Banco Atlántida"],
|
||||
logo: "Banco_Atlantida.png",
|
||||
locale: ['Nicaragua']
|
||||
},
|
||||
{
|
||||
keywords: ["Banco de Finanzas"],
|
||||
logo: "Banco_de_Finanzas.svg",
|
||||
locale: ['Nicaragua'],
|
||||
logoClassName: 'h-4.5'
|
||||
},
|
||||
{
|
||||
keywords: ["Avanz"],
|
||||
logo: "Avanz.svg",
|
||||
logoDark: "Avanz-dark.svg",
|
||||
locale: ['Nicaragua'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ["Ficohsa"],
|
||||
logo: "Ficohsa.svg",
|
||||
locale: ['Nicaragua']
|
||||
},
|
||||
{
|
||||
keywords: ["BAC", "BAC Credomatic"],
|
||||
logo: "BAC_Credomatic.svg",
|
||||
locale: ['Nicaragua'],
|
||||
logoClassName: 'h-4.5'
|
||||
},
|
||||
{
|
||||
keywords: ["Banco Lafise"],
|
||||
logo: "Banco_Lafise.png",
|
||||
darkModeInvert: true,
|
||||
locale: ['Nicaragua']
|
||||
},
|
||||
// German
|
||||
{
|
||||
keywords: ["Sparkasse"],
|
||||
logo: "Sparkasse.png",
|
||||
locale: ['Germany']
|
||||
},
|
||||
{
|
||||
keywords: ["Volksbank", "Raiffeisenbank", "VR-Bank"],
|
||||
logo: "Volksbanken_Raiffeisenbanken.svg",
|
||||
locale: ['Germany'],
|
||||
logoClassName: 'min-w-32'
|
||||
},
|
||||
// Kenya
|
||||
{
|
||||
keywords: ["KCB Bank", "KCB"],
|
||||
logo: "KCB_Bank_Kenya.png",
|
||||
locale: ['Kenya']
|
||||
},
|
||||
{
|
||||
keywords: ["Equity Bank"],
|
||||
logo: "Equity_Bank.png",
|
||||
logoDark: "Equity_Bank-dark.png",
|
||||
locale: ['Kenya'],
|
||||
},
|
||||
{
|
||||
keywords: ["I&M"],
|
||||
logo: "I&M.png",
|
||||
locale: ['Kenya']
|
||||
},
|
||||
{
|
||||
keywords: ["ABSA"],
|
||||
logo: "ABSA.png",
|
||||
locale: ['Kenya'],
|
||||
darkModeInvert: true,
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ["Stanbic"],
|
||||
logo: "Stanbic.png",
|
||||
locale: ['Kenya'],
|
||||
logoClassName: 'h-7'
|
||||
},
|
||||
{
|
||||
keywords: ["DTB", "Diamond Trust Bank"],
|
||||
logo: "Diamond_Trust_Bank.png",
|
||||
locale: ['Kenya']
|
||||
},
|
||||
{
|
||||
keywords: ["Prime Bank"],
|
||||
logo: "Prime_Bank.png",
|
||||
locale: ['Kenya'],
|
||||
logoClassName: 'max-w-28'
|
||||
},
|
||||
{
|
||||
keywords: ["Stripe"],
|
||||
logo: "Stripe.svg",
|
||||
locale: ['Global'],
|
||||
logoClassName: 'h-6',
|
||||
darkModeInvert: true,
|
||||
},
|
||||
{
|
||||
keywords: ["PayPal"],
|
||||
logo: "PayPal.png",
|
||||
locale: ['Global'],
|
||||
logoClassName: 'h-6',
|
||||
}
|
||||
]
|
||||
@@ -1,457 +0,0 @@
|
||||
import { ActionLog, bankRecActionLog, bankRecAmountFilter, bankRecDateAtom, bankRecMatchFilters, bankRecSearchText, bankRecSelectedTransactionAtom, bankRecTransactionTypeFilter, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { useMemo } from 'react'
|
||||
import { SWRConfiguration, useFrappeGetCall, useFrappeGetDoc, useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
||||
import { BankAccount } from '@/types/Accounts/BankAccount'
|
||||
import dayjs from 'dayjs'
|
||||
import { toast } from 'sonner'
|
||||
import { BANK_LOGOS } from './logos'
|
||||
import { getErrorMessage } from '@/lib/frappe'
|
||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
||||
import _ from '@/lib/translate'
|
||||
import { BankTransactionRule } from '@/types/Accounts/BankTransactionRule'
|
||||
import { useRef } from 'react'
|
||||
import type { DebouncedState } from 'usehooks-ts'
|
||||
import { useDebounceCallback } from 'usehooks-ts'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
export const useGetAccountOpeningBalance = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const args = useMemo(() => {
|
||||
|
||||
return {
|
||||
bank_account: bankAccount?.name,
|
||||
company: companyID,
|
||||
till_date: dayjs(dates.fromDate).subtract(1, 'days').format('YYYY-MM-DD'),
|
||||
}
|
||||
|
||||
}, [companyID, bankAccount?.name, dates.fromDate])
|
||||
|
||||
return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args, undefined, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
}
|
||||
|
||||
export const useGetAccountClosingBalance = () => {
|
||||
|
||||
const companyID = useCurrentCompany()
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const args = useMemo(() => {
|
||||
|
||||
return {
|
||||
bank_account: bankAccount?.name,
|
||||
company: companyID,
|
||||
till_date: dates.toDate,
|
||||
}
|
||||
|
||||
}, [companyID, bankAccount?.name, dates.toDate])
|
||||
|
||||
return useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_account_balance', args,
|
||||
`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`,
|
||||
{
|
||||
revalidateOnFocus: false
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch the closing balance set in the database for the given bank and date
|
||||
*/
|
||||
export const useGetAccountClosingBalanceAsPerStatement = (swrConfig: SWRConfiguration = {}) => {
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
return useFrappeGetCall<{ message: { balance: number, date?: string } }>("erpnext.accounts.doctype.bank_account.bank_account.get_closing_balance_as_per_statement", {
|
||||
bank_account: bankAccount?.name,
|
||||
date: dates.toDate
|
||||
}, `bank-reconciliation-account-closing-balance-as-per-statement-${bankAccount?.name}-${dates.toDate}`, {
|
||||
revalidateOnFocus: false,
|
||||
...swrConfig
|
||||
})
|
||||
}
|
||||
|
||||
export type UnreconciledTransaction = Pick<BankTransaction, 'name' | 'matched_transaction_rule' | 'date' | 'withdrawal' | 'deposit' | 'currency' | 'description' | 'status' | 'transaction_type' | 'reference_number' | 'party_type' | 'party' | 'bank_account' | 'company' | 'unallocated_amount'>
|
||||
|
||||
|
||||
export const useGetUnreconciledTransactions = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
return useFrappeGetCall<{ message: UnreconciledTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', {
|
||||
bank_account: bankAccount?.name,
|
||||
from_date: dates.fromDate,
|
||||
to_date: dates.toDate
|
||||
}, bankAccount ? `bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
})
|
||||
}
|
||||
|
||||
export interface LinkedPayment {
|
||||
rank: number,
|
||||
doctype: string,
|
||||
name: string,
|
||||
paid_amount: number,
|
||||
reference_no: string,
|
||||
reference_date: string,
|
||||
posting_date: string,
|
||||
party_type?: string,
|
||||
party?: string,
|
||||
currency: string
|
||||
}
|
||||
|
||||
export const useGetBankTransactions = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
return useFrappeGetCall<{ message: BankTransaction[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_bank_transactions', {
|
||||
bank_account: bankAccount?.name,
|
||||
from_date: dates.fromDate,
|
||||
to_date: dates.toDate,
|
||||
all_transactions: true
|
||||
}, bankAccount ? `bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}` : null)
|
||||
}
|
||||
|
||||
|
||||
export const useGetVouchersForTransaction = (transaction: UnreconciledTransaction) => {
|
||||
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const matchFilters = useAtomValue(bankRecMatchFilters)
|
||||
|
||||
return useFrappeGetCall<{ message: LinkedPayment[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.get_linked_payments', {
|
||||
bank_transaction_name: transaction.name,
|
||||
document_types: matchFilters ?? ['payment_entry', 'journal_entry'],
|
||||
from_date: dates.fromDate,
|
||||
to_date: dates.toDate,
|
||||
filter_by_reference_date: 0
|
||||
}, `bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`, {
|
||||
revalidateOnFocus: false
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Common hook to refresh the unreconciled transactions list after a transaction is reconciled
|
||||
* @returns function to call to refresh the unreconciled transactions list AFTER the operation is done
|
||||
*/
|
||||
export const useRefreshUnreconciledTransactions = () => {
|
||||
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const matchFilters = useAtomValue(bankRecMatchFilters)
|
||||
const setSelectedTransaction = useSetAtom(bankRecSelectedTransactionAtom(selectedBank?.name || ''))
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const searchString = useAtomValue(bankRecSearchText)
|
||||
const typeFilter = useAtomValue(bankRecTransactionTypeFilter)
|
||||
const amountFilter = useAtomValue(bankRecAmountFilter)
|
||||
|
||||
const { data: unreconciledTransactions } = useGetUnreconciledTransactions()
|
||||
|
||||
/**
|
||||
* This function should be called after a transaction is reconciled
|
||||
* It will get the next unreconciled transaction and select it
|
||||
* And then refresh the balance + unreconciled transactions list
|
||||
*/
|
||||
const onReconcileTransaction = (transaction: UnreconciledTransaction, updatedTransaction?: BankTransaction) => {
|
||||
|
||||
// If the updated transaction has an unallocated amount of 0, then we need to select the next unreconciled transaction
|
||||
if (updatedTransaction && updatedTransaction?.unallocated_amount !== 0) {
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
||||
// Update the matching vouchers for the selected transaction
|
||||
mutate(`bank-reconciliation-vouchers-${transaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
|
||||
return
|
||||
}
|
||||
|
||||
// From unreconciled transactions list, first apply the filters based on the search criteria and other filters
|
||||
|
||||
const searchIndex = unreconciledTransactions ? new Fuse(unreconciledTransactions.message, {
|
||||
keys: ['description', 'reference_number'],
|
||||
threshold: 0.5,
|
||||
includeScore: true
|
||||
}) : null
|
||||
|
||||
const results = getSearchResults(searchIndex, searchString, typeFilter, amountFilter.value, unreconciledTransactions?.message)
|
||||
|
||||
const currentIndex = results.findIndex(t => t.name === transaction.name)
|
||||
let nextTransaction = null
|
||||
|
||||
if (currentIndex !== -1) {
|
||||
// Check if there is a next transaction
|
||||
if (currentIndex < (results.length || 0) - 1) {
|
||||
nextTransaction = results[currentIndex + 1]
|
||||
}
|
||||
}
|
||||
|
||||
// We need to select the next unreconciled transaction for a better UX
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
.then(res => {
|
||||
if (nextTransaction) {
|
||||
// Check if next transaction is there in the response
|
||||
const nextTransactionObj = res?.message.find((t: UnreconciledTransaction) => t.name === nextTransaction.name)
|
||||
if (nextTransactionObj) {
|
||||
setSelectedTransaction([nextTransactionObj])
|
||||
} else {
|
||||
// If the next transaction is not there in the response, we need to clear the selection
|
||||
setSelectedTransaction([])
|
||||
}
|
||||
} else {
|
||||
// If there is no next transaction, we need to clear the selection
|
||||
setSelectedTransaction([])
|
||||
}
|
||||
})
|
||||
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
||||
}
|
||||
|
||||
return onReconcileTransaction
|
||||
|
||||
}
|
||||
|
||||
export const useReconcileTransaction = () => {
|
||||
|
||||
const { call, loading } = useFrappePostCall<{ message: BankTransaction }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.reconcile_vouchers')
|
||||
|
||||
const onReconcileTransaction = useRefreshUnreconciledTransactions()
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const reconcileTransaction = (transaction: UnreconciledTransaction, voucher: LinkedPayment) => {
|
||||
|
||||
call({
|
||||
bank_transaction_name: transaction.name,
|
||||
vouchers: JSON.stringify([{
|
||||
"payment_doctype": voucher.doctype,
|
||||
"payment_name": voucher.name,
|
||||
"amount": voucher.paid_amount
|
||||
}])
|
||||
}).then((res) => {
|
||||
addToActionLog({
|
||||
type: 'match',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: false,
|
||||
items: [
|
||||
{
|
||||
bankTransaction: res.message,
|
||||
voucher: {
|
||||
reference_doctype: voucher.doctype,
|
||||
reference_name: voucher.name,
|
||||
reference_no: voucher.reference_no,
|
||||
reference_date: voucher.reference_date,
|
||||
posting_date: voucher.posting_date,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
onReconcileTransaction(transaction, res.message)
|
||||
toast.success(_("Reconciled"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(transaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
toast.error(_("Error"), {
|
||||
duration: 5000,
|
||||
description: getErrorMessage(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return { reconcileTransaction, loading }
|
||||
|
||||
}
|
||||
|
||||
interface BankAccountWithCurrency extends Pick<BankAccount, 'name' | 'bank' | 'account_name' | 'is_credit_card' | 'company' | 'account' | 'account_type' | 'account_subtype' | 'bank_account_no' | 'last_integration_date'> {
|
||||
account_currency?: string
|
||||
}
|
||||
|
||||
type BankLogoEntry = (typeof BANK_LOGOS)[number]
|
||||
|
||||
/** Prefer the longest keyword match so short tokens (e.g. "anz" in "finanzas") do not beat full bank names. */
|
||||
function findBankLogoForName(bankName: string | undefined | null): BankLogoEntry | undefined {
|
||||
if (!bankName) return undefined
|
||||
const haystack = bankName.toLowerCase()
|
||||
let best: BankLogoEntry | undefined
|
||||
let bestKeywordLen = 0
|
||||
for (const entry of BANK_LOGOS) {
|
||||
for (const keyword of entry.keywords) {
|
||||
const needle = keyword.toLowerCase()
|
||||
if (needle.length === 0) continue
|
||||
if (haystack.includes(needle) && needle.length > bestKeywordLen) {
|
||||
bestKeywordLen = needle.length
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
export const useGetBankAccounts = (onSuccess?: (data?: Omit<SelectedBank, 'logo'>[]) => void, filterFn?: (bank: SelectedBank) => boolean) => {
|
||||
|
||||
const company = useCurrentCompany()
|
||||
|
||||
const { data, isLoading, error } = useFrappeGetCall<{ message: BankAccountWithCurrency[] }>('erpnext.accounts.doctype.bank_account.bank_account.get_list', {
|
||||
company: company
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
onSuccess: (data) => {
|
||||
onSuccess?.(data?.message)
|
||||
}
|
||||
})
|
||||
|
||||
const banks = useMemo(() => {
|
||||
// Match the bank account to the logo
|
||||
const banksWithLogos = data?.message.map((bank) => {
|
||||
const logo = findBankLogoForName(bank.bank)
|
||||
return {
|
||||
...bank,
|
||||
logo: logo?.logo,
|
||||
logoDark: logo?.logoDark,
|
||||
darkModeInvert: logo?.darkModeInvert,
|
||||
logoClassName: logo?.logoClassName
|
||||
}
|
||||
}) ?? []
|
||||
|
||||
if (filterFn) {
|
||||
return banksWithLogos.filter(filterFn)
|
||||
}
|
||||
|
||||
return banksWithLogos
|
||||
}, [data, filterFn])
|
||||
|
||||
return {
|
||||
banks,
|
||||
isLoading,
|
||||
error
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export const useIsTransactionWithdrawal = (transaction: UnreconciledTransaction) => {
|
||||
return useMemo(() => {
|
||||
const isWithdrawal = transaction.withdrawal && transaction.withdrawal > 0
|
||||
const isDeposit = transaction.deposit && transaction.deposit > 0
|
||||
|
||||
return {
|
||||
amount: isWithdrawal ? transaction.withdrawal : transaction.deposit,
|
||||
isWithdrawal,
|
||||
isDeposit
|
||||
}
|
||||
}, [transaction])
|
||||
}
|
||||
|
||||
export const useGetRuleForTransaction = (transaction: UnreconciledTransaction) => {
|
||||
|
||||
return useFrappeGetDoc<BankTransactionRule>('Bank Transaction Rule', transaction.matched_transaction_rule,
|
||||
transaction.matched_transaction_rule ? undefined : null, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/** Hook to handle the search input while maintaining debouncing and global state. */
|
||||
export function useTransactionSearch(): [string, DebouncedState<(value: string) => void>] {
|
||||
const delay = 500
|
||||
const unwrappedInitialValue = ''
|
||||
const eq = (left: string, right: string) => left === right
|
||||
const [debouncedValue, setDebouncedValue] = useAtom(bankRecSearchText)
|
||||
const previousValueRef = useRef<string | undefined>(unwrappedInitialValue)
|
||||
|
||||
const updateDebouncedValue = useDebounceCallback(
|
||||
setDebouncedValue,
|
||||
delay,
|
||||
)
|
||||
|
||||
// Update the debounced value if the initial value changes
|
||||
if (!eq(previousValueRef.current as string, unwrappedInitialValue)) {
|
||||
updateDebouncedValue(unwrappedInitialValue)
|
||||
previousValueRef.current = unwrappedInitialValue
|
||||
}
|
||||
|
||||
return [debouncedValue, updateDebouncedValue]
|
||||
}
|
||||
|
||||
/** Utility function to get the search results based on the search index, search string, type filter, amount filter and unreconciled transactions */
|
||||
export const getSearchResults = (
|
||||
/** Fuse index of the unreconciled transactions */
|
||||
searchIndex: Fuse<UnreconciledTransaction> | null,
|
||||
/** Search string */
|
||||
search: string,
|
||||
/** Type filter */
|
||||
typeFilter: string,
|
||||
/** Amount filter */
|
||||
amountFilter: number,
|
||||
/** Unreconciled transactions */
|
||||
unreconciledTransactions?: UnreconciledTransaction[]) => {
|
||||
|
||||
let r = []
|
||||
if (!searchIndex || !search) {
|
||||
r = unreconciledTransactions ?? []
|
||||
} else {
|
||||
r = searchIndex.search(search).map((result) => result.item)
|
||||
}
|
||||
|
||||
if (typeFilter !== 'All') {
|
||||
r = r.filter((transaction) => {
|
||||
if (typeFilter === 'Debits') {
|
||||
return transaction.withdrawal && transaction.withdrawal > 0
|
||||
}
|
||||
if (typeFilter === 'Credits') {
|
||||
return transaction.deposit && transaction.deposit > 0
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if (amountFilter > 0) {
|
||||
r = r.filter((transaction) => {
|
||||
if (transaction.withdrawal && transaction.withdrawal > 0) {
|
||||
return transaction.withdrawal === amountFilter
|
||||
}
|
||||
if (transaction.deposit && transaction.deposit > 0) {
|
||||
return transaction.deposit === amountFilter
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
export const useUpdateActionLog = () => {
|
||||
|
||||
const setActionLog = useSetAtom(bankRecActionLog)
|
||||
|
||||
const addToActionLog = (action: ActionLog) => {
|
||||
// Store at max 100 actions
|
||||
setActionLog((prev) => {
|
||||
const newActions = [action, ...prev]
|
||||
if (newActions.length > 100) {
|
||||
return newActions.slice(0, 100)
|
||||
}
|
||||
return newActions
|
||||
})
|
||||
}
|
||||
|
||||
return addToActionLog
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import CSVRawDataPreview from './CSVRawDataPreview'
|
||||
import StatementDetails from './StatementDetails'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
const CSVImport = ({ data, mutate }: { data: { message: GetStatementDetailsResponse }, mutate: () => void }) => {
|
||||
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
<div className="w-[50%] p-4 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[50%] border-s border-t pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<CSVRawDataPreview data={data.message} mutate={mutate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CSVImport
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import _ from "@/lib/translate"
|
||||
import RawTableGrid from "../RawTableGrid"
|
||||
import {
|
||||
applyColumnMappingChange,
|
||||
ColumnMapsTo,
|
||||
GetStatementDetailsResponse,
|
||||
useSetHeaderIndex,
|
||||
useUpdateColumnMapping,
|
||||
} from "../import_utils"
|
||||
import { BankStatementImportLogColumnMap } from "@/types/Accounts/BankStatementImportLogColumnMap"
|
||||
|
||||
type Mapping = Pick<BankStatementImportLogColumnMap, "index" | "maps_to" | "header_text" | "variable">
|
||||
|
||||
const toMapping = (columns?: BankStatementImportLogColumnMap[]): Mapping[] =>
|
||||
(columns ?? []).map((c) => ({
|
||||
index: c.index,
|
||||
maps_to: c.maps_to,
|
||||
header_text: c.header_text,
|
||||
variable: c.variable,
|
||||
}))
|
||||
|
||||
const headerToState = (index?: number) => (index != null && index >= 0 ? index : null)
|
||||
|
||||
const CSVRawDataPreview = ({
|
||||
data,
|
||||
mutate,
|
||||
}: {
|
||||
data: GetStatementDetailsResponse
|
||||
mutate: () => void
|
||||
}) => {
|
||||
const isCompleted = data.doc.status === "Completed"
|
||||
|
||||
const [mapping, setMapping] = useState<Mapping[]>(() => toMapping(data.doc.column_mapping))
|
||||
const [headerIndex, setHeaderIndex] = useState<number | null>(() =>
|
||||
headerToState(data.doc.detected_header_index),
|
||||
)
|
||||
|
||||
const { call: updateMapping, loading: savingMapping } = useUpdateColumnMapping()
|
||||
const { call: setHeader, loading: savingHeader } = useSetHeaderIndex()
|
||||
|
||||
const mappingRef = useRef(mapping)
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
|
||||
useEffect(() => () => clearTimeout(saveTimer.current), [])
|
||||
|
||||
const columnMappingRecord: Record<number, ColumnMapsTo> = {}
|
||||
mapping.forEach((c) => {
|
||||
if (c.maps_to) columnMappingRecord[c.index] = c.maps_to as ColumnMapsTo
|
||||
})
|
||||
|
||||
const commitMapping = (next: Mapping[]) => {
|
||||
mappingRef.current = next
|
||||
setMapping(next)
|
||||
}
|
||||
|
||||
// Persist mapping edits (debounced) so the transaction preview updates in realtime.
|
||||
const scheduleSaveMapping = () => {
|
||||
if (isCompleted) return
|
||||
clearTimeout(saveTimer.current)
|
||||
saveTimer.current = setTimeout(() => {
|
||||
updateMapping({ statement_import_id: data.doc.name, column_mapping: mappingRef.current })
|
||||
.then(() => mutate())
|
||||
.catch(() => toast.error(_("Could not save the column mapping.")))
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const onChangeMapping = (columnIndex: number, mapsTo: ColumnMapsTo) => {
|
||||
if (isCompleted) return
|
||||
commitMapping(applyColumnMappingChange(mappingRef.current, columnIndex, mapsTo))
|
||||
scheduleSaveMapping()
|
||||
}
|
||||
|
||||
const onSetHeader = (rowIndex: number | null) => {
|
||||
if (isCompleted) return
|
||||
setHeaderIndex(rowIndex)
|
||||
setHeader({ statement_import_id: data.doc.name, header_index: rowIndex ?? -1 })
|
||||
.then((res) => {
|
||||
// The backend re-derives the mapping for the new header; sync local state.
|
||||
const doc = res?.message?.doc
|
||||
if (doc) {
|
||||
commitMapping(toMapping(doc.column_mapping))
|
||||
setHeaderIndex(headerToState(doc.detected_header_index))
|
||||
}
|
||||
mutate()
|
||||
})
|
||||
.catch(() => toast.error(_("Could not update the header row.")))
|
||||
}
|
||||
|
||||
return (
|
||||
<RawTableGrid
|
||||
rows={data.raw_data}
|
||||
columnMapping={columnMappingRecord}
|
||||
headerIndex={headerIndex}
|
||||
editable={!isCompleted}
|
||||
disabled={isCompleted || savingMapping || savingHeader}
|
||||
onChangeMapping={onChangeMapping}
|
||||
onSetHeader={onSetHeader}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default CSVRawDataPreview
|
||||
@@ -1,360 +0,0 @@
|
||||
import _ from '@/lib/translate'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
import { flt, formatCurrency } from '@/lib/numbers'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { bankRecDateAtom } from '../../BankReconciliation/bankRecAtoms'
|
||||
import { AlertCircleIcon, ChevronLeftIcon, ChevronRightIcon, ExternalLinkIcon, InfoIcon, Loader2Icon } from 'lucide-react'
|
||||
import { H2, H3, Paragraph } from '@/components/ui/typography'
|
||||
import { FileTypeIcon } from '@/components/ui/file-dropzone'
|
||||
import { getFileExtension } from '@/lib/file'
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useFrappeEventListener, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
import { useGetBankAccounts } from '../../BankReconciliation/utils'
|
||||
import { BankStatementImportLog } from '@/types/Accounts/BankStatementImportLog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
|
||||
const parseDateFormat = (dateFormat: string) => {
|
||||
|
||||
const charMap = {
|
||||
"%d": "DD",
|
||||
"%m": "MM",
|
||||
"%Y": "YYYY",
|
||||
"%y": "YY",
|
||||
"%b": "MMM",
|
||||
"%B": "MMMM",
|
||||
}
|
||||
|
||||
let label = dateFormat
|
||||
|
||||
Object.keys(charMap).forEach((char) => {
|
||||
label = label.replace(char, charMap[char as keyof typeof charMap])
|
||||
})
|
||||
|
||||
return dateFormat
|
||||
|
||||
}
|
||||
|
||||
type Props = {
|
||||
data: GetStatementDetailsResponse,
|
||||
}
|
||||
|
||||
const StatementDetails = ({ data }: Props) => {
|
||||
const dateFormat = parseDateFormat(data.date_format)
|
||||
|
||||
const { call, loading, error } = useFrappePostCall<{ docs: BankStatementImportLog[] }>('run_doc_method')
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const setDates = useSetAtom(bankRecDateAtom)
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
const onImport = () => {
|
||||
|
||||
call({
|
||||
docs: data.doc,
|
||||
method: 'insert_transactions'
|
||||
}).then((response) => {
|
||||
const doc = response.docs ? response.docs[0] : undefined
|
||||
if (doc && doc.start_date && doc.end_date) {
|
||||
setDates({
|
||||
fromDate: doc.start_date,
|
||||
toDate: doc.end_date,
|
||||
})
|
||||
}
|
||||
toast.success(_("Bank statement imported."))
|
||||
navigate(`/`)
|
||||
}).catch(() => {
|
||||
toast.error(_("There was an error while importing the bank statement."))
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
useFrappeEventListener("bank-rec-statement-import-progress", (event) => {
|
||||
setProgress(event.progress)
|
||||
})
|
||||
|
||||
const file_name = data.doc.file.split("/").pop() ?? ""
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
|
||||
return banks?.find((bank) => bank.name === data.doc.bank_account)
|
||||
|
||||
}, [data.doc.bank_account, banks])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<Button size='sm' variant='outline' asChild>
|
||||
<Link to="/statement-importer">
|
||||
{direction === 'ltr' ? <ChevronLeftIcon /> : <ChevronRightIcon />}
|
||||
{_("Back")}
|
||||
</Link>
|
||||
</Button>
|
||||
{data.doc.status === 'Completed' ? <Badge theme='green'>{_("Completed")}</Badge> :
|
||||
<Button onClick={onImport} disabled={loading || data.final_transactions?.length === 0} size='sm' type='button'>
|
||||
{loading ? <Loader2Icon className='size-4 animate-spin' /> : null}
|
||||
{loading ? _("Importing...") : _("Import {0} transactions", [data.final_transactions?.length?.toString() || "0"])}</Button>
|
||||
}
|
||||
</div>
|
||||
<div className='flex items-start gap-4'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<H2 className='text-lg border-0 p-0'>{_("Statement Details")}</H2>
|
||||
<Paragraph className='text-p-sm'><span>
|
||||
{_("We've auto-detected the details of the statement file.")}
|
||||
</span><br />
|
||||
<span>
|
||||
{_("Please review the details below and click the 'Import' button to proceed.")}
|
||||
</span>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{progress > 0 && <div className='flex flex-col gap-2'><Progress value={progress} max={100} size="lg" />
|
||||
<span className='text-sm'>{_("Importing {0} transactions", [progress.toString()])}
|
||||
</span>
|
||||
</div>}
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<Table>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableHead>{_("Bank Account")}</TableHead>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<BankLogo bank={bank} />
|
||||
<span className="text-sm">{bank?.account_name}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableCell>
|
||||
<span title="GL Account" className="text-sm">{bank?.account}</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Statement File")}</TableHead>
|
||||
<TableCell>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileTypeIcon fileType={getFileExtension(file_name)} size='md' showBackground={false} />
|
||||
{file_name}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Transaction Dates")}</TableHead>
|
||||
{data.doc.start_date && data.doc.end_date ? (
|
||||
<TableCell>{_("{0} to {1}", [formatDate(data.doc.start_date, "Do MMMM YYYY"), formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableCell>
|
||||
) : (
|
||||
<TableCell>-</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Number of Transactions")}</TableHead>
|
||||
<TableCell>{data.doc.number_of_transactions}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Total Debits")}</TableHead>
|
||||
<TableCell><span className='font-numeric'>{formatCurrency(flt(data.doc.total_debits, 2), data.currency)}</span> <span className='text-ink-gray-5 font-sans'>({data.doc.total_debit_transactions} {data.doc.total_debit_transactions === 1 ? _("transaction") : _("transactions")})</span></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Total Credits")}</TableHead>
|
||||
<TableCell><span className='font-numeric'>{formatCurrency(flt(data.doc.total_credits, 2), data.currency)}</span> <span className='text-ink-gray-5 font-sans'>({data.doc.total_credit_transactions} {data.doc.total_credit_transactions === 1 ? _("transaction") : _("transactions")})</span></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Closing Balance as of {}", [formatDate(data.doc.end_date, "Do MMMM YYYY")])}</TableHead>
|
||||
<TableCell className='font-numeric'>{formatCurrency(flt(data.doc.closing_balance, 2), data.currency)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<div className='flex items-center gap-2'>
|
||||
{_("Detected Amount Format")} <Tooltip>
|
||||
<TooltipTrigger><InfoIcon size={16} /></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("The amount format detected in the statement file. This is used to parse the deposit and withdrawal values from each row.")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableCell>{data.doc.detected_amount_format}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>
|
||||
<div className='flex items-center gap-2'>
|
||||
{_("Detected Date Format")}
|
||||
<Tooltip>
|
||||
<TooltipTrigger><InfoIcon size={16} /></TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("The date format detected in the statement file. This is used to parse the date values.")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableCell>
|
||||
{dateFormat || data.date_format} (e.g.{" "}
|
||||
{formatDate(new Date(), dateFormat || "YYYY-MM-DD")})
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{data.doc.status === "Not Started" ? <>
|
||||
|
||||
<ConflictingTransactions transactions={data.conflicting_transactions} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<H3 className='text-base border-0 p-0'>{_("Preview Transactions")}</H3>
|
||||
{data.final_transactions?.length === 1 ? (
|
||||
<Paragraph className='text-p-sm'>{_("We've found 1 transaction in the statement file that will be imported into the system. Please review the details below and click the 'Import' button to proceed.")}</Paragraph>
|
||||
) : (
|
||||
<Paragraph className='text-p-sm'>{_("{0} transactions will be imported into the system. Please review the details below and click the 'Import' button to proceed.", [data.final_transactions?.length?.toString() || "0"])}</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className='max-h-[400px] overflow-scroll pb-2'>
|
||||
<Table>
|
||||
<TableCaption>{_("Transactions to be imported into the system")}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='w-8'>#</TableHead>
|
||||
<TableHead>{_("Date")}</TableHead>
|
||||
<TableHead>{_("Description")}</TableHead>
|
||||
<TableHead>{_("Ref.")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Withdrawal")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Deposit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{data.final_transactions?.map((transaction, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell className='w-8'>{index + 1}</TableCell>
|
||||
<TableCell>{formatDate(transaction.date)}</TableCell>
|
||||
<TableCell className='max-w-[200px] w-fit overflow-hidden text-ellipsis'>{transaction.description}</TableCell>
|
||||
<TableCell className='max-w-[100px] w-fit overflow-hidden text-ellipsis'>{transaction.reference}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.withdrawal, data.currency)}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.deposit, data.currency)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</> : null}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
|
||||
const ConflictingTransactions = ({ transactions }: { transactions: GetStatementDetailsResponse["conflicting_transactions"] }) => {
|
||||
|
||||
if (transactions.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>
|
||||
<Alert theme="red">
|
||||
<AlertCircleIcon />
|
||||
<AlertTitle>{_("Conflicting Transactions")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{transactions.length === 1 ? _("We've found 1 existing transaction in the system that conflicts with the transactions in the statement file. Are you sure you want to proceed with the import?")
|
||||
: _("We've found {0} existing transactions in the system that conflict with the transactions in the statement file. Are you sure you want to proceed with the import?", [transactions.length.toString()])}
|
||||
|
||||
<div className='py-2'>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size='sm'
|
||||
type='button'
|
||||
theme='red'
|
||||
variant='solid'>
|
||||
<span>{transactions.length > 1 ? _("View transactions") : _("View transaction")}</span>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Conflicting Transactions")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{transactions.length === 1 ? _("We've found 1 existing transaction in the system that conflicts with the transactions in the statement file. Are you sure you want to proceed with the import?")
|
||||
: _("We've found {0} existing transactions in the system that conflict with the transactions in the statement file. Are you sure you want to proceed with the import?", [transactions.length.toString()])}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className='max-h-[400px] overflow-scroll pb-2'>
|
||||
<Table>
|
||||
<TableCaption>{_("Existing transactions in the system belonging to the same bank account and date range")}</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Date")}</TableHead>
|
||||
<TableHead>{_("Description")}</TableHead>
|
||||
<TableHead>{_("Ref.")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Withdrawal")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Deposit")}</TableHead>
|
||||
<TableHead></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transactions.map((transaction) => (
|
||||
<TableRow key={transaction.name}>
|
||||
<TableCell>{formatDate(transaction.date)}</TableCell>
|
||||
<TableCell title={transaction.description} className='max-w-[200px] w-fit overflow-hidden text-ellipsis'>{transaction.description}</TableCell>
|
||||
<TableCell title={transaction.reference_number} className='max-w-[100px] w-fit overflow-hidden text-ellipsis'>{transaction.reference_number ? transaction.reference_number : "-"}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.withdrawal, transaction.currency)}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(transaction.deposit, transaction.currency)}</TableCell>
|
||||
<TableCell className='text-end'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant='link' isIconButton asChild className='text-ink-gray-5 hover:text-black p-0 h-4'>
|
||||
<a href={`/desk/bank-transaction/${transaction.name}`} target='_blank' rel='noopener noreferrer'>
|
||||
<ExternalLinkIcon />
|
||||
</a>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Open {0} in a new tab", [transaction.name])}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} size='md' type='button'>{_("Close")}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
</Dialog>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
}
|
||||
|
||||
export default StatementDetails
|
||||
@@ -1,129 +0,0 @@
|
||||
import { RefObject, useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type Bbox = [number, number, number, number]
|
||||
|
||||
const MIN_SIZE = 8 // PDF points
|
||||
|
||||
// Keep the box valid: normalise flipped edges, enforce a min size, clamp to the page.
|
||||
const clampBbox = (bbox: Bbox, pageWidth: number, pageHeight: number): Bbox => {
|
||||
let [x0, top, x1, bottom] = bbox
|
||||
if (x1 < x0) [x0, x1] = [x1, x0]
|
||||
if (bottom < top) [top, bottom] = [bottom, top]
|
||||
x0 = Math.max(0, Math.min(x0, pageWidth - MIN_SIZE))
|
||||
top = Math.max(0, Math.min(top, pageHeight - MIN_SIZE))
|
||||
x1 = Math.min(pageWidth, Math.max(x1, x0 + MIN_SIZE))
|
||||
bottom = Math.min(pageHeight, Math.max(bottom, top + MIN_SIZE))
|
||||
return [x0, top, x1, bottom]
|
||||
}
|
||||
|
||||
const HANDLES = [
|
||||
{ id: 'nw', className: 'left-0 top-0 -translate-x-1/2 -translate-y-1/2 cursor-nwse-resize' },
|
||||
{ id: 'ne', className: 'right-0 top-0 translate-x-1/2 -translate-y-1/2 cursor-nesw-resize' },
|
||||
{ id: 'sw', className: 'left-0 bottom-0 -translate-x-1/2 translate-y-1/2 cursor-nesw-resize' },
|
||||
{ id: 'se', className: 'right-0 bottom-0 translate-x-1/2 translate-y-1/2 cursor-nwse-resize' },
|
||||
]
|
||||
|
||||
type Props = {
|
||||
bbox: Bbox
|
||||
pageWidth: number
|
||||
pageHeight: number
|
||||
color: { border: string; bg: string; swatch: string }
|
||||
label: string
|
||||
included: boolean
|
||||
disabled?: boolean
|
||||
containerRef: RefObject<HTMLDivElement | null>
|
||||
onCommit: (bbox: Bbox) => void
|
||||
}
|
||||
|
||||
/** A draggable + corner-resizable rectangle over a rendered PDF page. Coordinates are in PDF
|
||||
* points (top-left origin); pixel deltas are converted using the container's rendered size. */
|
||||
const BBoxOverlay = ({ bbox, pageWidth, pageHeight, color, label, included, disabled, containerRef, onCommit }: Props) => {
|
||||
const [draft, setDraft] = useState<Bbox>(bbox)
|
||||
const draftRef = useRef<Bbox>(bbox)
|
||||
const drag = useRef<{ mode: string; startX: number; startY: number; start: Bbox } | null>(null)
|
||||
|
||||
// Reset to the authoritative bbox whenever it changes (e.g. after a server re-extract).
|
||||
useEffect(() => {
|
||||
setDraft(bbox)
|
||||
draftRef.current = bbox
|
||||
}, [bbox])
|
||||
|
||||
const apply = (next: Bbox) => {
|
||||
draftRef.current = next
|
||||
setDraft(next)
|
||||
}
|
||||
|
||||
const onPointerDown = (e: React.PointerEvent) => {
|
||||
if (disabled) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const mode = (e.target as HTMLElement).dataset.handle ?? 'move'
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
drag.current = { mode, startX: e.clientX, startY: e.clientY, start: draftRef.current }
|
||||
}
|
||||
|
||||
const onPointerMove = (e: React.PointerEvent) => {
|
||||
if (!drag.current || !containerRef.current) return
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
const dx = ((e.clientX - drag.current.startX) / rect.width) * pageWidth
|
||||
const dy = ((e.clientY - drag.current.startY) / rect.height) * pageHeight
|
||||
let [x0, top, x1, bottom] = drag.current.start
|
||||
const m = drag.current.mode
|
||||
if (m === 'move') {
|
||||
x0 += dx
|
||||
x1 += dx
|
||||
top += dy
|
||||
bottom += dy
|
||||
} else {
|
||||
if (m.includes('w')) x0 += dx
|
||||
if (m.includes('e')) x1 += dx
|
||||
if (m.includes('n')) top += dy
|
||||
if (m.includes('s')) bottom += dy
|
||||
}
|
||||
apply(clampBbox([x0, top, x1, bottom], pageWidth, pageHeight))
|
||||
}
|
||||
|
||||
const onPointerUp = (e: React.PointerEvent) => {
|
||||
if (!drag.current) return
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||||
drag.current = null
|
||||
onCommit(draftRef.current)
|
||||
}
|
||||
|
||||
const [x0, top, x1, bottom] = draft
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute touch-none border-2',
|
||||
color.border,
|
||||
included ? color.bg : 'opacity-40',
|
||||
disabled ? 'pointer-events-none' : 'cursor-move',
|
||||
)}
|
||||
style={{
|
||||
left: `${(x0 / pageWidth) * 100}%`,
|
||||
top: `${(top / pageHeight) * 100}%`,
|
||||
width: `${((x1 - x0) / pageWidth) * 100}%`,
|
||||
height: `${((bottom - top) / pageHeight) * 100}%`,
|
||||
}}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<span className={cn('pointer-events-none absolute -top-5 left-0 rounded px-1 text-[10px] font-medium text-white', color.swatch)}>
|
||||
{label}
|
||||
</span>
|
||||
{!disabled &&
|
||||
HANDLES.map((handle) => (
|
||||
<span
|
||||
key={handle.id}
|
||||
data-handle={handle.id}
|
||||
className={cn('absolute size-2.5 rounded-sm border border-white', color.swatch, handle.className)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BBoxOverlay
|
||||
@@ -1,23 +0,0 @@
|
||||
import StatementDetails from '../CSV/StatementDetails'
|
||||
import PDFTableEditor from './PDFTableEditor'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
type Props = {
|
||||
data: { message: GetStatementDetailsResponse }
|
||||
mutate: () => void
|
||||
}
|
||||
|
||||
const PDFImport = ({ data, mutate }: Props) => {
|
||||
return (
|
||||
<div className="w-full flex">
|
||||
<div className="w-[45%] p-4 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<StatementDetails data={data.message} />
|
||||
</div>
|
||||
<div className="w-[55%] border-s pe-1 ps-0 border-outline-gray-2 h-[calc(100vh-72px)] overflow-scroll">
|
||||
<PDFTableEditor data={data.message} mutate={mutate} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PDFImport
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user