mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-05 21:29:11 +00:00
Merge branch 'develop' into standard-letter-heads
This commit is contained in:
52
.github/helper/merge_po_files.py
vendored
Normal file
52
.github/helper/merge_po_files.py
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
#!/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.")
|
||||||
121
.github/helper/sync_hotfix_translations.sh
vendored
Normal file
121
.github/helper/sync_hotfix_translations.sh
vendored
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
#!/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}"
|
||||||
70
.github/workflows/build-and-commit-assets.yml
vendored
Normal file
70
.github/workflows/build-and-commit-assets.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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
|
||||||
2
.github/workflows/docker-release.yml
vendored
2
.github/workflows/docker-release.yml
vendored
@@ -15,4 +15,4 @@ jobs:
|
|||||||
- name: curl
|
- name: curl
|
||||||
run: |
|
run: |
|
||||||
apk add curl bash
|
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/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/core-build-stable.yml/dispatches -d '{"ref":"main"}'
|
||||||
|
|||||||
52
.github/workflows/run-hotfix-translation-sync.yml
vendored
Normal file
52
.github/workflows/run-hotfix-translation-sync.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
# 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
|
||||||
40
.github/workflows/sync-hotfix-translations.yml
vendored
Normal file
40
.github/workflows/sync-hotfix-translations.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 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 }}
|
||||||
@@ -9,16 +9,18 @@ export default defineConfig([
|
|||||||
globalIgnores(["dist"]),
|
globalIgnores(["dist"]),
|
||||||
{
|
{
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite],
|
||||||
js.configs.recommended,
|
plugins: {
|
||||||
tseslint.configs.recommended,
|
"react-hooks": reactHooks,
|
||||||
reactHooks.configs.flat.recommended,
|
},
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
onlyExportComponents: false,
|
rules: {
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"react-refresh/only-export-components": "off",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -41,7 +41,6 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.15.0",
|
"react-router": "^7.15.0",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.0",
|
||||||
"react-virtuoso": "^4.18.6",
|
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
const common_site_config = require('../../../sites/common_site_config.json');
|
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;
|
const { webserver_port } = common_site_config;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -6,8 +10,8 @@ export default {
|
|||||||
target: `http://127.0.0.1:${webserver_port}`,
|
target: `http://127.0.0.1:${webserver_port}`,
|
||||||
ws: true,
|
ws: true,
|
||||||
router: function (req) {
|
router: function (req) {
|
||||||
const site_name = req.headers.host.split(':')[0];
|
const site_name = req.headers?.host?.split(':')[0];
|
||||||
return `http://${site_name}:${webserver_port}`;
|
return `http://${site_name ?? 'localhost'}:${webserver_port}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { useEffect } from 'react'
|
import { lazy, useEffect } from 'react'
|
||||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||||
import { FrappeProvider } from 'frappe-react-sdk'
|
import { FrappeProvider } from 'frappe-react-sdk'
|
||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import BankReconciliation from '@/pages/BankReconciliation'
|
import BankReconciliation from '@/pages/BankReconciliation'
|
||||||
|
import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer'
|
||||||
import { TooltipProvider } from './components/ui/tooltip'
|
import { TooltipProvider } from './components/ui/tooltip'
|
||||||
import BankStatementImporter from '@/pages/BankStatementImporter'
|
|
||||||
import { LucideProvider } from 'lucide-react'
|
import { LucideProvider } from 'lucide-react'
|
||||||
import { ThemeProvider } from './components/ui/theme-provider'
|
import { ThemeProvider } from './components/ui/theme-provider'
|
||||||
import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog'
|
|
||||||
import BankStatementImporterContainer from './pages/BankStatementImporterContainer'
|
const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter'))
|
||||||
|
const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog'))
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -43,7 +44,6 @@ function App() {
|
|||||||
>
|
>
|
||||||
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
|
{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}` : ''}>
|
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route index element={<BankReconciliation />} />
|
<Route index element={<BankReconciliation />} />
|
||||||
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
|
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
|
||||||
|
|||||||
@@ -1,35 +1,13 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { useAtomValue, useSetAtom } from 'jotai'
|
import { HistoryIcon } from 'lucide-react'
|
||||||
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
import { useGetBankAccounts } from '../BankReconciliation/utils'
|
import ActionLogDialog from './ActionLogDialog'
|
||||||
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 ActionLog = () => {
|
const ActionLog = () => {
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
useHotkeys('meta+z', () => {
|
useHotkeys('meta+z', () => {
|
||||||
@@ -54,422 +32,11 @@ const ActionLog = () => {
|
|||||||
{_("Reconciliation History")}
|
{_("Reconciliation History")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<DialogContent className='min-w-[90vw]'>
|
{isOpen && (
|
||||||
<DialogHeader>
|
<ActionLogDialog onClose={() => setIsOpen(false)} />
|
||||||
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
|
)}
|
||||||
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<ActionLogDialogContent />
|
|
||||||
<DialogFooter>
|
|
||||||
<DialogClose asChild>
|
|
||||||
<Button variant={'outline'} size='md' onClick={() => setIsOpen(false)}>{_("Close")}</Button>
|
|
||||||
</DialogClose>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ActionLogDialogContent = () => {
|
|
||||||
|
|
||||||
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 ActionLog
|
export default ActionLog
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
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
|
||||||
@@ -83,7 +83,7 @@ const BankClearanceSummaryView = () => {
|
|||||||
toast.success(_("Copied to clipboard"))
|
toast.success(_("Copied to clipboard"))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[copyToClipboard, _],
|
[copyToClipboard],
|
||||||
)
|
)
|
||||||
|
|
||||||
const accountCurrency = useMemo(
|
const accountCurrency = useMemo(
|
||||||
@@ -200,7 +200,7 @@ const BankClearanceSummaryView = () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[_, accountCurrency, bankAccount, companyID, mutate, onCopy],
|
[accountCurrency, bankAccount, companyID, mutate, onCopy],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <div className="space-y-4 py-2">
|
return <div className="space-y-4 py-2">
|
||||||
|
|||||||
@@ -1,36 +1,13 @@
|
|||||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
import { useAtom } from "jotai"
|
||||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms"
|
||||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog"
|
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
|
||||||
|
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
|
import { lazy, Suspense } from "react"
|
||||||
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
|
|
||||||
import { JournalEntry } from "@/types/Accounts/JournalEntry"
|
const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent'))
|
||||||
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 { 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 BankEntryModal = () => {
|
const BankEntryModal = () => {
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,790 +19,14 @@ const BankEntryModal = () => {
|
|||||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{isOpen && (
|
||||||
|
<Suspense fallback={<ModalContentFallback />}>
|
||||||
<RecordBankEntryModalContent />
|
<RecordBankEntryModalContent />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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, setUploadProgress] = useState(0)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
const uploadPromises = files.map(f => {
|
|
||||||
return frappeFile.uploadFile(f, {
|
|
||||||
isPrivate: true,
|
|
||||||
doctype: "Journal Entry",
|
|
||||||
docname: message.journal_entry.name,
|
|
||||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
|
||||||
|
|
||||||
setUploadProgress((currentProgress) => {
|
|
||||||
//If there are multiple files, we need to add the progress to the current progress
|
|
||||||
return currentProgress + ((progress?.progress ?? 0) / files.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Promise.all(uploadPromises).then(() => {
|
|
||||||
setUploadProgress(0)
|
|
||||||
setIsUploading(false)
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error(error)
|
|
||||||
toast.error(_("Error uploading attachments"), {
|
|
||||||
duration: 4000,
|
|
||||||
})
|
|
||||||
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(() => {
|
|
||||||
remove(selectedRows)
|
|
||||||
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 BankEntryModal
|
export default BankEntryModal
|
||||||
|
|||||||
@@ -0,0 +1,811 @@
|
|||||||
|
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
|
||||||
@@ -76,7 +76,7 @@ const BankReconciliationStatementView = () => {
|
|||||||
toast.success(_("Copied to clipboard"))
|
toast.success(_("Copied to clipboard"))
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[copyToClipboard, _],
|
[copyToClipboard],
|
||||||
)
|
)
|
||||||
|
|
||||||
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||||
@@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => {
|
|||||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[_, onCopy],
|
[onCopy],
|
||||||
)
|
)
|
||||||
|
|
||||||
const statementRows = useMemo(() => {
|
const statementRows = useMemo(() => {
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const BankTransactionListView = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[_, accountCurrency, onUndo],
|
[accountCurrency, onUndo],
|
||||||
)
|
)
|
||||||
|
|
||||||
const [search, setSearch] = useDebounceValue('', 250)
|
const [search, setSearch] = useDebounceValue('', 250)
|
||||||
|
|||||||
@@ -1,20 +1,25 @@
|
|||||||
import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"
|
import {
|
||||||
import { useAtom, useAtomValue } from "jotai"
|
AlertDialog,
|
||||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
AlertDialogContent,
|
||||||
import { useMemo } from "react"
|
AlertDialogDescription,
|
||||||
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
AlertDialogHeader,
|
||||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
AlertDialogTitle,
|
||||||
import { toast } from "sonner"
|
} from "@/components/ui/alert-dialog"
|
||||||
import ErrorBanner from "@/components/ui/error-banner"
|
import { useAtom } from "jotai"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Loader2Icon } from "lucide-react"
|
||||||
import { formatCurrency } from "@/lib/numbers"
|
import { lazy, Suspense } from "react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { bankRecUnreconcileModalAtom } from "./bankRecAtoms"
|
||||||
import { slug } from "@/lib/frappe"
|
|
||||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
|
|
||||||
const BankTransactionUnreconcileModal = () => {
|
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 [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||||
|
|
||||||
const onOpenChange = (v: boolean) => {
|
const onOpenChange = (v: boolean) => {
|
||||||
@@ -23,8 +28,12 @@ const BankTransactionUnreconcileModal = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <AlertDialog open={!!unreconcileModal} onOpenChange={onOpenChange}>
|
if (!unreconcileModal) {
|
||||||
<AlertDialogOverlay />
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog open onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent className="min-w-2xl">
|
<AlertDialogContent className="min-w-2xl">
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||||
@@ -32,94 +41,12 @@ const BankTransactionUnreconcileModal = () => {
|
|||||||
{_("Are you sure you want to unreconcile this transaction?")}
|
{_("Are you sure you want to unreconcile this transaction?")}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<BankTransactionUnreconcileModalContent />
|
<Suspense fallback={<BankTransactionUnreconcileModalFallback />}>
|
||||||
|
<BankTransactionUnreconcileModalBody />
|
||||||
|
</Suspense>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
const BankTransactionUnreconcileModalContent = () => {
|
|
||||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
|
||||||
const dates = useAtomValue(bankRecDateAtom)
|
|
||||||
|
|
||||||
const { mutate } = useSWRConfig()
|
|
||||||
|
|
||||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
|
||||||
|
|
||||||
const { data: transaction, error } = 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 the transactions list, unreconciled transactions list and account closing balance
|
|
||||||
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>
|
|
||||||
<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}>
|
|
||||||
{_("Unreconcile")}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default BankTransactionUnreconcileModal
|
export default BankTransactionUnreconcileModal
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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
|
||||||
@@ -91,7 +91,7 @@ const IncorrectlyClearedEntriesView = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[clearClearingDate, mutate, _],
|
[clearClearingDate, mutate],
|
||||||
)
|
)
|
||||||
|
|
||||||
const accountCurrency = useMemo(
|
const accountCurrency = useMemo(
|
||||||
@@ -174,7 +174,7 @@ const IncorrectlyClearedEntriesView = () => {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[_, accountCurrency, onClearClick],
|
[accountCurrency, onClearClick],
|
||||||
)
|
)
|
||||||
|
|
||||||
return <div className="space-y-4 py-2">
|
return <div className="space-y-4 py-2">
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import CurrencyInput from 'react-currency-input-field'
|
import CurrencyInput from 'react-currency-input-field'
|
||||||
import { getCurrencySymbol } from "@/lib/currency"
|
import { getCurrencySymbol } from "@/lib/currency"
|
||||||
import { Virtuoso } from 'react-virtuoso'
|
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||||
import { formatDate } from "@/lib/date"
|
import { formatDate } from "@/lib/date"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||||
@@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp
|
|||||||
import { Skeleton } from "@/components/ui/skeleton"
|
import { Skeleton } from "@/components/ui/skeleton"
|
||||||
import { slug } from "@/lib/frappe"
|
import { slug } from "@/lib/frappe"
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
|
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import TransferModal from "./TransferModal"
|
import TransferModal from "./TransferModal"
|
||||||
import BankEntryModal from "./BankEntryModal"
|
import BankEntryModal from "./BankEntryModal"
|
||||||
import RecordPaymentModal from "./RecordPaymentModal"
|
import RecordPaymentModal from "./RecordPaymentModal"
|
||||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||||
import MatchFilters from "./MatchFilters"
|
import MatchFilters from "./MatchFilters"
|
||||||
import { useHotkeys } from "react-hotkeys-hook"
|
import { useHotkeys } from "react-hotkeys-hook"
|
||||||
@@ -69,6 +69,59 @@ const MatchAndReconcile = ({ contentHeight }: { contentHeight: number }) => {
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** TanStack requires `estimateSize` for initial scroll range; `measureElement` on each row sets the real height. */
|
||||||
|
function VirtualizedListBody<T>({
|
||||||
|
items,
|
||||||
|
height,
|
||||||
|
getItemKey,
|
||||||
|
children,
|
||||||
|
estimateSize = 74,
|
||||||
|
}: {
|
||||||
|
items: T[]
|
||||||
|
height: number
|
||||||
|
getItemKey: (item: T, index: number) => string | number
|
||||||
|
children: (item: T, index: number) => React.ReactNode
|
||||||
|
estimateSize?: number
|
||||||
|
}) {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: items.length,
|
||||||
|
getScrollElement: () => scrollRef.current,
|
||||||
|
estimateSize: () => estimateSize,
|
||||||
|
overscan: 8,
|
||||||
|
getItemKey: (index) => String(getItemKey(items[index], index)),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={scrollRef}
|
||||||
|
className="overflow-auto contain-strict"
|
||||||
|
style={{ height }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative w-full"
|
||||||
|
style={{ height: rowVirtualizer.getTotalSize() }}
|
||||||
|
>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
|
||||||
|
<div
|
||||||
|
key={virtualRow.key}
|
||||||
|
data-index={virtualRow.index}
|
||||||
|
ref={rowVirtualizer.measureElement}
|
||||||
|
className="absolute top-0 left-0 w-full"
|
||||||
|
style={{ transform: `translateY(${virtualRow.start}px)` }}
|
||||||
|
>
|
||||||
|
{children(items[virtualRow.index], virtualRow.index)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number }) => {
|
||||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||||
@@ -134,6 +187,7 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||||
|
const listHeight = contentHeight - 72
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <UnreconciledTransactionsLoadingState />
|
return <UnreconciledTransactionsLoadingState />
|
||||||
@@ -222,14 +276,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
|||||||
text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")}
|
text={hasFilters ? _("No transactions found for the given filters.") : _("No unreconciled transactions found")}
|
||||||
description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />}
|
description={hasFilters ? _("Try adjusting your search or filter criteria.") : _("Import your bank statement to get started.")} />}
|
||||||
|
|
||||||
<Virtuoso
|
<VirtualizedListBody
|
||||||
data={results}
|
items={results}
|
||||||
itemContent={(_index, transaction) => (
|
height={listHeight}
|
||||||
<UnreconciledTransactionItem transaction={transaction} />
|
estimateSize={74}
|
||||||
)}
|
getItemKey={(transaction) => transaction.name}
|
||||||
style={{ minHeight: Math.max(contentHeight - 80, 400) }}
|
>
|
||||||
totalCount={results?.length}
|
{(transaction) => <UnreconciledTransactionItem transaction={transaction} />}
|
||||||
/>
|
</VirtualizedListBody>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -559,11 +613,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
||||||
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||||
|
|
||||||
if (!rule) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const getActionIcon = () => {
|
const getActionIcon = () => {
|
||||||
|
if (!rule) return null
|
||||||
switch (rule.classify_as) {
|
switch (rule.classify_as) {
|
||||||
case "Bank Entry":
|
case "Bank Entry":
|
||||||
return <Landmark />
|
return <Landmark />
|
||||||
@@ -577,6 +628,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActionStyles = () => {
|
const getActionStyles = () => {
|
||||||
|
if (!rule) return {}
|
||||||
switch (rule.classify_as) {
|
switch (rule.classify_as) {
|
||||||
case "Bank Entry":
|
case "Bank Entry":
|
||||||
return {
|
return {
|
||||||
@@ -610,6 +662,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleActionClick = () => {
|
const handleActionClick = () => {
|
||||||
|
if (!rule) return
|
||||||
switch (rule.classify_as) {
|
switch (rule.classify_as) {
|
||||||
case "Bank Entry":
|
case "Bank Entry":
|
||||||
setRecordJournalEntryModalOpen(true)
|
setRecordJournalEntryModalOpen(true)
|
||||||
@@ -624,6 +677,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getActionDescription = () => {
|
const getActionDescription = () => {
|
||||||
|
if (!rule) return ""
|
||||||
switch (rule.classify_as) {
|
switch (rule.classify_as) {
|
||||||
case "Bank Entry":
|
case "Bank Entry":
|
||||||
return _("Create a journal entry for expenses, income or split transactions")
|
return _("Create a journal entry for expenses, income or split transactions")
|
||||||
@@ -636,8 +690,7 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useHotkeys('meta+r', () => {
|
useHotkeys('alt+r', () => {
|
||||||
//
|
|
||||||
handleActionClick()
|
handleActionClick()
|
||||||
}, {
|
}, {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -647,6 +700,10 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
|||||||
|
|
||||||
const styles = getActionStyles()
|
const styles = getActionStyles()
|
||||||
|
|
||||||
|
if (!rule) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
|
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
|
||||||
<CardHeader className="pb-0">
|
<CardHeader className="pb-0">
|
||||||
@@ -721,6 +778,9 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
|||||||
|
|
||||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||||
|
|
||||||
|
const voucherList = vouchers?.message ?? []
|
||||||
|
const listHeight = contentHeight - 120
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <ErrorBanner error={error} />
|
return <ErrorBanner error={error} />
|
||||||
}
|
}
|
||||||
@@ -747,7 +807,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
|||||||
<span>or</span>
|
<span>or</span>
|
||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
{vouchers?.message.length === 0 && <Empty className="my-4">
|
{voucherList.length === 0 && <Empty className="my-4">
|
||||||
<EmptyMedia>
|
<EmptyMedia>
|
||||||
<ReceiptIcon />
|
<ReceiptIcon />
|
||||||
</EmptyMedia>
|
</EmptyMedia>
|
||||||
@@ -756,14 +816,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
|||||||
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
|
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
|
||||||
</EmptyHeader>
|
</EmptyHeader>
|
||||||
</Empty>}
|
</Empty>}
|
||||||
<Virtuoso
|
<VirtualizedListBody
|
||||||
data={vouchers?.message}
|
items={voucherList}
|
||||||
itemContent={(index, voucher) => (
|
height={listHeight}
|
||||||
<VoucherItem voucher={voucher} index={index} />
|
estimateSize={121}
|
||||||
)}
|
getItemKey={(voucher) => voucher.name}
|
||||||
style={{ height: contentHeight }}
|
>
|
||||||
totalCount={vouchers?.message.length}
|
{(voucher, index) => <VoucherItem voucher={voucher} index={index} />}
|
||||||
/>
|
</VirtualizedListBody>
|
||||||
</div >
|
</div >
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,36 +1,13 @@
|
|||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
import { useAtom } from 'jotai'
|
||||||
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
import { ModalContentFallback } from '@/components/ui/modal-content-fallback'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
|
import { lazy, Suspense } from 'react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { bankRecTransferModalAtom } from './bankRecAtoms'
|
||||||
import SelectedTransactionDetails from './SelectedTransactionDetails'
|
|
||||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
const TransferModalContent = lazy(() => import('./TransferModalContent'))
|
||||||
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 { 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 TransferModal = () => {
|
const TransferModal = () => {
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,514 +19,14 @@ const TransferModal = () => {
|
|||||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{isOpen && (
|
||||||
|
<Suspense fallback={<ModalContentFallback />}>
|
||||||
<TransferModalContent />
|
<TransferModalContent />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 ?? '')
|
|
||||||
|
|
||||||
console.log("This is here", transactions)
|
|
||||||
|
|
||||||
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, setUploadProgress] = useState(0)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
const uploadPromises = files.map(f => {
|
|
||||||
return frappeFile.uploadFile(f, {
|
|
||||||
isPrivate: true,
|
|
||||||
doctype: "Payment Entry",
|
|
||||||
docname: message.payment_entry.name,
|
|
||||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
|
||||||
|
|
||||||
setUploadProgress((currentProgress) => {
|
|
||||||
//If there are multiple files, we need to add the progress to the current progress
|
|
||||||
return currentProgress + ((progress?.progress ?? 0) / files.length)
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return Promise.all(uploadPromises).then(() => {
|
|
||||||
setUploadProgress(0)
|
|
||||||
setIsUploading(false)
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
return Promise.resolve()
|
|
||||||
}
|
|
||||||
}).then(() => {
|
|
||||||
setUploadProgress(0)
|
|
||||||
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) => (
|
|
||||||
<div
|
|
||||||
className={cn('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'
|
|
||||||
)}
|
|
||||||
role='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>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<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 <div className={cn('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'
|
|
||||||
)}
|
|
||||||
role='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>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
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 TransferModal
|
export default TransferModal
|
||||||
@@ -0,0 +1,530 @@
|
|||||||
|
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,6 +1,5 @@
|
|||||||
import CSVRawDataPreview from './CSVRawDataPreview'
|
import CSVRawDataPreview from './CSVRawDataPreview'
|
||||||
import StatementDetails from './StatementDetails'
|
import StatementDetails from './StatementDetails'
|
||||||
import _ from '@/lib/translate'
|
|
||||||
import { GetStatementDetailsResponse } from '../import_utils'
|
import { GetStatementDetailsResponse } from '../import_utils'
|
||||||
|
|
||||||
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
const CSVImport = ({ data }: { data: { message: GetStatementDetailsResponse } }) => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { KeyboardMetaKeyIcon } from '@/components/ui/keyboard-keys'
|
|||||||
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
|
import { SettingsPanelDescription, SettingsPanelTitle, SettingsPanelHeader, SettingsPanelContent } from '@/components/ui/settings-dialog'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, OptionIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
||||||
|
|
||||||
const Shortcuts = [
|
const Shortcuts = [
|
||||||
{
|
{
|
||||||
@@ -32,7 +32,7 @@ const Shortcuts = [
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
shortcut: <KbdGroup><Kbd><OptionIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||||
action: {
|
action: {
|
||||||
icon: <ZapIcon />,
|
icon: <ZapIcon />,
|
||||||
label: _("Accept Matching Rule"),
|
label: _("Accept Matching Rule"),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const Preferences = () => {
|
|||||||
|
|
||||||
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
|
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
|
||||||
|
|
||||||
const onUpdate = (field: keyof AccountsSettings, value: any) => {
|
const onUpdate = <K extends keyof AccountsSettings>(field: K, value: AccountsSettings[K]) => {
|
||||||
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
|
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
|
||||||
[field]: value
|
[field]: value
|
||||||
}), {
|
}), {
|
||||||
|
|||||||
@@ -1,24 +1,13 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import {
|
|
||||||
SettingsDialog,
|
|
||||||
SettingsPanel,
|
|
||||||
SettingsPanels,
|
|
||||||
SettingsTabGroup,
|
|
||||||
SettingsTabItem,
|
|
||||||
SettingsTabs,
|
|
||||||
} from '@/components/ui/settings-dialog'
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
|
import { SettingsIcon } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Preferences } from './Preferences'
|
|
||||||
import MatchingRules from './MatchingRules'
|
|
||||||
import KeyboardShortcuts from './KeyboardShortcuts'
|
|
||||||
import { useHotkeys } from 'react-hotkeys-hook'
|
import { useHotkeys } from 'react-hotkeys-hook'
|
||||||
|
import SettingsDialogContent from './SettingsDialogContent'
|
||||||
|
|
||||||
const Settings = () => {
|
const Settings = () => {
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
useHotkeys('shift+meta+g', () => {
|
useHotkeys('shift+meta+g', () => {
|
||||||
@@ -34,7 +23,7 @@ const Settings = () => {
|
|||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant={'outline'} isIconButton size='md'>
|
<Button variant={'outline'} isIconButton size='md' aria-label={_("Settings")}>
|
||||||
<SettingsIcon />
|
<SettingsIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -43,51 +32,9 @@ const Settings = () => {
|
|||||||
{_("Settings")}
|
{_("Settings")}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<SettingsDialog defaultValue="preferences" onClose={() => setIsOpen(false)}>
|
{isOpen && (
|
||||||
<SettingsTabs>
|
<SettingsDialogContent onClose={() => setIsOpen(false)} />
|
||||||
<SettingsTabGroup header={_("Settings")}>
|
)}
|
||||||
<SettingsTabItem
|
|
||||||
icon={<SlidersVerticalIcon />}
|
|
||||||
label={_("Preferences")}
|
|
||||||
value="preferences"
|
|
||||||
/>
|
|
||||||
<SettingsTabItem
|
|
||||||
icon={<ZapIcon />}
|
|
||||||
label={_("Matching Rules")}
|
|
||||||
value="rules"
|
|
||||||
/>
|
|
||||||
{/* <SettingsTabItem
|
|
||||||
icon={<LandmarkIcon />}
|
|
||||||
label={_("Bank Accounts")}
|
|
||||||
value="bank-accounts"
|
|
||||||
/>
|
|
||||||
<SettingsTabItem
|
|
||||||
icon={<ListIcon />}
|
|
||||||
label={_("Masters")}
|
|
||||||
value="masters"
|
|
||||||
/> */}
|
|
||||||
<SettingsTabItem
|
|
||||||
icon={<KeyboardIcon />}
|
|
||||||
label={_("Keyboard Shortcuts")}
|
|
||||||
value="keyboard-shortcuts"
|
|
||||||
/>
|
|
||||||
</SettingsTabGroup>
|
|
||||||
</SettingsTabs>
|
|
||||||
|
|
||||||
<SettingsPanels>
|
|
||||||
<SettingsPanel value="preferences">
|
|
||||||
<Preferences />
|
|
||||||
</SettingsPanel>
|
|
||||||
<SettingsPanel value="rules">
|
|
||||||
<MatchingRules />
|
|
||||||
</SettingsPanel>
|
|
||||||
<SettingsPanel value="bank-accounts" />
|
|
||||||
<SettingsPanel value="masters" />
|
|
||||||
<SettingsPanel value="keyboard-shortcuts">
|
|
||||||
<KeyboardShortcuts />
|
|
||||||
</SettingsPanel>
|
|
||||||
</SettingsPanels>
|
|
||||||
</SettingsDialog>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
SettingsDialog,
|
||||||
|
SettingsPanels,
|
||||||
|
SettingsTabGroup,
|
||||||
|
SettingsTabItem,
|
||||||
|
SettingsTabs,
|
||||||
|
} from '@/components/ui/settings-dialog'
|
||||||
|
import _ from '@/lib/translate'
|
||||||
|
import { KeyboardIcon, Loader2Icon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
|
||||||
|
import { lazy, Suspense } from 'react'
|
||||||
|
|
||||||
|
const SettingsPanelsContent = lazy(() => import('./SettingsPanelsContent'))
|
||||||
|
|
||||||
|
const SettingsPanelsFallback = () => (
|
||||||
|
<div className="flex flex-1 items-center justify-center min-h-full">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const SettingsDialogContent = ({ onClose }: { onClose: () => void }) => {
|
||||||
|
return (
|
||||||
|
<SettingsDialog defaultValue="preferences" onClose={onClose}>
|
||||||
|
<SettingsTabs>
|
||||||
|
<SettingsTabGroup header={_("Settings")}>
|
||||||
|
<SettingsTabItem
|
||||||
|
icon={<SlidersVerticalIcon />}
|
||||||
|
label={_("Preferences")}
|
||||||
|
value="preferences"
|
||||||
|
/>
|
||||||
|
<SettingsTabItem
|
||||||
|
icon={<ZapIcon />}
|
||||||
|
label={_("Matching Rules")}
|
||||||
|
value="rules"
|
||||||
|
/>
|
||||||
|
<SettingsTabItem
|
||||||
|
icon={<KeyboardIcon />}
|
||||||
|
label={_("Keyboard Shortcuts")}
|
||||||
|
value="keyboard-shortcuts"
|
||||||
|
/>
|
||||||
|
</SettingsTabGroup>
|
||||||
|
</SettingsTabs>
|
||||||
|
|
||||||
|
<SettingsPanels>
|
||||||
|
<Suspense fallback={<SettingsPanelsFallback />}>
|
||||||
|
<SettingsPanelsContent />
|
||||||
|
</Suspense>
|
||||||
|
</SettingsPanels>
|
||||||
|
</SettingsDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsDialogContent
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { SettingsPanel } from '@/components/ui/settings-dialog'
|
||||||
|
import { Preferences } from './Preferences'
|
||||||
|
import MatchingRules from './MatchingRules'
|
||||||
|
import KeyboardShortcuts from './KeyboardShortcuts'
|
||||||
|
|
||||||
|
const SettingsPanelsContent = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SettingsPanel value="preferences">
|
||||||
|
<Preferences />
|
||||||
|
</SettingsPanel>
|
||||||
|
<SettingsPanel value="rules">
|
||||||
|
<MatchingRules />
|
||||||
|
</SettingsPanel>
|
||||||
|
<SettingsPanel value="bank-accounts" />
|
||||||
|
<SettingsPanel value="masters" />
|
||||||
|
<SettingsPanel value="keyboard-shortcuts">
|
||||||
|
<KeyboardShortcuts />
|
||||||
|
</SettingsPanel>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SettingsPanelsContent
|
||||||
@@ -170,7 +170,7 @@ function AlertDialogCancel({
|
|||||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
|
||||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
||||||
return (
|
return (
|
||||||
<Button variant={variant} size={size} asChild>
|
<Button variant={variant} size={size} theme={theme} asChild>
|
||||||
<AlertDialogPrimitive.Cancel
|
<AlertDialogPrimitive.Cancel
|
||||||
data-slot="alert-dialog-cancel"
|
data-slot="alert-dialog-cancel"
|
||||||
className={cn(className)}
|
className={cn(className)}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface ParsedErrorMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const parseHeading = (message?: ParsedErrorMessage) => {
|
const parseHeading = (message?: ParsedErrorMessage) => {
|
||||||
if (message?.title === 'Message' || message?.title === 'Error') return "There was an error."
|
if (message?.title === 'Message' || message?.title === 'Error') return _("There was an error.")
|
||||||
return message?.title
|
return message?.title
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
banking/src/components/ui/modal-content-fallback.tsx
Normal file
7
banking/src/components/ui/modal-content-fallback.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Loader2Icon } from 'lucide-react'
|
||||||
|
|
||||||
|
export const ModalContentFallback = () => (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
@@ -151,7 +151,7 @@ function SettingsTabItem({
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex-1 shrink-0 truncate text-sm duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
"flex-1 shrink-0 truncate text-sm leading-4 duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
||||||
icon && "ms-2"
|
icon && "ms-2"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
37
banking/src/hooks/useMultiFileUploadProgress.ts
Normal file
37
banking/src/hooks/useMultiFileUploadProgress.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react"
|
||||||
|
|
||||||
|
/** Tracks per-file upload progress (0–1) and exposes their average. */
|
||||||
|
export function useMultiFileUploadProgress() {
|
||||||
|
const [uploadProgress, setUploadProgress] = useState(0)
|
||||||
|
const fileProgressesRef = useRef<number[]>([])
|
||||||
|
|
||||||
|
const startTracking = useCallback((fileCount: number) => {
|
||||||
|
if (fileCount <= 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileProgressesRef.current = new Array(fileCount).fill(0)
|
||||||
|
setUploadProgress(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const updateFileProgress = useCallback((fileIndex: number, progress: number) => {
|
||||||
|
if (fileIndex < 0 || fileIndex >= fileProgressesRef.current.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileProgressesRef.current.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileProgressesRef.current[fileIndex] = progress
|
||||||
|
const total =
|
||||||
|
fileProgressesRef.current.reduce((sum, p) => sum + p, 0) /
|
||||||
|
fileProgressesRef.current.length
|
||||||
|
setUploadProgress(total)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const resetProgress = useCallback(() => {
|
||||||
|
fileProgressesRef.current = []
|
||||||
|
setUploadProgress(0)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { uploadProgress, startTracking, updateFileProgress, resetProgress }
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { in_list } from "./checks";
|
|
||||||
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
|
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
|
||||||
import { getSystemDefault } from "./frappe";
|
import { getSystemDefault } from "./frappe";
|
||||||
import _ from "@/lib/translate";
|
import _ from "@/lib/translate";
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
||||||
import BankClearanceSummary from "@/components/features/BankReconciliation/BankClearanceSummary"
|
|
||||||
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
||||||
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
|
import BankRecDateFilter from "@/components/features/BankReconciliation/BankRecDateFilter"
|
||||||
import BankReconciliationStatement from "@/components/features/BankReconciliation/BankReconciliationStatement"
|
|
||||||
import BankTransactions from "@/components/features/BankReconciliation/BankTransactionList"
|
|
||||||
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
|
import BankTransactionUnreconcileModal from "@/components/features/BankReconciliation/BankTransactionUnreconcileModal"
|
||||||
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
|
import CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
|
||||||
import IncorrectlyClearedEntries from "@/components/features/BankReconciliation/IncorrectlyClearedEntries"
|
|
||||||
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
|
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
|
||||||
import Settings from "@/components/features/Settings/Settings"
|
import Settings from "@/components/features/Settings/Settings"
|
||||||
import ActionLog from "@/components/features/ActionLog/ActionLog"
|
import ActionLog from "@/components/features/ActionLog/ActionLog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||||
import _ from "@/lib/translate"
|
import _ from "@/lib/translate"
|
||||||
import { useLayoutEffect, useRef, useState } from "react"
|
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
|
||||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||||
@@ -22,6 +18,10 @@ import { Button } from "@/components/ui/button"
|
|||||||
import { useAtomValue } from "jotai"
|
import { useAtomValue } from "jotai"
|
||||||
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
|
import { selectedBankAccountAtom } from "@/components/features/BankReconciliation/bankRecAtoms"
|
||||||
|
|
||||||
|
const BankReconciliationStatement = lazy(() => import('@/components/features/BankReconciliation/BankReconciliationStatement'))
|
||||||
|
const BankTransactions = lazy(() => import('@/components/features/BankReconciliation/BankTransactionList'))
|
||||||
|
const BankClearanceSummary = lazy(() => import('@/components/features/BankReconciliation/BankClearanceSummary'))
|
||||||
|
const IncorrectlyClearedEntries = lazy(() => import('@/components/features/BankReconciliation/IncorrectlyClearedEntries'))
|
||||||
|
|
||||||
const BankReconciliation = () => {
|
const BankReconciliation = () => {
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ const BankReconciliation = () => {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 270
|
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -122,6 +122,11 @@ const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: n
|
|||||||
<TabsContent value="Match and Reconcile">
|
<TabsContent value="Match and Reconcile">
|
||||||
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center p-16">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
<TabsContent value="Bank Reconciliation Statement">
|
<TabsContent value="Bank Reconciliation Statement">
|
||||||
<BankReconciliationStatement />
|
<BankReconciliationStatement />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -134,6 +139,7 @@ const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: n
|
|||||||
<TabsContent value="Incorrectly Cleared Entries">
|
<TabsContent value="Incorrectly Cleared Entries">
|
||||||
<IncorrectlyClearedEntries />
|
<IncorrectlyClearedEntries />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</Suspense>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { Suspense } from 'react'
|
||||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
|
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
|
||||||
import _ from '@/lib/translate'
|
import _ from '@/lib/translate'
|
||||||
import { HomeIcon } from 'lucide-react'
|
import { HomeIcon, Loader2Icon } from 'lucide-react'
|
||||||
import { Link, Outlet } from 'react-router'
|
import { Link, Outlet } from 'react-router'
|
||||||
|
|
||||||
const BankStatementImporterContainer = () => {
|
const BankStatementImporterContainer = () => {
|
||||||
@@ -29,7 +30,13 @@ const BankStatementImporterContainer = () => {
|
|||||||
</BreadcrumbList>
|
</BreadcrumbList>
|
||||||
</Breadcrumb>
|
</Breadcrumb>
|
||||||
</div>
|
</div>
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex flex-1 items-center justify-center p-16">
|
||||||
|
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import CSVImport from '@/components/features/BankStatementImporter/CSV/CSVImport'
|
import { lazy } from 'react'
|
||||||
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
|
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { useDirection } from '@/components/ui/direction'
|
import { useDirection } from '@/components/ui/direction'
|
||||||
@@ -8,6 +8,8 @@ import { useFrappeDocumentEventListener } from 'frappe-react-sdk'
|
|||||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||||
import { Link, useParams } from 'react-router'
|
import { Link, useParams } from 'react-router'
|
||||||
|
|
||||||
|
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
|
||||||
|
|
||||||
const ViewBankStatementImportLog = () => {
|
const ViewBankStatementImportLog = () => {
|
||||||
|
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export interface BankStatementImportLog{
|
|||||||
/** Detected Date Format : Data */
|
/** Detected Date Format : Data */
|
||||||
detected_date_format?: string
|
detected_date_format?: string
|
||||||
/** Detected Amount Format : Select */
|
/** Detected Amount Format : Select */
|
||||||
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has "CR"/"DR" values" | "Amount column has positive/negative values" | "Transaction type column has "CR"/"DR" values" | "Transaction type column has "Deposit"/"Withdrawal" values" | "Transaction type column has "C"/"D" values"
|
detected_amount_format?: "Separate columns for withdrawal and deposit" | "Amount column has \"CR\"/\"DR\" values" | "Amount column has positive/negative values" | "Transaction type column has \"CR\"/\"DR\" values" | "Transaction type column has \"Deposit\"/\"Withdrawal\" values" | "Transaction type column has \"C\"/\"D\" values"
|
||||||
/** Detected Header Index : Int */
|
/** Detected Header Index : Int */
|
||||||
detected_header_index?: number
|
detected_header_index?: number
|
||||||
/** Detected Transaction Starting Index : Int */
|
/** Detected Transaction Starting Index : Int */
|
||||||
|
|||||||
@@ -21,5 +21,35 @@ export default defineConfig({
|
|||||||
outDir: '../erpnext/public/banking',
|
outDir: '../erpnext/public/banking',
|
||||||
emptyOutDir: true,
|
emptyOutDir: true,
|
||||||
target: 'es2015',
|
target: 'es2015',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks(id) {
|
||||||
|
if (!id.includes('node_modules')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (id.includes('react-dom') || id.includes('/react/')) {
|
||||||
|
return 'vendor-react'
|
||||||
|
}
|
||||||
|
if (id.includes('frappe-react-sdk')) {
|
||||||
|
return 'vendor-frappe'
|
||||||
|
}
|
||||||
|
if (id.includes('@tanstack')) {
|
||||||
|
return 'vendor-tanstack'
|
||||||
|
}
|
||||||
|
if (id.includes('fuse.js')) {
|
||||||
|
return 'vendor-fuse'
|
||||||
|
}
|
||||||
|
if (id.includes('radix-ui') || id.includes('@radix-ui')) {
|
||||||
|
return 'vendor-radix'
|
||||||
|
}
|
||||||
|
if (id.includes('jotai')) {
|
||||||
|
return 'vendor-jotai'
|
||||||
|
}
|
||||||
|
if (id.includes('lucide-react')) {
|
||||||
|
return 'vendor-lucide'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3333,11 +3333,6 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
|||||||
get-nonce "^1.0.0"
|
get-nonce "^1.0.0"
|
||||||
tslib "^2.0.0"
|
tslib "^2.0.0"
|
||||||
|
|
||||||
react-virtuoso@^4.18.6:
|
|
||||||
version "4.18.6"
|
|
||||||
resolved "https://registry.yarnpkg.com/react-virtuoso/-/react-virtuoso-4.18.6.tgz#953637adf805d562892270aafdeeedb0bda1881b"
|
|
||||||
integrity sha512-CrT3P6HyjJMHZVWSste2bG2q5aWGlHfW2QuySZjiFwB2Qok/xsvgy+k8Z2jeDP8PP5KsBip7zNrl/F0QoxeyKw==
|
|
||||||
|
|
||||||
react@^19.2.6:
|
react@^19.2.6:
|
||||||
version "19.2.6"
|
version "19.2.6"
|
||||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d"
|
resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d"
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
{
|
|
||||||
"custom_fields": [
|
|
||||||
{
|
|
||||||
"_assign": null,
|
|
||||||
"_comments": null,
|
|
||||||
"_liked_by": null,
|
|
||||||
"_user_tags": null,
|
|
||||||
"allow_in_quick_entry": 0,
|
|
||||||
"allow_on_submit": 0,
|
|
||||||
"bold": 0,
|
|
||||||
"collapsible": 0,
|
|
||||||
"collapsible_depends_on": null,
|
|
||||||
"columns": 0,
|
|
||||||
"creation": "2018-12-28 22:29:21.828090",
|
|
||||||
"default": null,
|
|
||||||
"depends_on": null,
|
|
||||||
"description": null,
|
|
||||||
"docstatus": 0,
|
|
||||||
"dt": "Address",
|
|
||||||
"fetch_from": null,
|
|
||||||
"fetch_if_empty": 0,
|
|
||||||
"fieldname": "tax_category",
|
|
||||||
"fieldtype": "Link",
|
|
||||||
"hidden": 0,
|
|
||||||
"hide_border": 0,
|
|
||||||
"hide_days": 0,
|
|
||||||
"hide_seconds": 0,
|
|
||||||
"idx": 15,
|
|
||||||
"ignore_user_permissions": 0,
|
|
||||||
"ignore_xss_filter": 0,
|
|
||||||
"in_global_search": 0,
|
|
||||||
"in_list_view": 0,
|
|
||||||
"in_preview": 0,
|
|
||||||
"in_standard_filter": 0,
|
|
||||||
"insert_after": "fax",
|
|
||||||
"label": "Tax Category",
|
|
||||||
"length": 0,
|
|
||||||
"mandatory_depends_on": null,
|
|
||||||
"modified": "2018-12-28 22:29:21.828090",
|
|
||||||
"modified_by": "Administrator",
|
|
||||||
"name": "Address-tax_category",
|
|
||||||
"no_copy": 0,
|
|
||||||
"options": "Tax Category",
|
|
||||||
"owner": "Administrator",
|
|
||||||
"parent": null,
|
|
||||||
"parentfield": null,
|
|
||||||
"parenttype": null,
|
|
||||||
"permlevel": 0,
|
|
||||||
"precision": "",
|
|
||||||
"print_hide": 0,
|
|
||||||
"print_hide_if_no_value": 0,
|
|
||||||
"print_width": null,
|
|
||||||
"read_only": 0,
|
|
||||||
"read_only_depends_on": null,
|
|
||||||
"report_hide": 0,
|
|
||||||
"reqd": 0,
|
|
||||||
"search_index": 0,
|
|
||||||
"translatable": 0,
|
|
||||||
"unique": 0,
|
|
||||||
"width": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"_assign": null,
|
|
||||||
"_comments": null,
|
|
||||||
"_liked_by": null,
|
|
||||||
"_user_tags": null,
|
|
||||||
"allow_in_quick_entry": 0,
|
|
||||||
"allow_on_submit": 0,
|
|
||||||
"bold": 0,
|
|
||||||
"collapsible": 0,
|
|
||||||
"collapsible_depends_on": null,
|
|
||||||
"columns": 0,
|
|
||||||
"creation": "2020-10-14 17:41:40.878179",
|
|
||||||
"default": "0",
|
|
||||||
"depends_on": null,
|
|
||||||
"description": null,
|
|
||||||
"docstatus": 0,
|
|
||||||
"dt": "Address",
|
|
||||||
"fetch_from": null,
|
|
||||||
"fetch_if_empty": 0,
|
|
||||||
"fieldname": "is_your_company_address",
|
|
||||||
"fieldtype": "Check",
|
|
||||||
"hidden": 0,
|
|
||||||
"hide_border": 0,
|
|
||||||
"hide_days": 0,
|
|
||||||
"hide_seconds": 0,
|
|
||||||
"idx": 20,
|
|
||||||
"ignore_user_permissions": 0,
|
|
||||||
"ignore_xss_filter": 0,
|
|
||||||
"in_global_search": 0,
|
|
||||||
"in_list_view": 0,
|
|
||||||
"in_preview": 0,
|
|
||||||
"in_standard_filter": 0,
|
|
||||||
"insert_after": "linked_with",
|
|
||||||
"label": "Is Your Company Address",
|
|
||||||
"length": 0,
|
|
||||||
"mandatory_depends_on": null,
|
|
||||||
"modified": "2020-10-14 17:41:40.878179",
|
|
||||||
"modified_by": "Administrator",
|
|
||||||
"name": "Address-is_your_company_address",
|
|
||||||
"no_copy": 0,
|
|
||||||
"options": null,
|
|
||||||
"owner": "Administrator",
|
|
||||||
"parent": null,
|
|
||||||
"parentfield": null,
|
|
||||||
"parenttype": null,
|
|
||||||
"permlevel": 0,
|
|
||||||
"precision": "",
|
|
||||||
"print_hide": 0,
|
|
||||||
"print_hide_if_no_value": 0,
|
|
||||||
"print_width": null,
|
|
||||||
"read_only": 0,
|
|
||||||
"read_only_depends_on": null,
|
|
||||||
"report_hide": 0,
|
|
||||||
"reqd": 0,
|
|
||||||
"search_index": 0,
|
|
||||||
"translatable": 0,
|
|
||||||
"unique": 0,
|
|
||||||
"width": null
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"custom_perms": [],
|
|
||||||
"doctype": "Address",
|
|
||||||
"property_setters": [],
|
|
||||||
"sync_on_migrate": 1
|
|
||||||
}
|
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"label": "Account Type",
|
"label": "Account Type",
|
||||||
"oldfieldname": "account_type",
|
"oldfieldname": "account_type",
|
||||||
"oldfieldtype": "Select",
|
"oldfieldtype": "Select",
|
||||||
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nService Received But Not Billed\nTax\nTemporary",
|
"options": "\nAccumulated Depreciation\nAsset Received But Not Billed\nBank\nCash\nChargeable\nCapital Work in Progress\nCost of Goods Sold\nCurrent Asset\nCurrent Liability\nDepreciation\nDirect Expense\nDirect Income\nEquity\nExpense Account\nExpenses Included In Asset Valuation\nExpenses Included In Valuation\nFixed Asset\nIncome Account\nIndirect Expense\nIndirect Income\nLiability\nPayable\nReceivable\nRound Off\nRound Off for Opening\nStock\nStock Adjustment\nStock Received But Not Billed\nStock Delivered But Not Billed\nService Received But Not Billed\nTax\nTemporary",
|
||||||
"search_index": 1
|
"search_index": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class Account(NestedSet):
|
|||||||
"Stock",
|
"Stock",
|
||||||
"Stock Adjustment",
|
"Stock Adjustment",
|
||||||
"Stock Received But Not Billed",
|
"Stock Received But Not Billed",
|
||||||
|
"Stock Delivered But Not Billed",
|
||||||
"Service Received But Not Billed",
|
"Service Received But Not Billed",
|
||||||
"Tax",
|
"Tax",
|
||||||
"Temporary",
|
"Temporary",
|
||||||
@@ -174,16 +175,19 @@ class Account(NestedSet):
|
|||||||
if cint(self.is_group):
|
if cint(self.is_group):
|
||||||
db_value = self.get_doc_before_save()
|
db_value = self.get_doc_before_save()
|
||||||
if db_value:
|
if db_value:
|
||||||
|
Account = frappe.qb.DocType("Account")
|
||||||
|
query = frappe.qb.update(Account).where((Account.lft > self.lft) & (Account.rgt < self.rgt))
|
||||||
|
|
||||||
|
updated = False
|
||||||
if self.report_type != db_value.report_type:
|
if self.report_type != db_value.report_type:
|
||||||
frappe.db.sql(
|
query = query.set(Account.report_type, self.report_type)
|
||||||
"update `tabAccount` set report_type=%s where lft > %s and rgt < %s",
|
updated = True
|
||||||
(self.report_type, self.lft, self.rgt),
|
|
||||||
)
|
|
||||||
if self.root_type != db_value.root_type:
|
if self.root_type != db_value.root_type:
|
||||||
frappe.db.sql(
|
query = query.set(Account.root_type, self.root_type)
|
||||||
"update `tabAccount` set root_type=%s where lft > %s and rgt < %s",
|
updated = True
|
||||||
(self.root_type, self.lft, self.rgt),
|
|
||||||
)
|
if updated:
|
||||||
|
query.run()
|
||||||
|
|
||||||
if self.root_type and not self.report_type:
|
if self.root_type and not self.report_type:
|
||||||
self.report_type = (
|
self.report_type = (
|
||||||
@@ -448,11 +452,7 @@ class Account(NestedSet):
|
|||||||
return frappe.db.get_value("GL Entry", {"account": self.name})
|
return frappe.db.get_value("GL Entry", {"account": self.name})
|
||||||
|
|
||||||
def check_if_child_exists(self):
|
def check_if_child_exists(self):
|
||||||
return frappe.db.sql(
|
return frappe.db.exists("Account", {"parent_account": self.name, "docstatus": ["!=", 2]})
|
||||||
"""select name from `tabAccount` where parent_account = %s
|
|
||||||
and docstatus != 2""",
|
|
||||||
self.name,
|
|
||||||
)
|
|
||||||
|
|
||||||
def validate_mandatory(self):
|
def validate_mandatory(self):
|
||||||
if not self.root_type:
|
if not self.root_type:
|
||||||
@@ -472,14 +472,24 @@ class Account(NestedSet):
|
|||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
@frappe.validate_and_sanitize_search_inputs
|
@frappe.validate_and_sanitize_search_inputs
|
||||||
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||||
return frappe.db.sql(
|
Account = frappe.qb.DocType("Account")
|
||||||
"""select name from tabAccount
|
|
||||||
where is_group = 1 and docstatus != 2 and company = {}
|
search_field_obj = getattr(Account, searchfield)
|
||||||
and {} like {} order by name limit {} offset {}""".format("%s", searchfield, "%s", "%s", "%s"),
|
|
||||||
(filters["company"], "%%%s%%" % txt, page_len, start),
|
query = (
|
||||||
as_list=1,
|
frappe.qb.from_(Account)
|
||||||
|
.select(Account.name)
|
||||||
|
.where(Account.is_group == 1)
|
||||||
|
.where(Account.docstatus != 2)
|
||||||
|
.where(Account.company == filters["company"])
|
||||||
|
.where(search_field_obj.like(f"%{txt}%"))
|
||||||
|
.order_by(Account.name)
|
||||||
|
.limit(page_len)
|
||||||
|
.offset(start)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return query.run(as_list=1)
|
||||||
|
|
||||||
|
|
||||||
def get_account_currency(account):
|
def get_account_currency(account):
|
||||||
"""Helper function to get account currency"""
|
"""Helper function to get account currency"""
|
||||||
@@ -520,6 +530,7 @@ def update_account_number(
|
|||||||
):
|
):
|
||||||
_ensure_idle_system()
|
_ensure_idle_system()
|
||||||
account = frappe.get_cached_doc("Account", name)
|
account = frappe.get_cached_doc("Account", name)
|
||||||
|
account.check_permission("write")
|
||||||
if not account:
|
if not account:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -673,6 +684,7 @@ def get_company_default_account_fields():
|
|||||||
"default_expense_account": "Default Expense Account",
|
"default_expense_account": "Default Expense Account",
|
||||||
"default_income_account": "Default Income Account",
|
"default_income_account": "Default Income Account",
|
||||||
"stock_received_but_not_billed": "Stock Received But Not Billed Account",
|
"stock_received_but_not_billed": "Stock Received But Not Billed Account",
|
||||||
|
"stock_delivered_but_not_billed": "Stock Delivered But Not Billed Account",
|
||||||
"stock_adjustment_account": "Stock Adjustment Account",
|
"stock_adjustment_account": "Stock Adjustment Account",
|
||||||
"write_off_account": "Write Off Account",
|
"write_off_account": "Write Off Account",
|
||||||
"default_discount_account": "Default Payment Discount Account",
|
"default_discount_account": "Default Payment Discount Account",
|
||||||
|
|||||||
@@ -378,6 +378,9 @@
|
|||||||
"Passifs de stock": {
|
"Passifs de stock": {
|
||||||
"Stock re\u00e7u non factur\u00e9": {
|
"Stock re\u00e7u non factur\u00e9": {
|
||||||
"account_type": "Stock Received But Not Billed"
|
"account_type": "Stock Received But Not Billed"
|
||||||
|
},
|
||||||
|
"Stock livr\u00e9 non factur\u00e9": {
|
||||||
|
"account_type": "Stock Delivered But Not Billed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Provision pour vacances et cong\u00e9s": {},
|
"Provision pour vacances et cong\u00e9s": {},
|
||||||
|
|||||||
@@ -221,6 +221,10 @@
|
|||||||
"account_number": "1702",
|
"account_number": "1702",
|
||||||
"account_type": "Stock Received But Not Billed"
|
"account_type": "Stock Received But Not Billed"
|
||||||
},
|
},
|
||||||
|
"Warenausgangs-Verrechnungskonto": {
|
||||||
|
"account_number": "1703",
|
||||||
|
"account_type": "Stock Delivered But Not Billed"
|
||||||
|
},
|
||||||
"Verbindlichkeiten aus Lohn und Gehalt": {
|
"Verbindlichkeiten aus Lohn und Gehalt": {
|
||||||
"account_number": "1740",
|
"account_number": "1740",
|
||||||
"account_type": "Payable"
|
"account_type": "Payable"
|
||||||
|
|||||||
@@ -1144,6 +1144,10 @@
|
|||||||
"Wareneingangs-Verrechnungskonto" : {
|
"Wareneingangs-Verrechnungskonto" : {
|
||||||
"account_number": "70001",
|
"account_number": "70001",
|
||||||
"account_type": "Stock Received But Not Billed"
|
"account_type": "Stock Received But Not Billed"
|
||||||
|
},
|
||||||
|
"Warenausgangs-Verrechnungskonto" : {
|
||||||
|
"account_number": "70002",
|
||||||
|
"account_type": "Stock Delivered But Not Billed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Verb. aus Lieferungen und Leistungen": {
|
"Verb. aus Lieferungen und Leistungen": {
|
||||||
|
|||||||
@@ -1076,6 +1076,9 @@
|
|||||||
"account_type": "Stock Received But Not Billed",
|
"account_type": "Stock Received But Not Billed",
|
||||||
"account_number": "4088"
|
"account_number": "4088"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Stock livr\u00e9 non factur\u00e9": {
|
||||||
|
"account_type": "Stock Delivered But Not Billed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
||||||
|
|||||||
@@ -1589,6 +1589,9 @@
|
|||||||
"account_type": "Stock Received But Not Billed",
|
"account_type": "Stock Received But Not Billed",
|
||||||
"account_number": "4088"
|
"account_number": "4088"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Stock livr\u00e9 non factur\u00e9": {
|
||||||
|
"account_type": "Stock Delivered But Not Billed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
||||||
|
|||||||
@@ -1592,6 +1592,9 @@
|
|||||||
"account_number": "4088"
|
"account_number": "4088"
|
||||||
},
|
},
|
||||||
"account_number": "408"
|
"account_number": "408"
|
||||||
|
},
|
||||||
|
"Stock livr\u00e9 non factur\u00e9": {
|
||||||
|
"account_type": "Stock Delivered But Not Billed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
||||||
|
|||||||
@@ -805,6 +805,9 @@
|
|||||||
},
|
},
|
||||||
"account_type": "Stock Received But Not Billed"
|
"account_type": "Stock Received But Not Billed"
|
||||||
},
|
},
|
||||||
|
"Stock livr\u00e9 non factur\u00e9": {
|
||||||
|
"account_type": "Stock Delivered But Not Billed"
|
||||||
|
},
|
||||||
"account_type": "Payable"
|
"account_type": "Payable"
|
||||||
},
|
},
|
||||||
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
||||||
|
|||||||
@@ -1520,6 +1520,9 @@
|
|||||||
"account_number": "4088"
|
"account_number": "4088"
|
||||||
},
|
},
|
||||||
"account_number": "408"
|
"account_number": "408"
|
||||||
|
},
|
||||||
|
"Stock livr\u00e9 non factur\u00e9": {
|
||||||
|
"account_type": "Stock Delivered But Not Billed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
"41-Clients et comptes rattach\u00e9s (PASSIF)": {
|
||||||
|
|||||||
@@ -223,6 +223,10 @@
|
|||||||
"Stock Received But Not Billed": {
|
"Stock Received But Not Billed": {
|
||||||
"account_type": "Stock Received But Not Billed",
|
"account_type": "Stock Received But Not Billed",
|
||||||
"account_category": "Trade Payables"
|
"account_category": "Trade Payables"
|
||||||
|
},
|
||||||
|
"Stock Delivered But Not Billed": {
|
||||||
|
"account_type": "Stock Delivered But Not Billed",
|
||||||
|
"account_category": "Trade Payables"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Duties and Taxes": {
|
"Duties and Taxes": {
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
{
|
||||||
|
"country_code": "nz",
|
||||||
|
"name": "New Zealand - Chart of Accounts with Account Numbers",
|
||||||
|
"disabled": "No",
|
||||||
|
"tree": {
|
||||||
|
"Application of Funds (Assets)": {
|
||||||
|
"Current Assets": {
|
||||||
|
"Bank Accounts": {
|
||||||
|
"Business Transaction Account": {
|
||||||
|
"account_number": "11011",
|
||||||
|
"account_type": "Bank"
|
||||||
|
},
|
||||||
|
"Business Savings Account": {
|
||||||
|
"account_number": "11012",
|
||||||
|
"account_type": "Bank"
|
||||||
|
},
|
||||||
|
"account_number": "11010",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Cash on Hand": {
|
||||||
|
"account_number": "11020",
|
||||||
|
"account_type": "Cash"
|
||||||
|
},
|
||||||
|
"Accounts Receivable": {
|
||||||
|
"Debtors": {
|
||||||
|
"account_number": "11210",
|
||||||
|
"account_type": "Receivable"
|
||||||
|
},
|
||||||
|
"Provision for Doubtful Debts": {
|
||||||
|
"account_number": "11220"
|
||||||
|
},
|
||||||
|
"account_number": "11200",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Inventory": {
|
||||||
|
"Stock on Hand": {
|
||||||
|
"account_number": "11311",
|
||||||
|
"account_type": "Stock"
|
||||||
|
},
|
||||||
|
"Work In Progress": {
|
||||||
|
"account_number": "11312",
|
||||||
|
"account_type": "Stock"
|
||||||
|
},
|
||||||
|
"account_number": "11310",
|
||||||
|
"account_type": "Stock",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Prepayments": {
|
||||||
|
"Prepayments": {
|
||||||
|
"account_number": "11411"
|
||||||
|
},
|
||||||
|
"Supplier Advances": {
|
||||||
|
"account_number": "11412"
|
||||||
|
},
|
||||||
|
"Deferred Expense": {
|
||||||
|
"account_number": "11413"
|
||||||
|
},
|
||||||
|
"account_number": "11410",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"GST Receivable": {
|
||||||
|
"account_number": "11510",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"Income Tax Receivable": {
|
||||||
|
"account_number": "11520",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"account_number": "11000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Fixed Assets": {
|
||||||
|
"Plant & Equipment": {
|
||||||
|
"Plant & Equipment": {
|
||||||
|
"account_number": "16011",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Plant & Equipment": {
|
||||||
|
"account_number": "16012",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16010",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Motor Vehicles": {
|
||||||
|
"Motor Vehicles": {
|
||||||
|
"account_number": "16021",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Motor Vehicles": {
|
||||||
|
"account_number": "16022",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16020",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Office Equipment": {
|
||||||
|
"Office Equipment": {
|
||||||
|
"account_number": "16031",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Office Equipment": {
|
||||||
|
"account_number": "16032",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16030",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Buildings": {
|
||||||
|
"Buildings": {
|
||||||
|
"account_number": "16041",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Buildings": {
|
||||||
|
"account_number": "16042",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16040",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Computer Equipment": {
|
||||||
|
"Computer Equipment": {
|
||||||
|
"account_number": "16051",
|
||||||
|
"account_type": "Fixed Asset"
|
||||||
|
},
|
||||||
|
"Accumulated Depreciation - Computer Equipment": {
|
||||||
|
"account_number": "16052",
|
||||||
|
"account_type": "Accumulated Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "16050",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Capital Work in Progress": {
|
||||||
|
"account_number": "16090",
|
||||||
|
"account_type": "Capital Work in Progress"
|
||||||
|
},
|
||||||
|
"account_number": "16000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "10000",
|
||||||
|
"root_type": "Asset"
|
||||||
|
},
|
||||||
|
"Source of Funds (Liabilities)": {
|
||||||
|
"Current Liabilities": {
|
||||||
|
"Accounts Payable": {
|
||||||
|
"Creditors": {
|
||||||
|
"account_number": "21010",
|
||||||
|
"account_type": "Payable"
|
||||||
|
},
|
||||||
|
"account_number": "21000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Goods Received Not Invoiced": {
|
||||||
|
"account_number": "21100",
|
||||||
|
"account_type": "Stock Received But Not Billed"
|
||||||
|
},
|
||||||
|
"Asset Received Not Invoiced": {
|
||||||
|
"account_number": "21110",
|
||||||
|
"account_type": "Asset Received But Not Billed"
|
||||||
|
},
|
||||||
|
"Service Received Not Invoiced": {
|
||||||
|
"account_number": "21120",
|
||||||
|
"account_type": "Service Received But Not Billed"
|
||||||
|
},
|
||||||
|
"Accrued Expenses": {
|
||||||
|
"account_number": "21200"
|
||||||
|
},
|
||||||
|
"Wages Payable": {
|
||||||
|
"account_number": "21300"
|
||||||
|
},
|
||||||
|
"PAYE Payable": {
|
||||||
|
"account_number": "22010"
|
||||||
|
},
|
||||||
|
"KiwiSaver Payable": {
|
||||||
|
"account_number": "22020"
|
||||||
|
},
|
||||||
|
"ACC Payable": {
|
||||||
|
"account_number": "22030"
|
||||||
|
},
|
||||||
|
"Credit Cards": {
|
||||||
|
"Business Credit Card": {
|
||||||
|
"account_number": "22110"
|
||||||
|
},
|
||||||
|
"account_number": "22100",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Customer Advances": {
|
||||||
|
"account_number": "22200"
|
||||||
|
},
|
||||||
|
"Deferred Revenue": {
|
||||||
|
"account_number": "22210"
|
||||||
|
},
|
||||||
|
"Provisional Account": {
|
||||||
|
"account_number": "22220"
|
||||||
|
},
|
||||||
|
"Tax Liabilities": {
|
||||||
|
"GST Payable": {
|
||||||
|
"account_number": "22310",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"GST Suspense": {
|
||||||
|
"account_number": "22320",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"FBT Payable": {
|
||||||
|
"account_number": "22330",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"Income Tax Payable": {
|
||||||
|
"account_number": "22340",
|
||||||
|
"account_type": "Tax"
|
||||||
|
},
|
||||||
|
"account_number": "22300",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "21500",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Non-Current Liabilities": {
|
||||||
|
"Bank Loans": {
|
||||||
|
"Bank Loan": {
|
||||||
|
"account_number": "25011"
|
||||||
|
},
|
||||||
|
"account_number": "25010",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Lease Liabilities": {
|
||||||
|
"Lease Liability": {
|
||||||
|
"account_number": "25021"
|
||||||
|
},
|
||||||
|
"account_number": "25020",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Shareholder Loans": {
|
||||||
|
"Shareholder Loan": {
|
||||||
|
"account_number": "25031"
|
||||||
|
},
|
||||||
|
"account_number": "25030",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "25000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "20000",
|
||||||
|
"root_type": "Liability"
|
||||||
|
},
|
||||||
|
"Equity": {
|
||||||
|
"Share Capital": {
|
||||||
|
"account_number": "31010",
|
||||||
|
"account_type": "Equity"
|
||||||
|
},
|
||||||
|
"Drawings": {
|
||||||
|
"account_number": "31020",
|
||||||
|
"account_type": "Equity"
|
||||||
|
},
|
||||||
|
"Current Year Earnings": {
|
||||||
|
"account_number": "35010",
|
||||||
|
"account_type": "Equity"
|
||||||
|
},
|
||||||
|
"Retained Earnings": {
|
||||||
|
"account_number": "35020",
|
||||||
|
"account_type": "Equity"
|
||||||
|
},
|
||||||
|
"account_number": "30000",
|
||||||
|
"root_type": "Equity"
|
||||||
|
},
|
||||||
|
"Income": {
|
||||||
|
"Sales": {
|
||||||
|
"account_number": "41010",
|
||||||
|
"account_type": "Income Account"
|
||||||
|
},
|
||||||
|
"Other Income": {
|
||||||
|
"Interest Income": {
|
||||||
|
"account_number": "47010",
|
||||||
|
"account_type": "Income Account"
|
||||||
|
},
|
||||||
|
"Rounding Gain/Loss": {
|
||||||
|
"account_number": "47020",
|
||||||
|
"account_type": "Income Account"
|
||||||
|
},
|
||||||
|
"Foreign Exchange Gain": {
|
||||||
|
"account_number": "47030",
|
||||||
|
"account_type": "Income Account"
|
||||||
|
},
|
||||||
|
"account_number": "47000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"account_number": "40000",
|
||||||
|
"root_type": "Income"
|
||||||
|
},
|
||||||
|
"Expenses": {
|
||||||
|
"Cost of Goods Sold": {
|
||||||
|
"Purchases": {
|
||||||
|
"account_number": "51010",
|
||||||
|
"account_type": "Cost of Goods Sold"
|
||||||
|
},
|
||||||
|
"Freight Inwards": {
|
||||||
|
"account_number": "51020",
|
||||||
|
"account_type": "Expenses Included In Valuation"
|
||||||
|
},
|
||||||
|
"Duty and Landing Costs": {
|
||||||
|
"account_number": "51030",
|
||||||
|
"account_type": "Expenses Included In Valuation"
|
||||||
|
},
|
||||||
|
"Stock Adjustment": {
|
||||||
|
"account_number": "51040",
|
||||||
|
"account_type": "Stock Adjustment"
|
||||||
|
},
|
||||||
|
"Stock Write Off": {
|
||||||
|
"account_number": "51050",
|
||||||
|
"account_type": "Stock Adjustment"
|
||||||
|
},
|
||||||
|
"account_number": "51000",
|
||||||
|
"account_type": "Cost of Goods Sold",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Operating Expenses": {
|
||||||
|
"Wages & Salaries": {
|
||||||
|
"account_number": "61010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"KiwiSaver Employer Contribution": {
|
||||||
|
"account_number": "61020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"ACC Levies": {
|
||||||
|
"account_number": "61030",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Rent": {
|
||||||
|
"account_number": "65010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Power": {
|
||||||
|
"account_number": "65020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Telephone": {
|
||||||
|
"account_number": "66010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Insurance": {
|
||||||
|
"account_number": "64010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Accounting Fees": {
|
||||||
|
"account_number": "64020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Legal Fees": {
|
||||||
|
"account_number": "64030",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Advertising and Marketing": {
|
||||||
|
"account_number": "65030",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Repairs and Maintenance": {
|
||||||
|
"account_number": "65040",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Freight and Courier": {
|
||||||
|
"account_number": "65050",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Operating Costs": {
|
||||||
|
"account_number": "65060",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"account_number": "60000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Depreciation and Amortisation": {
|
||||||
|
"Depreciation - Plant & Equipment": {
|
||||||
|
"account_number": "62010",
|
||||||
|
"account_type": "Depreciation"
|
||||||
|
},
|
||||||
|
"Depreciation - Motor Vehicles": {
|
||||||
|
"account_number": "62020",
|
||||||
|
"account_type": "Depreciation"
|
||||||
|
},
|
||||||
|
"Depreciation - Office Equipment": {
|
||||||
|
"account_number": "62030",
|
||||||
|
"account_type": "Depreciation"
|
||||||
|
},
|
||||||
|
"Depreciation - Computer Equipment": {
|
||||||
|
"account_number": "62040",
|
||||||
|
"account_type": "Depreciation"
|
||||||
|
},
|
||||||
|
"account_number": "62000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Finance Costs": {
|
||||||
|
"Bank Charges": {
|
||||||
|
"account_number": "67010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Interest Expense": {
|
||||||
|
"account_number": "67020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Rounding Off": {
|
||||||
|
"account_number": "67030",
|
||||||
|
"account_type": "Round Off"
|
||||||
|
},
|
||||||
|
"Payment Discounts": {
|
||||||
|
"account_number": "67040",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"account_number": "67000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Income Tax Expense": {
|
||||||
|
"account_number": "81010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Foreign Exchange": {
|
||||||
|
"Exchange Gain/Loss": {
|
||||||
|
"account_number": "82010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Unrealized Exchange Gain/Loss": {
|
||||||
|
"account_number": "82020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"account_number": "82000",
|
||||||
|
"is_group": 1
|
||||||
|
},
|
||||||
|
"Bad Debts": {
|
||||||
|
"account_number": "83010",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Write Off": {
|
||||||
|
"account_number": "83020",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Gain/Loss on Asset Disposal": {
|
||||||
|
"account_number": "83030",
|
||||||
|
"account_type": "Expense Account"
|
||||||
|
},
|
||||||
|
"Expenses Included In Asset Valuation": {
|
||||||
|
"account_number": "84010",
|
||||||
|
"account_type": "Expenses Included In Asset Valuation"
|
||||||
|
},
|
||||||
|
"account_number": "50000",
|
||||||
|
"root_type": "Expense"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -570,6 +570,17 @@
|
|||||||
"account_number": "5000",
|
"account_number": "5000",
|
||||||
"is_group": 1,
|
"is_group": 1,
|
||||||
"root_type": "Expense",
|
"root_type": "Expense",
|
||||||
|
"Cost of Goods Sold": {
|
||||||
|
"account_number": "5001",
|
||||||
|
"is_group": 1,
|
||||||
|
"root_type": "Expense",
|
||||||
|
"Cost of Goods Sold": {
|
||||||
|
"account_number": "5010",
|
||||||
|
"is_group": 0,
|
||||||
|
"root_type": "Expense",
|
||||||
|
"account_type": "Cost of Goods Sold"
|
||||||
|
}
|
||||||
|
},
|
||||||
"Operating Expenses": {
|
"Operating Expenses": {
|
||||||
"account_number": "5100",
|
"account_number": "5100",
|
||||||
"is_group": 1,
|
"is_group": 1,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ def get():
|
|||||||
_("Short-term Investments"): {"account_category": "Short-term Investments"},
|
_("Short-term Investments"): {"account_category": "Short-term Investments"},
|
||||||
_("Stock Assets"): {
|
_("Stock Assets"): {
|
||||||
_("Stock In Hand"): {"account_type": "Stock", "account_category": "Stock Assets"},
|
_("Stock In Hand"): {"account_type": "Stock", "account_category": "Stock Assets"},
|
||||||
|
_("Stock Delivered But Not Billed"): {
|
||||||
|
"account_type": "Stock Delivered But Not Billed",
|
||||||
|
"account_category": "Stock Assets",
|
||||||
|
},
|
||||||
"account_type": "Stock",
|
"account_type": "Stock",
|
||||||
"account_category": "Stock Assets",
|
"account_category": "Stock Assets",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ def get():
|
|||||||
"account_number": "1410",
|
"account_number": "1410",
|
||||||
"account_category": "Stock Assets",
|
"account_category": "Stock Assets",
|
||||||
},
|
},
|
||||||
|
_("Stock Delivered But Not Billed"): {
|
||||||
|
"account_type": "Stock Delivered But Not Billed",
|
||||||
|
"account_number": "1420",
|
||||||
|
"account_category": "Stock Assets",
|
||||||
|
},
|
||||||
"account_type": "Stock",
|
"account_type": "Stock",
|
||||||
"account_number": "1400",
|
"account_number": "1400",
|
||||||
"account_category": "Stock Assets",
|
"account_category": "Stock Assets",
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class AccountingDimension(Document):
|
|||||||
def validate(self):
|
def validate(self):
|
||||||
self.validate_doctype()
|
self.validate_doctype()
|
||||||
validate_column_name(self.fieldname)
|
validate_column_name(self.fieldname)
|
||||||
|
self.validate_fieldname_conflict()
|
||||||
self.validate_dimension_defaults()
|
self.validate_dimension_defaults()
|
||||||
|
|
||||||
def validate_doctype(self):
|
def validate_doctype(self):
|
||||||
@@ -74,6 +75,27 @@ class AccountingDimension(Document):
|
|||||||
message += _("Please create a new Accounting Dimension if required.")
|
message += _("Please create a new Accounting Dimension if required.")
|
||||||
frappe.throw(message)
|
frappe.throw(message)
|
||||||
|
|
||||||
|
def validate_fieldname_conflict(self):
|
||||||
|
conflicting_doctypes = []
|
||||||
|
for doctype in get_doctypes_with_dimensions():
|
||||||
|
meta = frappe.get_meta(doctype, cached=False)
|
||||||
|
if any(f.fieldname == self.fieldname for f in meta.get("fields")):
|
||||||
|
conflicting_doctypes.append(doctype)
|
||||||
|
|
||||||
|
if conflicting_doctypes:
|
||||||
|
frappe.msgprint(
|
||||||
|
_(
|
||||||
|
"Fieldname {0} already exists in the following doctypes: {1}. "
|
||||||
|
"A separate dimension field will not be added to these doctypes. "
|
||||||
|
"GL Entries will use the value of the existing field as the dimension value."
|
||||||
|
).format(
|
||||||
|
frappe.bold(self.fieldname),
|
||||||
|
", ".join(frappe.bold(d) for d in conflicting_doctypes),
|
||||||
|
),
|
||||||
|
title=_("Fieldname Conflict"),
|
||||||
|
indicator="orange",
|
||||||
|
)
|
||||||
|
|
||||||
def validate_dimension_defaults(self):
|
def validate_dimension_defaults(self):
|
||||||
companies = []
|
companies = []
|
||||||
for default in self.get("dimension_defaults"):
|
for default in self.get("dimension_defaults"):
|
||||||
|
|||||||
@@ -38,16 +38,6 @@ frappe.ui.form.on("Accounts Settings", {
|
|||||||
add_taxes_from_item_tax_template(frm) {
|
add_taxes_from_item_tax_template(frm) {
|
||||||
toggle_tax_settings(frm, "add_taxes_from_item_tax_template");
|
toggle_tax_settings(frm, "add_taxes_from_item_tax_template");
|
||||||
},
|
},
|
||||||
|
|
||||||
drop_ar_procedures: function (frm) {
|
|
||||||
frm.call({
|
|
||||||
doc: frm.doc,
|
|
||||||
method: "drop_ar_sql_procedures",
|
|
||||||
callback: function (r) {
|
|
||||||
frappe.show_alert(__("Procedures dropped"), 5);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function toggle_tax_settings(frm, field_name) {
|
function toggle_tax_settings(frm, field_name) {
|
||||||
|
|||||||
@@ -97,7 +97,6 @@
|
|||||||
"receivable_payable_fetch_method",
|
"receivable_payable_fetch_method",
|
||||||
"default_ageing_range",
|
"default_ageing_range",
|
||||||
"column_break_ntmi",
|
"column_break_ntmi",
|
||||||
"drop_ar_procedures",
|
|
||||||
"legacy_section",
|
"legacy_section",
|
||||||
"ignore_is_opening_check_for_reporting",
|
"ignore_is_opening_check_for_reporting",
|
||||||
"tab_break_dpet",
|
"tab_break_dpet",
|
||||||
@@ -526,7 +525,7 @@
|
|||||||
"fieldname": "receivable_payable_fetch_method",
|
"fieldname": "receivable_payable_fetch_method",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Data Fetch Method",
|
"label": "Data Fetch Method",
|
||||||
"options": "Buffered Cursor\nUnBuffered Cursor\nRaw SQL"
|
"options": "Buffered Cursor\nUnBuffered Cursor"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "accounts_receivable_payable_tuning_section",
|
"fieldname": "accounts_receivable_payable_tuning_section",
|
||||||
@@ -595,13 +594,6 @@
|
|||||||
"fieldname": "column_break_ntmi",
|
"fieldname": "column_break_ntmi",
|
||||||
"fieldtype": "Column Break"
|
"fieldtype": "Column Break"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"depends_on": "eval:doc.receivable_payable_fetch_method == \"Raw SQL\"",
|
|
||||||
"description": "Drops existing SQL Procedures and Function setup by Accounts Receivable report",
|
|
||||||
"fieldname": "drop_ar_procedures",
|
|
||||||
"fieldtype": "Button",
|
|
||||||
"label": "Drop Procedures"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"default": "0",
|
"default": "0",
|
||||||
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
"fieldname": "fetch_valuation_rate_for_internal_transaction",
|
||||||
@@ -749,7 +741,7 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-04-22 01:38:42.418238",
|
"modified": "2026-05-18 12:16:33.679345",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Accounts Settings",
|
"name": "Accounts Settings",
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class AccountsSettings(Document):
|
|||||||
merge_similar_account_heads: DF.Check
|
merge_similar_account_heads: DF.Check
|
||||||
over_billing_allowance: DF.Currency
|
over_billing_allowance: DF.Currency
|
||||||
preview_mode: DF.Check
|
preview_mode: DF.Check
|
||||||
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor", "Raw SQL"]
|
receivable_payable_fetch_method: DF.Literal["Buffered Cursor", "UnBuffered Cursor"]
|
||||||
receivable_payable_remarks_length: DF.Int
|
receivable_payable_remarks_length: DF.Int
|
||||||
reconciliation_queue_size: DF.Int
|
reconciliation_queue_size: DF.Int
|
||||||
repost_allowed_types: DF.Table[RepostAllowedTypes]
|
repost_allowed_types: DF.Table[RepostAllowedTypes]
|
||||||
@@ -212,13 +212,6 @@ class AccountsSettings(Document):
|
|||||||
|
|
||||||
set_allow_on_submit_for_dimension_fields(doctypes)
|
set_allow_on_submit_for_dimension_fields(doctypes)
|
||||||
|
|
||||||
@frappe.whitelist()
|
|
||||||
def drop_ar_sql_procedures(self):
|
|
||||||
from erpnext.accounts.report.accounts_receivable.accounts_receivable import InitSQLProceduresForAR
|
|
||||||
|
|
||||||
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.init_procedure_name}")
|
|
||||||
frappe.db.sql(f"drop procedure if exists {InitSQLProceduresForAR.allocate_procedure_name}")
|
|
||||||
|
|
||||||
|
|
||||||
def toggle_accounting_dimension_sections(hide):
|
def toggle_accounting_dimension_sections(hide):
|
||||||
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
|
accounting_dimension_doctypes = frappe.get_hooks("accounting_dimension_doctypes")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"autoname": "format:Bank Statement Import on {creation}",
|
"autoname": "format:Bank Statement Import on {creation}",
|
||||||
"beta": 1,
|
|
||||||
"creation": "2019-08-04 14:16:08.318714",
|
"creation": "2019-08-04 14:16:08.318714",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
@@ -226,11 +226,11 @@
|
|||||||
],
|
],
|
||||||
"hide_toolbar": 1,
|
"hide_toolbar": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-06-11 02:23:22.159961",
|
"modified": "2026-05-31 00:41:11.251215",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Bank Statement Import",
|
"name": "Bank Statement Import",
|
||||||
"naming_rule": "Expression",
|
"naming_rule": "Expression (old style)",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class TestBankTransaction(ERPNextTestSuite):
|
|||||||
from_date=bank_transaction.date,
|
from_date=bank_transaction.date,
|
||||||
to_date=utils.today(),
|
to_date=utils.today(),
|
||||||
)
|
)
|
||||||
self.assertTrue(linked_payments[0]["party"] == "Conrad Electronic")
|
self.assertEqual(linked_payments[0]["party"], "Conrad Electronic")
|
||||||
|
|
||||||
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
|
# This test validates a simple reconciliation leading to the clearance of the bank transaction and the payment
|
||||||
def test_reconcile(self):
|
def test_reconcile(self):
|
||||||
@@ -70,10 +70,10 @@ class TestBankTransaction(ERPNextTestSuite):
|
|||||||
unallocated_amount = frappe.db.get_value(
|
unallocated_amount = frappe.db.get_value(
|
||||||
"Bank Transaction", bank_transaction.name, "unallocated_amount"
|
"Bank Transaction", bank_transaction.name, "unallocated_amount"
|
||||||
)
|
)
|
||||||
self.assertTrue(unallocated_amount == 0)
|
self.assertEqual(unallocated_amount, 0)
|
||||||
|
|
||||||
clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date")
|
clearance_date = frappe.db.get_value("Payment Entry", payment.name, "clearance_date")
|
||||||
self.assertTrue(clearance_date is not None)
|
self.assertIsNot(clearance_date, None)
|
||||||
|
|
||||||
bank_transaction.reload()
|
bank_transaction.reload()
|
||||||
bank_transaction.cancel()
|
bank_transaction.cancel()
|
||||||
@@ -178,9 +178,8 @@ class TestBankTransaction(ERPNextTestSuite):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount"), 0
|
frappe.db.get_value("Bank Transaction", bank_transaction.name, "unallocated_amount"), 0
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertIsNot(
|
||||||
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date")
|
frappe.db.get_value("Sales Invoice Payment", dict(parent=payment.name), "clearance_date"), None
|
||||||
is not None
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@if_lending_app_installed
|
@if_lending_app_installed
|
||||||
|
|||||||
@@ -705,18 +705,20 @@ def get_ordered_amount(params):
|
|||||||
|
|
||||||
|
|
||||||
def get_other_condition(params, for_doc):
|
def get_other_condition(params, for_doc):
|
||||||
condition = f"expense_account = '{params.expense_account}'"
|
condition = f"expense_account = {frappe.db.escape(params.expense_account)}"
|
||||||
budget_against_field = params.get("budget_against_field")
|
budget_against_field = params.get("budget_against_field")
|
||||||
|
|
||||||
if budget_against_field and params.get(budget_against_field):
|
if budget_against_field and params.get(budget_against_field):
|
||||||
condition += f" and child.{budget_against_field} = '{params.get(budget_against_field)}'"
|
condition += (
|
||||||
|
f" and child.{budget_against_field} = {frappe.db.escape(params.get(budget_against_field))}"
|
||||||
|
)
|
||||||
|
|
||||||
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
date_field = "schedule_date" if for_doc == "Material Request" else "transaction_date"
|
||||||
|
|
||||||
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
|
start_date = frappe.get_cached_value("Fiscal Year", params.from_fiscal_year, "year_start_date")
|
||||||
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
|
end_date = frappe.get_cached_value("Fiscal Year", params.to_fiscal_year, "year_end_date")
|
||||||
|
|
||||||
condition += f" and parent.{date_field} between '{start_date}' and '{end_date}'"
|
condition += f" and parent.{date_field} between {frappe.db.escape(str(start_date))} and {frappe.db.escape(str(end_date))}"
|
||||||
|
|
||||||
return condition
|
return condition
|
||||||
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ class TestCostCenterAllocation(ERPNextTestSuite):
|
|||||||
self.assertTrue(gl_entries)
|
self.assertTrue(gl_entries)
|
||||||
|
|
||||||
for gle in gl_entries:
|
for gle in gl_entries:
|
||||||
self.assertTrue(gle.cost_center in expected_values)
|
self.assertIn(gle.cost_center, expected_values)
|
||||||
self.assertEqual(gle.debit, 0)
|
self.assertEqual(gle.debit, 0)
|
||||||
self.assertEqual(gle.credit, expected_values[gle.cost_center])
|
self.assertEqual(gle.credit, expected_values[gle.cost_center])
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"allow_events_in_timeline": 1,
|
"allow_events_in_timeline": 1,
|
||||||
"autoname": "naming_series:",
|
"autoname": "naming_series:",
|
||||||
"beta": 1,
|
|
||||||
"creation": "2019-07-05 16:34:31.013238",
|
"creation": "2019-07-05 16:34:31.013238",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"engine": "InnoDB",
|
"engine": "InnoDB",
|
||||||
@@ -400,7 +400,7 @@
|
|||||||
],
|
],
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-11-26 13:46:07.760867",
|
"modified": "2026-05-30 23:18:04.712528",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Dunning",
|
"name": "Dunning",
|
||||||
@@ -449,6 +449,7 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "ASC",
|
"sort_order": "ASC",
|
||||||
"states": [],
|
"states": [],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"allow_rename": 1,
|
"allow_rename": 1,
|
||||||
"beta": 1,
|
|
||||||
"creation": "2019-12-04 04:59:08.003664",
|
"creation": "2019-12-04 04:59:08.003664",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
"link_fieldname": "dunning_type"
|
"link_fieldname": "dunning_type"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2024-03-27 13:08:19.584112",
|
"modified": "2026-05-30 23:18:20.740726",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Dunning Type",
|
"name": "Dunning Type",
|
||||||
@@ -151,6 +151,7 @@
|
|||||||
"write": 1
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": [],
|
"states": [],
|
||||||
|
|||||||
@@ -565,18 +565,19 @@ class FinancialQueryBuilder:
|
|||||||
frappe.qb.from_(acb_table)
|
frappe.qb.from_(acb_table)
|
||||||
.select(
|
.select(
|
||||||
acb_table.account,
|
acb_table.account,
|
||||||
(acb_table.debit - acb_table.credit).as_("balance"),
|
Sum(acb_table.debit - acb_table.credit).as_("balance"),
|
||||||
)
|
)
|
||||||
.where(acb_table.company == self.company)
|
.where(acb_table.company == self.company)
|
||||||
.where(acb_table.account.isin(account_names))
|
.where(acb_table.account.isin(account_names))
|
||||||
.where(acb_table.period_closing_voucher == closing_voucher)
|
.where(acb_table.period_closing_voucher == closing_voucher)
|
||||||
|
.groupby(acb_table.account)
|
||||||
)
|
)
|
||||||
|
|
||||||
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
|
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
|
||||||
results = self._execute_with_permissions(query, "Account Closing Balance")
|
results = self._execute_with_permissions(query, "Account Closing Balance")
|
||||||
|
|
||||||
for row in results:
|
for row in results:
|
||||||
closing_balances[row["account"]] = row["balance"]
|
closing_balances[row["account"]] = row["balance"] or 0.0
|
||||||
|
|
||||||
return closing_balances
|
return closing_balances
|
||||||
|
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ class CalculationFormulaValidator(Validator):
|
|||||||
"sqrt": lambda x: x**0.5,
|
"sqrt": lambda x: x**0.5,
|
||||||
"pow": pow,
|
"pow": pow,
|
||||||
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
|
"ceil": lambda x: int(x) + (1 if x % 1 else 0),
|
||||||
"floor": lambda x: int(x),
|
"floor": int,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from erpnext.accounts.doctype.financial_report_template.test_financial_report_te
|
|||||||
)
|
)
|
||||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||||
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
|
from erpnext.accounts.utils import get_currency_precision, get_fiscal_year
|
||||||
|
from erpnext.tests.utils import change_settings
|
||||||
|
|
||||||
|
|
||||||
class TestDependencyResolver(FinancialReportTemplateTestCase):
|
class TestDependencyResolver(FinancialReportTemplateTestCase):
|
||||||
@@ -1950,6 +1951,104 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
|
|||||||
|
|
||||||
jv_2023.cancel()
|
jv_2023.cancel()
|
||||||
|
|
||||||
|
@change_settings("Accounts Settings", {"use_legacy_controller_for_pcv": 1})
|
||||||
|
def test_opening_balance_sums_acb_rows_across_dimensions(self):
|
||||||
|
"""
|
||||||
|
Account Closing Balance stores one row per (account, cost_center,
|
||||||
|
project, finance_book). The closing-balance fetch must sum all rows.
|
||||||
|
"""
|
||||||
|
company = "_Test Company"
|
||||||
|
cash_account = "_Test Cash - _TC"
|
||||||
|
sales_account = "Sales - _TC"
|
||||||
|
cc_1 = "_Test Cost Center - _TC"
|
||||||
|
cc_2 = "_Test Cost Center 2 - _TC"
|
||||||
|
docs = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
jv_2023_cc1 = make_journal_entry(
|
||||||
|
account1=cash_account,
|
||||||
|
account2=sales_account,
|
||||||
|
amount=3000,
|
||||||
|
posting_date="2023-06-15",
|
||||||
|
cost_center=cc_1,
|
||||||
|
company=company,
|
||||||
|
submit=True,
|
||||||
|
)
|
||||||
|
docs.append(jv_2023_cc1)
|
||||||
|
jv_2023_cc2 = make_journal_entry(
|
||||||
|
account1=cash_account,
|
||||||
|
account2=sales_account,
|
||||||
|
amount=2000,
|
||||||
|
posting_date="2023-06-15",
|
||||||
|
cost_center=cc_2,
|
||||||
|
company=company,
|
||||||
|
submit=True,
|
||||||
|
)
|
||||||
|
docs.append(jv_2023_cc2)
|
||||||
|
|
||||||
|
fy_2023 = get_fiscal_year("2023-06-15", company=company)
|
||||||
|
|
||||||
|
pcv = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Period Closing Voucher",
|
||||||
|
"transaction_date": "2023-12-31",
|
||||||
|
"period_start_date": fy_2023[1],
|
||||||
|
"period_end_date": fy_2023[2],
|
||||||
|
"company": company,
|
||||||
|
"fiscal_year": fy_2023[0],
|
||||||
|
"cost_center": cc_1,
|
||||||
|
"closing_account_head": "Deferred Revenue - _TC",
|
||||||
|
"remarks": "Test multi-dim PCV",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pcv.insert()
|
||||||
|
pcv.submit()
|
||||||
|
docs.append(pcv)
|
||||||
|
|
||||||
|
jv_2024 = make_journal_entry(
|
||||||
|
account1=cash_account,
|
||||||
|
account2=sales_account,
|
||||||
|
amount=100,
|
||||||
|
posting_date="2024-01-15",
|
||||||
|
cost_center=cc_1,
|
||||||
|
company=company,
|
||||||
|
submit=True,
|
||||||
|
)
|
||||||
|
docs.append(jv_2024)
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
"company": company,
|
||||||
|
"from_fiscal_year": "2024",
|
||||||
|
"to_fiscal_year": "2024",
|
||||||
|
"period_start_date": "2024-01-01",
|
||||||
|
"period_end_date": "2024-03-31",
|
||||||
|
"filter_based_on": "Date Range",
|
||||||
|
"periodicity": "Monthly",
|
||||||
|
"ignore_closing_entries": True,
|
||||||
|
}
|
||||||
|
periods = [
|
||||||
|
{"key": "2024_jan", "from_date": "2024-01-01", "to_date": "2024-01-31"},
|
||||||
|
{"key": "2024_feb", "from_date": "2024-02-01", "to_date": "2024-02-29"},
|
||||||
|
{"key": "2024_mar", "from_date": "2024-03-01", "to_date": "2024-03-31"},
|
||||||
|
]
|
||||||
|
|
||||||
|
query_builder = FinancialQueryBuilder(filters, periods)
|
||||||
|
accounts = [
|
||||||
|
frappe._dict({"name": cash_account, "account_name": "Cash", "account_number": "1001"}),
|
||||||
|
]
|
||||||
|
|
||||||
|
balances_data = query_builder.fetch_account_balances(accounts)
|
||||||
|
cash_data = balances_data.get(cash_account)
|
||||||
|
self.assertIsNotNone(cash_data, "Cash account must appear in results")
|
||||||
|
|
||||||
|
jan_cash = cash_data.get_period("2024_jan")
|
||||||
|
self.assertEqual(jan_cash.opening, 5000.0)
|
||||||
|
self.assertEqual(jan_cash.movement, 100.0)
|
||||||
|
self.assertEqual(jan_cash.closing, 5100.0)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.cancel_docs(docs)
|
||||||
|
|
||||||
def test_opening_entries_roll_into_opening_after_period_closing(self):
|
def test_opening_entries_roll_into_opening_after_period_closing(self):
|
||||||
"""
|
"""
|
||||||
Sequence:
|
Sequence:
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ from erpnext.tests.utils import ERPNextTestSuite
|
|||||||
class FinancialReportTemplateTestCase(ERPNextTestSuite):
|
class FinancialReportTemplateTestCase(ERPNextTestSuite):
|
||||||
"""Utility class with common setup and helper methods for all test classes"""
|
"""Utility class with common setup and helper methods for all test classes"""
|
||||||
|
|
||||||
|
def cancel_docs(self, docs):
|
||||||
|
"""Cancel submitted docs in reverse creation order to avoid dependency issues."""
|
||||||
|
for doc in reversed(docs):
|
||||||
|
if doc:
|
||||||
|
doc.reload()
|
||||||
|
if doc.docstatus == 1:
|
||||||
|
doc.cancel()
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
"""Set up test data"""
|
"""Set up test data"""
|
||||||
self.create_test_template()
|
self.create_test_template()
|
||||||
|
|||||||
@@ -433,7 +433,8 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
|||||||
|
|
||||||
accounts_add(doc, cdt, cdn) {
|
accounts_add(doc, cdt, cdn) {
|
||||||
var row = frappe.get_doc(cdt, cdn);
|
var row = frappe.get_doc(cdt, cdn);
|
||||||
row.exchange_rate = 1;
|
if (!row.exchange_rate) row.exchange_rate = 1;
|
||||||
|
if (!row.account) {
|
||||||
$.each(doc.accounts, function (i, d) {
|
$.each(doc.accounts, function (i, d) {
|
||||||
if (d.account && d.party && d.party_type) {
|
if (d.account && d.party && d.party_type) {
|
||||||
row.account = d.account;
|
row.account = d.account;
|
||||||
@@ -442,6 +443,7 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
|||||||
row.exchange_rate = d.exchange_rate;
|
row.exchange_rate = d.exchange_rate;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// set difference
|
// set difference
|
||||||
if (doc.difference) {
|
if (doc.difference) {
|
||||||
|
|||||||
@@ -1292,7 +1292,11 @@ class JournalEntry(AccountsController):
|
|||||||
self.validate_total_debit_and_credit()
|
self.validate_total_debit_and_credit()
|
||||||
|
|
||||||
def get_values(self):
|
def get_values(self):
|
||||||
cond = f" and outstanding_amount <= {self.write_off_amount}" if flt(self.write_off_amount) > 0 else ""
|
cond = (
|
||||||
|
f" and outstanding_amount <= {flt(self.write_off_amount)}"
|
||||||
|
if flt(self.write_off_amount) > 0
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
if self.write_off_based_on == "Accounts Receivable":
|
if self.write_off_based_on == "Accounts Receivable":
|
||||||
return frappe.db.sql(
|
return frappe.db.sql(
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ class TestJournalEntry(ERPNextTestSuite):
|
|||||||
)
|
)
|
||||||
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
|
payment_against_order = base_jv.get("accounts")[0].get(dr_or_cr)
|
||||||
|
|
||||||
self.assertTrue(flt(advance_paid[0][0]) == flt(payment_against_order))
|
self.assertEqual(flt(advance_paid[0][0]), flt(payment_against_order))
|
||||||
|
|
||||||
def cancel_against_voucher_testcase(self, test_voucher):
|
def cancel_against_voucher_testcase(self, test_voucher):
|
||||||
if test_voucher.doctype == "Journal Entry":
|
if test_voucher.doctype == "Journal Entry":
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"allow_copy": 1,
|
"allow_copy": 1,
|
||||||
"beta": 1,
|
|
||||||
"creation": "2017-08-29 02:22:54.947711",
|
"creation": "2017-08-29 02:22:54.947711",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
"editable_grid": 1,
|
"editable_grid": 1,
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
"hide_toolbar": 1,
|
"hide_toolbar": 1,
|
||||||
"issingle": 1,
|
"issingle": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-03-31 01:47:20.360352",
|
"modified": "2026-05-30 23:18:48.691227",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Opening Invoice Creation Tool",
|
"name": "Opening Invoice Creation Tool",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
{
|
{
|
||||||
"fieldname": "advance_account",
|
"fieldname": "advance_account",
|
||||||
"fieldtype": "Link",
|
"fieldtype": "Link",
|
||||||
|
"in_list_view": 1,
|
||||||
"label": "Advance Account",
|
"label": "Advance Account",
|
||||||
"options": "Account"
|
"options": "Account"
|
||||||
}
|
}
|
||||||
@@ -36,13 +37,14 @@
|
|||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"istable": 1,
|
"istable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2024-03-27 13:10:08.489183",
|
"modified": "2026-05-27 14:19:00.888437",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Party Account",
|
"name": "Party Account",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [],
|
"permissions": [],
|
||||||
"quick_entry": 1,
|
"quick_entry": 1,
|
||||||
|
"row_format": "Dynamic",
|
||||||
"sort_field": "creation",
|
"sort_field": "creation",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"states": []
|
"states": []
|
||||||
|
|||||||
@@ -710,31 +710,12 @@ frappe.ui.form.on("Payment Entry", {
|
|||||||
if (!frm.doc.paid_from_account_currency || !frm.doc.company) return;
|
if (!frm.doc.paid_from_account_currency || !frm.doc.company) return;
|
||||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||||
|
|
||||||
if (frm.doc.paid_from_account_currency == company_currency) {
|
|
||||||
frm.set_value("source_exchange_rate", 1);
|
|
||||||
} else if (frm.doc.paid_from) {
|
|
||||||
if (["Internal Transfer", "Pay"].includes(frm.doc.payment_type)) {
|
|
||||||
let company_currency = frappe.get_doc(":Company", frm.doc.company)?.default_currency;
|
|
||||||
frappe.call({
|
|
||||||
method: "erpnext.setup.utils.get_exchange_rate",
|
|
||||||
args: {
|
|
||||||
from_currency: frm.doc.paid_from_account_currency,
|
|
||||||
to_currency: company_currency,
|
|
||||||
transaction_date: frm.doc.posting_date,
|
|
||||||
},
|
|
||||||
callback: function (r, rt) {
|
|
||||||
frm.set_value("source_exchange_rate", r.message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
frm.events.set_current_exchange_rate(
|
frm.events.set_current_exchange_rate(
|
||||||
frm,
|
frm,
|
||||||
"source_exchange_rate",
|
"source_exchange_rate",
|
||||||
frm.doc.paid_from_account_currency,
|
frm.doc.paid_from_account_currency,
|
||||||
company_currency
|
company_currency
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
paid_to_account_currency: function (frm) {
|
paid_to_account_currency: function (frm) {
|
||||||
@@ -766,49 +747,24 @@ frappe.ui.form.on("Payment Entry", {
|
|||||||
|
|
||||||
posting_date: function (frm) {
|
posting_date: function (frm) {
|
||||||
frm.events.paid_from_account_currency(frm);
|
frm.events.paid_from_account_currency(frm);
|
||||||
|
frm.events.paid_to_account_currency(frm);
|
||||||
},
|
},
|
||||||
|
|
||||||
source_exchange_rate: function (frm) {
|
source_exchange_rate: function (frm) {
|
||||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
|
||||||
if (frm.doc.paid_amount) {
|
|
||||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
|
||||||
// target exchange rate should always be same as source if both account currencies is same
|
|
||||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
|
||||||
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
|
|
||||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
|
||||||
} else if (company_currency == frm.doc.paid_to_account_currency) {
|
|
||||||
frm.set_value("received_amount", frm.doc.base_paid_amount);
|
|
||||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
|
||||||
}
|
|
||||||
|
|
||||||
// set_unallocated_amount is called by below method,
|
|
||||||
// no need trigger separately
|
|
||||||
frm.events.set_total_allocated_amount(frm);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make read only if Accounts Settings doesn't allow stale rates
|
|
||||||
frm.set_df_property("source_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
|
|
||||||
},
|
|
||||||
|
|
||||||
target_exchange_rate: function (frm) {
|
|
||||||
frm.set_paid_amount_based_on_received_amount = true;
|
frm.set_paid_amount_based_on_received_amount = true;
|
||||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||||
|
|
||||||
if (frm.doc.received_amount) {
|
if (frm.doc.base_received_amount && frm.doc.source_exchange_rate) {
|
||||||
frm.set_value(
|
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||||
"base_received_amount",
|
|
||||||
flt(frm.doc.received_amount) * flt(frm.doc.target_exchange_rate)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
// target exchange rate should always be same as source if both account currencies is same
|
||||||
!frm.doc.source_exchange_rate &&
|
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
||||||
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
|
frm.set_value("target_exchange_rate", frm.doc.source_exchange_rate);
|
||||||
) {
|
} else {
|
||||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
frm.set_value(
|
||||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
"paid_amount",
|
||||||
} else if (company_currency == frm.doc.paid_from_account_currency) {
|
flt(frm.doc.base_paid_amount) / flt(frm.doc.source_exchange_rate)
|
||||||
frm.set_value("paid_amount", frm.doc.base_received_amount);
|
);
|
||||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// set_unallocated_amount is called by below method,
|
// set_unallocated_amount is called by below method,
|
||||||
@@ -817,6 +773,32 @@ frappe.ui.form.on("Payment Entry", {
|
|||||||
}
|
}
|
||||||
frm.set_paid_amount_based_on_received_amount = false;
|
frm.set_paid_amount_based_on_received_amount = false;
|
||||||
|
|
||||||
|
// Make read only if Accounts Settings doesn't allow stale rates
|
||||||
|
frm.set_df_property("source_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
|
||||||
|
},
|
||||||
|
|
||||||
|
target_exchange_rate: function (frm) {
|
||||||
|
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||||
|
|
||||||
|
if (frm.doc.base_paid_amount && frm.doc.target_exchange_rate) {
|
||||||
|
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||||
|
if (
|
||||||
|
!frm.doc.source_exchange_rate &&
|
||||||
|
frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency
|
||||||
|
) {
|
||||||
|
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
||||||
|
} else {
|
||||||
|
frm.set_value(
|
||||||
|
"received_amount",
|
||||||
|
flt(frm.doc.base_received_amount) / flt(frm.doc.target_exchange_rate)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// set_unallocated_amount is called by below method,
|
||||||
|
// no need trigger separately
|
||||||
|
frm.events.set_total_allocated_amount(frm);
|
||||||
|
}
|
||||||
|
|
||||||
// Make read only if Accounts Settings doesn't allow stale rates
|
// Make read only if Accounts Settings doesn't allow stale rates
|
||||||
frm.set_df_property("target_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
|
frm.set_df_property("target_exchange_rate", "read_only", erpnext.stale_rate_allowed() ? 0 : 1);
|
||||||
},
|
},
|
||||||
@@ -825,11 +807,14 @@ frappe.ui.form.on("Payment Entry", {
|
|||||||
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
frm.set_value("base_paid_amount", flt(frm.doc.paid_amount) * flt(frm.doc.source_exchange_rate));
|
||||||
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
let company_currency = frappe.get_doc(":Company", frm.doc.company).default_currency;
|
||||||
if (!frm.doc.received_amount) {
|
if (!frm.doc.received_amount) {
|
||||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
|
||||||
frm.set_value("received_amount", frm.doc.paid_amount);
|
|
||||||
} else if (company_currency == frm.doc.paid_to_account_currency) {
|
|
||||||
frm.set_value("received_amount", frm.doc.base_paid_amount);
|
|
||||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||||
|
if (company_currency == frm.doc.paid_to_account_currency) {
|
||||||
|
frm.set_value("received_amount", frm.doc.base_paid_amount);
|
||||||
|
} else if (frm.doc.target_exchange_rate) {
|
||||||
|
frm.set_value(
|
||||||
|
"received_amount",
|
||||||
|
flt(frm.doc.base_paid_amount) / flt(frm.doc.target_exchange_rate)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
frm.trigger("reset_received_amount");
|
frm.trigger("reset_received_amount");
|
||||||
@@ -846,15 +831,14 @@ frappe.ui.form.on("Payment Entry", {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!frm.doc.paid_amount) {
|
if (!frm.doc.paid_amount) {
|
||||||
if (frm.doc.paid_from_account_currency == frm.doc.paid_to_account_currency) {
|
|
||||||
frm.set_value("paid_amount", frm.doc.received_amount);
|
|
||||||
if (frm.doc.target_exchange_rate) {
|
|
||||||
frm.set_value("source_exchange_rate", frm.doc.target_exchange_rate);
|
|
||||||
}
|
|
||||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||||
} else if (company_currency == frm.doc.paid_from_account_currency) {
|
if (company_currency == frm.doc.paid_from_account_currency) {
|
||||||
frm.set_value("paid_amount", frm.doc.base_received_amount);
|
frm.set_value("paid_amount", frm.doc.base_received_amount);
|
||||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
} else if (frm.doc.source_exchange_rate) {
|
||||||
|
frm.set_value(
|
||||||
|
"paid_amount",
|
||||||
|
flt(frm.doc.base_received_amount) / flt(frm.doc.source_exchange_rate)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1742,6 +1726,35 @@ frappe.ui.form.on("Payment Entry", {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
before_cancel: function (frm) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
frappe.call({
|
||||||
|
method: "erpnext.accounts.doctype.payment_entry.payment_entry.get_linked_bank_transactions",
|
||||||
|
args: { payment_entry: frm.doc.name },
|
||||||
|
callback: function (r) {
|
||||||
|
const linked = r.message || [];
|
||||||
|
if (!linked.length) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const bt_links = linked
|
||||||
|
.map((name) => frappe.utils.get_form_link("Bank Transaction", name, true))
|
||||||
|
.join(", ");
|
||||||
|
frappe.confirm(
|
||||||
|
__(
|
||||||
|
"This Payment Entry is reconciled with {0}. Cancelling will automatically unreconcile it. Do you want to proceed?",
|
||||||
|
[bt_links]
|
||||||
|
),
|
||||||
|
() => resolve(),
|
||||||
|
() => reject(),
|
||||||
|
__("Yes"),
|
||||||
|
__("No")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
frappe.ui.form.on("Payment Entry Reference", {
|
frappe.ui.form.on("Payment Entry Reference", {
|
||||||
|
|||||||
@@ -322,7 +322,7 @@
|
|||||||
"reqd": 1
|
"reqd": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"depends_on": "doc.received_amount",
|
"depends_on": "eval:doc.received_amount;",
|
||||||
"fieldname": "base_received_amount",
|
"fieldname": "base_received_amount",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"label": "Received Amount (Company Currency)",
|
"label": "Received Amount (Company Currency)",
|
||||||
@@ -795,7 +795,7 @@
|
|||||||
"table_fieldname": "payment_entries"
|
"table_fieldname": "payment_entries"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"modified": "2026-03-09 17:15:30.453920",
|
"modified": "2026-05-15 13:31:01.166010",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Payment Entry",
|
"name": "Payment Entry",
|
||||||
|
|||||||
@@ -1238,9 +1238,9 @@ class PaymentEntry(AccountsController):
|
|||||||
else:
|
else:
|
||||||
remarks = [
|
remarks = [
|
||||||
_("Amount {0} {1} {2} {3}").format(
|
_("Amount {0} {1} {2} {3}").format(
|
||||||
_(self.paid_to_account_currency)
|
_(self.paid_from_account_currency)
|
||||||
if self.payment_type == "Receive"
|
if self.payment_type == "Receive"
|
||||||
else _(self.paid_from_account_currency),
|
else _(self.paid_to_account_currency),
|
||||||
self.paid_amount if self.payment_type == "Receive" else self.received_amount,
|
self.paid_amount if self.payment_type == "Receive" else self.received_amount,
|
||||||
_("received from") if self.payment_type == "Receive" else _("paid to"),
|
_("received from") if self.payment_type == "Receive" else _("paid to"),
|
||||||
self.party,
|
self.party,
|
||||||
@@ -1256,7 +1256,7 @@ class PaymentEntry(AccountsController):
|
|||||||
for d in self.get("references"):
|
for d in self.get("references"):
|
||||||
if d.allocated_amount:
|
if d.allocated_amount:
|
||||||
remarks.append(
|
remarks.append(
|
||||||
_("Amount {0} {1} against {2} {3}").format(
|
_("Amount {0} {1} adjusted against {2} {3}").format(
|
||||||
_(self.party_account_currency),
|
_(self.party_account_currency),
|
||||||
d.allocated_amount,
|
d.allocated_amount,
|
||||||
d.reference_doctype,
|
d.reference_doctype,
|
||||||
@@ -1267,7 +1267,7 @@ class PaymentEntry(AccountsController):
|
|||||||
for d in self.get("deductions"):
|
for d in self.get("deductions"):
|
||||||
if d.amount:
|
if d.amount:
|
||||||
remarks.append(
|
remarks.append(
|
||||||
_("Amount {0} {1} deducted against {2}").format(
|
_("Amount {0} {1} as adjustment to {2}").format(
|
||||||
_(self.company_currency), d.amount, d.account
|
_(self.company_currency), d.amount, d.account
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -3574,3 +3574,16 @@ def make_payment_order(source_name: str, target_doc: str | Document | None = Non
|
|||||||
@erpnext.allow_regional
|
@erpnext.allow_regional
|
||||||
def add_regional_gl_entries(gl_entries, doc):
|
def add_regional_gl_entries(gl_entries, doc):
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
@frappe.whitelist()
|
||||||
|
def get_linked_bank_transactions(payment_entry: str) -> list:
|
||||||
|
frappe.has_permission("Payment Entry", ptype="read", doc=payment_entry, throw=True)
|
||||||
|
return frappe.get_all(
|
||||||
|
"Bank Transaction Payments",
|
||||||
|
filters={
|
||||||
|
"payment_document": "Payment Entry",
|
||||||
|
"payment_entry": payment_entry,
|
||||||
|
},
|
||||||
|
pluck="parent",
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,20 +1,4 @@
|
|||||||
frappe.listview_settings["Payment Entry"] = {
|
frappe.listview_settings["Payment Entry"] = {
|
||||||
add_fields: ["unallocated_amount", "docstatus"],
|
|
||||||
get_indicator: function (doc) {
|
|
||||||
if (doc.docstatus === 2) {
|
|
||||||
return [__("Cancelled"), "red", "docstatus,=,2"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (doc.docstatus === 0) {
|
|
||||||
return [__("Draft"), "orange", "docstatus,=,0"];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (flt(doc.unallocated_amount) > 0) {
|
|
||||||
return [__("Unreconciled"), "orange", "docstatus,=,1|unallocated_amount,>,0"];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [__("Reconciled"), "green", "docstatus,=,1|unallocated_amount,=,0"];
|
|
||||||
},
|
|
||||||
onload: function (listview) {
|
onload: function (listview) {
|
||||||
if (listview.page.fields_dict.party_type) {
|
if (listview.page.fields_dict.party_type) {
|
||||||
listview.page.fields_dict.party_type.get_query = function () {
|
listview.page.fields_dict.party_type.get_query = function () {
|
||||||
|
|||||||
@@ -1119,7 +1119,7 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
with self.assertRaises(frappe.ValidationError) as err:
|
with self.assertRaises(frappe.ValidationError) as err:
|
||||||
pe.save()
|
pe.save()
|
||||||
|
|
||||||
self.assertTrue("is on hold" in str(err.exception).lower())
|
self.assertIn("is on hold", str(err.exception).lower())
|
||||||
|
|
||||||
def test_payment_entry_for_employee(self):
|
def test_payment_entry_for_employee(self):
|
||||||
employee = make_employee("test_payment_entry@salary.com", company="_Test Company")
|
employee = make_employee("test_payment_entry@salary.com", company="_Test Company")
|
||||||
@@ -2035,8 +2035,8 @@ class TestPaymentEntry(ERPNextTestSuite):
|
|||||||
|
|
||||||
# check cancellation of payment entry and journal entry
|
# check cancellation of payment entry and journal entry
|
||||||
pe.cancel()
|
pe.cancel()
|
||||||
self.assertTrue(pe.docstatus == 2)
|
self.assertEqual(pe.docstatus, 2)
|
||||||
self.assertTrue(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus") == 2)
|
self.assertEqual(frappe.db.get_value("Journal Entry", {"name": jv[0]}, "docstatus"), 2)
|
||||||
|
|
||||||
# check deletion of payment entry and journal entry
|
# check deletion of payment entry and journal entry
|
||||||
pe.delete()
|
pe.delete()
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import qb
|
from frappe import qb
|
||||||
from frappe.utils import nowdate
|
from frappe.query_builder.functions import Count, Sum
|
||||||
|
from frappe.utils import add_days, nowdate
|
||||||
|
|
||||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||||
@@ -90,6 +91,7 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
|
|||||||
posting_date = nowdate()
|
posting_date = nowdate()
|
||||||
|
|
||||||
sinv = create_sales_invoice(
|
sinv = create_sales_invoice(
|
||||||
|
posting_date=posting_date,
|
||||||
qty=qty,
|
qty=qty,
|
||||||
rate=rate,
|
rate=rate,
|
||||||
company=self.company,
|
company=self.company,
|
||||||
@@ -531,3 +533,82 @@ class TestPaymentLedgerEntry(ERPNextTestSuite):
|
|||||||
# with references removed, deletion should be possible
|
# with references removed, deletion should be possible
|
||||||
so.delete()
|
so.delete()
|
||||||
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
|
self.assertRaises(frappe.DoesNotExistError, frappe.get_doc, so.doctype, so.name)
|
||||||
|
|
||||||
|
@ERPNextTestSuite.change_settings(
|
||||||
|
"Accounts Settings",
|
||||||
|
{"enable_immutable_ledger": 1},
|
||||||
|
)
|
||||||
|
def test_reverse_entries_on_cancel_for_immutable_ledger(self):
|
||||||
|
invoice_posting_date = add_days(nowdate(), -5)
|
||||||
|
gle = qb.DocType("GL Entry")
|
||||||
|
ple = qb.DocType("Payment Ledger Entry")
|
||||||
|
|
||||||
|
si = self.create_sales_invoice(qty=1, rate=100, posting_date=invoice_posting_date)
|
||||||
|
|
||||||
|
gles_before = (
|
||||||
|
qb.from_(gle)
|
||||||
|
.select(
|
||||||
|
Count(gle.name),
|
||||||
|
)
|
||||||
|
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||||
|
.run()[0][0]
|
||||||
|
)
|
||||||
|
ples_before = (
|
||||||
|
qb.from_(ple)
|
||||||
|
.select(
|
||||||
|
Count(ple.name),
|
||||||
|
)
|
||||||
|
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
|
||||||
|
.run()[0][0]
|
||||||
|
)
|
||||||
|
|
||||||
|
si.cancel()
|
||||||
|
|
||||||
|
gles_after = (
|
||||||
|
qb.from_(gle)
|
||||||
|
.select(Count(gle.account))
|
||||||
|
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||||
|
.run()[0][0]
|
||||||
|
)
|
||||||
|
self.assertEqual(gles_after, gles_before * 2)
|
||||||
|
|
||||||
|
ples_after = (
|
||||||
|
qb.from_(ple)
|
||||||
|
.select(
|
||||||
|
Count(ple.name),
|
||||||
|
)
|
||||||
|
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked.eq(0)))
|
||||||
|
.run()[0][0]
|
||||||
|
)
|
||||||
|
self.assertEqual(ples_after, ples_before * 2)
|
||||||
|
|
||||||
|
# assert debit/credit are reversed
|
||||||
|
gl_entries = (
|
||||||
|
qb.from_(gle)
|
||||||
|
.select(gle.account, Sum(gle.debit).as_("total_debit"), Sum(gle.credit).as_("total_credit"))
|
||||||
|
.where((gle.voucher_type == si.doctype) & (gle.voucher_no == si.name) & (gle.is_cancelled == 0))
|
||||||
|
.groupby(gle.account)
|
||||||
|
.run(as_dict=True)
|
||||||
|
)
|
||||||
|
for gl in gl_entries:
|
||||||
|
with self.subTest(gl=gl):
|
||||||
|
self.assertEqual(gl.total_debit, gl.total_credit)
|
||||||
|
|
||||||
|
# assert amounts are reversed
|
||||||
|
pl_entries = (
|
||||||
|
qb.from_(ple)
|
||||||
|
.select(ple.account, Sum(ple.amount).as_("total_amount"))
|
||||||
|
.where((ple.voucher_type == si.doctype) & (ple.voucher_no == si.name) & (ple.delinked == 0))
|
||||||
|
.groupby(ple.account)
|
||||||
|
.run(as_dict=True)
|
||||||
|
)
|
||||||
|
for pl in pl_entries:
|
||||||
|
with self.subTest(pl=pl):
|
||||||
|
self.assertEqual(pl.total_amount, 0)
|
||||||
|
|
||||||
|
self.assertFalse(
|
||||||
|
frappe.db.exists(
|
||||||
|
"Payment Ledger Entry",
|
||||||
|
{"voucher_type": si.doctype, "voucher_no": si.name, "delinked": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
|
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe import qb
|
|
||||||
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
|
from frappe.utils import add_days, add_years, flt, getdate, nowdate, today
|
||||||
from frappe.utils.data import getdate as convert_to_date
|
from frappe.utils.data import getdate as convert_to_date
|
||||||
|
|
||||||
from erpnext import get_default_cost_center
|
|
||||||
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
|
||||||
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
from erpnext.accounts.doctype.payment_entry.test_payment_entry import create_payment_entry
|
||||||
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice
|
||||||
@@ -15,7 +13,6 @@ from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sal
|
|||||||
from erpnext.accounts.party import get_party_account
|
from erpnext.accounts.party import get_party_account
|
||||||
from erpnext.accounts.utils import get_fiscal_year
|
from erpnext.accounts.utils import get_fiscal_year
|
||||||
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
from erpnext.buying.doctype.purchase_order.test_purchase_order import create_purchase_order
|
||||||
from erpnext.stock.doctype.item.test_item import create_item
|
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1293,11 +1293,16 @@ def get_open_payment_requests_query(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
(
|
{
|
||||||
pr.name,
|
"value": pr.name,
|
||||||
|
"description": ", ".join(
|
||||||
|
[
|
||||||
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
|
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
|
||||||
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
|
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
|
||||||
)
|
]
|
||||||
|
),
|
||||||
|
"description_html": True,
|
||||||
|
}
|
||||||
for pr in open_payment_requests
|
for pr in open_payment_requests
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
|
from frappe.utils import add_days, getdate
|
||||||
|
|
||||||
|
from erpnext.controllers.accounts_controller import get_payment_term_details
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +57,52 @@ class TestPaymentTermsTemplate(ERPNextTestSuite):
|
|||||||
|
|
||||||
self.assertRaises(frappe.ValidationError, template.insert)
|
self.assertRaises(frappe.ValidationError, template.insert)
|
||||||
|
|
||||||
|
def test_no_discount_date_without_discount(self):
|
||||||
|
posting_date = "2026-05-29"
|
||||||
|
term = frappe._dict(
|
||||||
|
{
|
||||||
|
"payment_term": "_Test No Discount Term",
|
||||||
|
"invoice_portion": 100.0,
|
||||||
|
"due_date_based_on": "Day(s) after invoice date",
|
||||||
|
"credit_days": 0,
|
||||||
|
"credit_months": 0,
|
||||||
|
"discount_type": "Percentage",
|
||||||
|
"discount": 0,
|
||||||
|
"discount_validity_based_on": "Day(s) after invoice date",
|
||||||
|
"discount_validity": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
details = get_payment_term_details(
|
||||||
|
term, posting_date=posting_date, grand_total=100, base_grand_total=100
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(getdate(details.due_date), getdate(posting_date))
|
||||||
|
self.assertIsNone(details.discount_date)
|
||||||
|
|
||||||
|
def test_discount_date_generated_with_discount(self):
|
||||||
|
posting_date = "2026-05-29"
|
||||||
|
term = frappe._dict(
|
||||||
|
{
|
||||||
|
"payment_term": "_Test Discount Term",
|
||||||
|
"invoice_portion": 100.0,
|
||||||
|
"due_date_based_on": "Day(s) after invoice date",
|
||||||
|
"credit_days": 30,
|
||||||
|
"credit_months": 0,
|
||||||
|
"discount_type": "Percentage",
|
||||||
|
"discount": 5,
|
||||||
|
"discount_validity_based_on": "Day(s) after invoice date",
|
||||||
|
"discount_validity": 10,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
details = get_payment_term_details(
|
||||||
|
term, posting_date=posting_date, grand_total=100, base_grand_total=100
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(getdate(details.due_date), getdate(add_days(posting_date, 30)))
|
||||||
|
self.assertEqual(getdate(details.discount_date), getdate(add_days(posting_date, 10)))
|
||||||
|
|
||||||
def test_duplicate_terms(self):
|
def test_duplicate_terms(self):
|
||||||
template = frappe.get_doc(
|
template = frappe.get_doc(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from frappe.utils import today
|
|||||||
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
from erpnext.accounts.doctype.finance_book.test_finance_book import create_finance_book
|
||||||
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||||
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice
|
||||||
|
from erpnext.accounts.general_ledger import make_reverse_gl_entries
|
||||||
from erpnext.accounts.utils import get_fiscal_year
|
from erpnext.accounts.utils import get_fiscal_year
|
||||||
from erpnext.tests.utils import ERPNextTestSuite
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
@@ -333,6 +334,48 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
|||||||
|
|
||||||
return pcv
|
return pcv
|
||||||
|
|
||||||
|
@ERPNextTestSuite.change_settings(
|
||||||
|
"Accounts Settings",
|
||||||
|
{"enable_immutable_ledger": 1},
|
||||||
|
)
|
||||||
|
def test_immutable_ledger_reverse_entry_uses_passed_posting_date_after_pcv(self):
|
||||||
|
company = create_company()
|
||||||
|
cost_center = create_cost_center("Test Cost Center 1")
|
||||||
|
|
||||||
|
jv = make_journal_entry(
|
||||||
|
posting_date="2021-03-15",
|
||||||
|
amount=400,
|
||||||
|
account1="Cash - TPC",
|
||||||
|
account2="Sales - TPC",
|
||||||
|
cost_center=cost_center,
|
||||||
|
company=company,
|
||||||
|
save=False,
|
||||||
|
)
|
||||||
|
jv.company = company
|
||||||
|
jv.save()
|
||||||
|
jv.submit()
|
||||||
|
|
||||||
|
self.make_period_closing_voucher(posting_date="2021-03-31")
|
||||||
|
|
||||||
|
# Passed posting_date is after PCV end date, so cancellation should not fail.
|
||||||
|
make_reverse_gl_entries(
|
||||||
|
voucher_type="Journal Entry",
|
||||||
|
voucher_no=jv.name,
|
||||||
|
posting_date="2022-01-01",
|
||||||
|
)
|
||||||
|
|
||||||
|
totals_after_cancel = frappe.db.sql(
|
||||||
|
"""
|
||||||
|
select sum(debit) as total_debit, sum(credit) as total_credit
|
||||||
|
from `tabGL Entry`
|
||||||
|
where voucher_type=%s and voucher_no=%s and is_cancelled=0
|
||||||
|
""",
|
||||||
|
("Journal Entry", jv.name),
|
||||||
|
as_dict=True,
|
||||||
|
)[0]
|
||||||
|
|
||||||
|
self.assertEqual(totals_after_cancel.total_debit, totals_after_cancel.total_credit)
|
||||||
|
|
||||||
|
|
||||||
def create_company():
|
def create_company():
|
||||||
company = frappe.get_doc(
|
company = frappe.get_doc(
|
||||||
|
|||||||
@@ -26,8 +26,6 @@
|
|||||||
"due_date",
|
"due_date",
|
||||||
"amended_from",
|
"amended_from",
|
||||||
"return_against",
|
"return_against",
|
||||||
"section_break_clmv",
|
|
||||||
"title",
|
|
||||||
"accounting_dimensions_section",
|
"accounting_dimensions_section",
|
||||||
"project",
|
"project",
|
||||||
"dimension_col_break",
|
"dimension_col_break",
|
||||||
@@ -172,6 +170,7 @@
|
|||||||
"is_discounted",
|
"is_discounted",
|
||||||
"col_break23",
|
"col_break23",
|
||||||
"status",
|
"status",
|
||||||
|
"title",
|
||||||
"more_info",
|
"more_info",
|
||||||
"debit_to",
|
"debit_to",
|
||||||
"party_account_currency",
|
"party_account_currency",
|
||||||
@@ -1625,10 +1624,6 @@
|
|||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break",
|
||||||
"label": "Auto Repeat"
|
"label": "Auto Repeat"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"fieldname": "section_break_clmv",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"allow_on_submit": 1,
|
"allow_on_submit": 1,
|
||||||
"fieldname": "title",
|
"fieldname": "title",
|
||||||
@@ -1641,7 +1636,7 @@
|
|||||||
"icon": "fa fa-file-text",
|
"icon": "fa fa-file-text",
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-05-01 02:37:30.580568",
|
"modified": "2026-05-28 12:22:50.253090",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "POS Invoice",
|
"name": "POS Invoice",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
|
|||||||
pos_inv3.load_from_db()
|
pos_inv3.load_from_db()
|
||||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
|
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
|
||||||
|
|
||||||
self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice)
|
self.assertNotEqual(pos_inv.consolidated_invoice, pos_inv3.consolidated_invoice)
|
||||||
|
|
||||||
def test_consolidated_credit_note_creation(self):
|
def test_consolidated_credit_note_creation(self):
|
||||||
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
|
pos_inv = create_pos_invoice(rate=300, do_not_submit=1)
|
||||||
@@ -454,12 +454,12 @@ class TestPOSInvoiceMergeLog(ERPNextTestSuite):
|
|||||||
pos_inv2.load_from_db()
|
pos_inv2.load_from_db()
|
||||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv2.consolidated_invoice))
|
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv2.consolidated_invoice))
|
||||||
|
|
||||||
self.assertFalse(pos_inv.consolidated_invoice == pos_inv3.consolidated_invoice)
|
self.assertNotEqual(pos_inv.consolidated_invoice, pos_inv3.consolidated_invoice)
|
||||||
|
|
||||||
pos_inv3.load_from_db()
|
pos_inv3.load_from_db()
|
||||||
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
|
self.assertTrue(frappe.db.exists("Sales Invoice", pos_inv3.consolidated_invoice))
|
||||||
|
|
||||||
self.assertTrue(pos_inv2.consolidated_invoice == pos_inv3.consolidated_invoice)
|
self.assertEqual(pos_inv2.consolidated_invoice, pos_inv3.consolidated_invoice)
|
||||||
|
|
||||||
def test_company_in_pos_invoice_merge_log(self):
|
def test_company_in_pos_invoice_merge_log(self):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -209,15 +209,14 @@ class POSProfile(Document):
|
|||||||
def set_defaults(self, include_current_pos=True):
|
def set_defaults(self, include_current_pos=True):
|
||||||
frappe.defaults.clear_default("is_pos")
|
frappe.defaults.clear_default("is_pos")
|
||||||
|
|
||||||
if not include_current_pos:
|
pfu = frappe.qb.DocType("POS Profile User")
|
||||||
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
|
|
||||||
else:
|
|
||||||
condition = " where pfu.default = 1 "
|
|
||||||
|
|
||||||
pos_view_users = frappe.db.sql_list(
|
query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
|
||||||
f"""select pfu.user
|
|
||||||
from `tabPOS Profile User` as pfu {condition}"""
|
if not include_current_pos:
|
||||||
)
|
query = query.where(pfu.name != self.name)
|
||||||
|
|
||||||
|
pos_view_users = query.run(as_list=1, pluck=True)
|
||||||
|
|
||||||
for user in pos_view_users:
|
for user in pos_view_users:
|
||||||
if user:
|
if user:
|
||||||
|
|||||||
@@ -151,13 +151,13 @@
|
|||||||
"label": "Default Advance Account",
|
"label": "Default Advance Account",
|
||||||
"mandatory_depends_on": "doc.party_type",
|
"mandatory_depends_on": "doc.party_type",
|
||||||
"options": "Account",
|
"options": "Account",
|
||||||
"reqd": 1
|
"reqd": 0
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-01-08 08:22:14.798085",
|
"modified": "2026-05-16 11:43:12.758685",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Process Payment Reconciliation",
|
"name": "Process Payment Reconciliation",
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class ProcessPaymentReconciliation(Document):
|
|||||||
bank_cash_account: DF.Link | None
|
bank_cash_account: DF.Link | None
|
||||||
company: DF.Link
|
company: DF.Link
|
||||||
cost_center: DF.Link | None
|
cost_center: DF.Link | None
|
||||||
default_advance_account: DF.Link
|
default_advance_account: DF.Link | None
|
||||||
error_log: DF.LongText | None
|
error_log: DF.LongText | None
|
||||||
from_invoice_date: DF.Date | None
|
from_invoice_date: DF.Date | None
|
||||||
from_payment_date: DF.Date | None
|
from_payment_date: DF.Date | None
|
||||||
@@ -218,10 +218,7 @@ def trigger_reconciliation_for_queued_docs():
|
|||||||
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
|
fields = ["company", "party_type", "party", "receivable_payable_account", "default_advance_account"]
|
||||||
|
|
||||||
def get_filters_as_tuple(fields, doc):
|
def get_filters_as_tuple(fields, doc):
|
||||||
filters = ()
|
return tuple(doc.get(x) or "" for x in fields)
|
||||||
for x in fields:
|
|
||||||
filters += tuple(doc.get(x))
|
|
||||||
return filters
|
|
||||||
|
|
||||||
for x in all_queued:
|
for x in all_queued:
|
||||||
doc = frappe.get_doc("Process Payment Reconciliation", x)
|
doc = frappe.get_doc("Process Payment Reconciliation", x)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"actions": [],
|
"actions": [],
|
||||||
|
"allow_bulk_edit": 1,
|
||||||
"autoname": "format:Process-PCV-{###}",
|
"autoname": "format:Process-PCV-{###}",
|
||||||
"creation": "2025-09-25 15:44:03.534699",
|
"creation": "2025-09-25 15:44:03.534699",
|
||||||
"doctype": "DocType",
|
"doctype": "DocType",
|
||||||
@@ -7,11 +8,13 @@
|
|||||||
"field_order": [
|
"field_order": [
|
||||||
"parent_pcv",
|
"parent_pcv",
|
||||||
"status",
|
"status",
|
||||||
|
"amended_from",
|
||||||
|
"section_normal_balances",
|
||||||
"p_l_closing_balance",
|
"p_l_closing_balance",
|
||||||
"normal_balances",
|
|
||||||
"bs_closing_balance",
|
"bs_closing_balance",
|
||||||
"z_opening_balances",
|
"normal_balances",
|
||||||
"amended_from"
|
"section_opening_balances",
|
||||||
|
"z_opening_balances"
|
||||||
],
|
],
|
||||||
"fields": [
|
"fields": [
|
||||||
{
|
{
|
||||||
@@ -64,17 +67,27 @@
|
|||||||
"fieldname": "bs_closing_balance",
|
"fieldname": "bs_closing_balance",
|
||||||
"fieldtype": "JSON",
|
"fieldtype": "JSON",
|
||||||
"label": "Balance Sheet Closing Balance"
|
"label": "Balance Sheet Closing Balance"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_normal_balances",
|
||||||
|
"fieldtype": "Tab Break",
|
||||||
|
"label": "Normal Balances"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "section_opening_balances",
|
||||||
|
"fieldtype": "Tab Break",
|
||||||
|
"label": "Opening Balances"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
"index_web_pages_for_search": 1,
|
"index_web_pages_for_search": 1,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2025-11-05 11:40:24.996403",
|
"modified": "2026-06-01 12:16:37.374412",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Process Period Closing Voucher",
|
"name": "Process Period Closing Voucher",
|
||||||
"naming_rule": "Expression",
|
"naming_rule": "Expression (old style)",
|
||||||
"owner": "Administrator",
|
"owner": "Administrator",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ class ProcessPeriodClosingVoucher(Document):
|
|||||||
parent_pcv: DF.Link
|
parent_pcv: DF.Link
|
||||||
status: DF.Literal["Queued", "Running", "Paused", "Completed", "Cancelled"]
|
status: DF.Literal["Queued", "Running", "Paused", "Completed", "Cancelled"]
|
||||||
z_opening_balances: DF.Table[ProcessPeriodClosingVoucherDetail]
|
z_opening_balances: DF.Table[ProcessPeriodClosingVoucherDetail]
|
||||||
|
|
||||||
# end: auto-generated types
|
# end: auto-generated types
|
||||||
|
|
||||||
def on_discard(self):
|
def on_discard(self):
|
||||||
self.db_set("status", "Cancelled")
|
self.db_set("status", "Cancelled")
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ class ProcessPeriodClosingVoucher(Document):
|
|||||||
pcv = frappe.get_doc("Period Closing Voucher", self.parent_pcv)
|
pcv = frappe.get_doc("Period Closing Voucher", self.parent_pcv)
|
||||||
if pcv.is_first_period_closing_voucher():
|
if pcv.is_first_period_closing_voucher():
|
||||||
gl = qb.DocType("GL Entry")
|
gl = qb.DocType("GL Entry")
|
||||||
min = qb.from_(gl).select(Min(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0]
|
min = qb.from_(gl).select(Min(gl.posting_date)).run()[0][0]
|
||||||
max = qb.from_(gl).select(Max(gl.posting_date)).where(gl.company.eq(pcv.company)).run()[0][0]
|
max = qb.from_(gl).select(Max(gl.posting_date)).run()[0][0]
|
||||||
|
|
||||||
dates = self.get_dates(get_datetime(min), get_datetime(max))
|
dates = self.get_dates(get_datetime(min), get_datetime(max))
|
||||||
for x in dates:
|
for x in dates:
|
||||||
@@ -93,12 +93,16 @@ class ProcessPeriodClosingVoucher(Document):
|
|||||||
def start_pcv_processing(docname: str):
|
def start_pcv_processing(docname: str):
|
||||||
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
if frappe.db.get_value("Process Period Closing Voucher", docname, "status") in ["Queued", "Running"]:
|
||||||
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
frappe.db.set_value("Process Period Closing Voucher", docname, "status", "Running")
|
||||||
if normal_balances := frappe.db.get_all(
|
|
||||||
"Process Period Closing Voucher Detail",
|
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||||
filters={"parent": docname, "status": "Queued"},
|
if normal_balances := (
|
||||||
fields=["processing_date", "report_type", "parentfield"],
|
qb.from_(ppcvd)
|
||||||
order_by="parentfield, idx, processing_date",
|
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||||
limit=4,
|
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||||
|
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||||
|
.limit(4)
|
||||||
|
.for_update(skip_locked=True)
|
||||||
|
.run(as_dict=True)
|
||||||
):
|
):
|
||||||
if not is_scheduler_inactive():
|
if not is_scheduler_inactive():
|
||||||
for x in normal_balances:
|
for x in normal_balances:
|
||||||
@@ -133,9 +137,10 @@ def pause_pcv_processing(docname: str):
|
|||||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||||
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
qb.update(ppcv).set(ppcv.status, "Paused").where(ppcv.name.eq(docname)).run()
|
||||||
|
|
||||||
|
# If a date is stuck in 'Running' state, this will allow it to procced.
|
||||||
if queued_dates := frappe.db.get_all(
|
if queued_dates := frappe.db.get_all(
|
||||||
"Process Period Closing Voucher Detail",
|
"Process Period Closing Voucher Detail",
|
||||||
filters={"parent": docname, "status": "Queued"},
|
filters={"parent": docname, "status": ["in", ["Queued", "Running"]]},
|
||||||
pluck="name",
|
pluck="name",
|
||||||
):
|
):
|
||||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||||
@@ -169,6 +174,9 @@ def resume_pcv_processing(docname: str):
|
|||||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||||
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
|
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
|
||||||
start_pcv_processing(docname)
|
start_pcv_processing(docname)
|
||||||
|
else:
|
||||||
|
# If a parent doc is stuck in 'Running' state, will allow it to proceed.
|
||||||
|
schedule_next_date(docname)
|
||||||
|
|
||||||
|
|
||||||
def update_default_dimensions(dimension_fields, gl_entry, dimension_values):
|
def update_default_dimensions(dimension_fields, gl_entry, dimension_values):
|
||||||
@@ -238,12 +246,15 @@ def get_gle_for_closing_account(pcv, dimension_balance, dimensions):
|
|||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def schedule_next_date(docname: str):
|
def schedule_next_date(docname: str):
|
||||||
if to_process := frappe.db.get_all(
|
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||||
"Process Period Closing Voucher Detail",
|
if to_process := (
|
||||||
filters={"parent": docname, "status": "Queued"},
|
qb.from_(ppcvd)
|
||||||
fields=["processing_date", "report_type", "parentfield"],
|
.select(ppcvd.processing_date, ppcvd.report_type, ppcvd.parentfield)
|
||||||
order_by="parentfield, idx, processing_date",
|
.where(ppcvd.parent.eq(docname) & ppcvd.status.eq("Queued"))
|
||||||
limit=1,
|
.orderby(ppcvd.parentfield, ppcvd.idx, ppcvd.processing_date)
|
||||||
|
.limit(1)
|
||||||
|
.for_update(skip_locked=True)
|
||||||
|
.run(as_dict=True)
|
||||||
):
|
):
|
||||||
if not is_scheduler_inactive():
|
if not is_scheduler_inactive():
|
||||||
frappe.db.set_value(
|
frappe.db.set_value(
|
||||||
@@ -281,7 +292,21 @@ def schedule_next_date(docname: str):
|
|||||||
)
|
)
|
||||||
# Ensure both normal and opening balances are processed for all dates
|
# Ensure both normal and opening balances are processed for all dates
|
||||||
if total_no_of_dates == completed:
|
if total_no_of_dates == completed:
|
||||||
summarize_and_post_ledger_entries(docname)
|
from erpnext.accounts.doctype.process_payment_reconciliation.process_payment_reconciliation import (
|
||||||
|
is_job_running,
|
||||||
|
)
|
||||||
|
|
||||||
|
job_name = f"summarize_{docname}"
|
||||||
|
if not is_job_running(job_name):
|
||||||
|
frappe.enqueue(
|
||||||
|
method="erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher.summarize_and_post_ledger_entries",
|
||||||
|
queue="long",
|
||||||
|
timeout="3600",
|
||||||
|
is_async=True,
|
||||||
|
job_name=job_name,
|
||||||
|
enqueue_after_commit=True,
|
||||||
|
docname=docname,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def make_dict_json_compliant(dimension_wise_balance) -> dict:
|
def make_dict_json_compliant(dimension_wise_balance) -> dict:
|
||||||
@@ -537,6 +562,9 @@ def process_individual_date(docname: str, date, report_type, parentfield):
|
|||||||
|
|
||||||
if parentfield == "z_opening_balances":
|
if parentfield == "z_opening_balances":
|
||||||
query = query.where(gle.is_opening.eq("Yes"))
|
query = query.where(gle.is_opening.eq("Yes"))
|
||||||
|
else:
|
||||||
|
# Keep balances aligned with legacy PCV logic (non-opening transactions only)
|
||||||
|
query = query.where(gle.is_opening.eq("No"))
|
||||||
|
|
||||||
query = query.groupby(gle.account)
|
query = query.groupby(gle.account)
|
||||||
for dim in dimensions:
|
for dim in dimensions:
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
|
// License: GNU General Public License v3. See license.txt
|
||||||
|
|
||||||
|
// render
|
||||||
|
frappe.listview_settings["Process Period Closing Voucher"] = {
|
||||||
|
add_fields: ["status"],
|
||||||
|
get_indicator: function (doc) {
|
||||||
|
const status_colors = {
|
||||||
|
Queued: "blue",
|
||||||
|
Running: "orange",
|
||||||
|
Paused: "gray",
|
||||||
|
Completed: "green",
|
||||||
|
Cancelled: "red",
|
||||||
|
};
|
||||||
|
return [__(doc.status), status_colors[doc.status], "status,=," + doc.status];
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,4 +1,173 @@
|
|||||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||||
# See license.txt
|
# See license.txt
|
||||||
|
|
||||||
# import frappe
|
import frappe
|
||||||
|
from frappe.utils import today
|
||||||
|
|
||||||
|
from erpnext.accounts.doctype.journal_entry.test_journal_entry import make_journal_entry
|
||||||
|
from erpnext.accounts.doctype.process_period_closing_voucher.process_period_closing_voucher import (
|
||||||
|
process_individual_date,
|
||||||
|
)
|
||||||
|
from erpnext.accounts.utils import get_fiscal_year
|
||||||
|
from erpnext.tests.utils import ERPNextTestSuite
|
||||||
|
|
||||||
|
|
||||||
|
class TestProcessPeriodClosingVoucher(ERPNextTestSuite):
|
||||||
|
def setUp(self):
|
||||||
|
frappe.db.set_single_value("Accounts Settings", "use_legacy_controller_for_pcv", 0)
|
||||||
|
self.company = "_Test Company"
|
||||||
|
|
||||||
|
def make_period_closing_voucher(self, posting_date, submit=True):
|
||||||
|
fy = get_fiscal_year(posting_date, company="_Test Company")
|
||||||
|
pcv = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Period Closing Voucher",
|
||||||
|
"transaction_date": posting_date or today(),
|
||||||
|
"period_start_date": fy[1],
|
||||||
|
"period_end_date": fy[2],
|
||||||
|
"company": self.company,
|
||||||
|
"fiscal_year": fy[0],
|
||||||
|
"closing_account_head": "Retained Earnings - _TC",
|
||||||
|
"remarks": "closing",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
pcv.insert()
|
||||||
|
if submit:
|
||||||
|
pcv.submit()
|
||||||
|
|
||||||
|
return pcv
|
||||||
|
|
||||||
|
def make_process_pcv(self):
|
||||||
|
self.pcv = self.make_period_closing_voucher(posting_date=today(), submit=False)
|
||||||
|
ppcv = frappe.get_doc(
|
||||||
|
{
|
||||||
|
"doctype": "Process Period Closing Voucher",
|
||||||
|
"parent_pcv": self.pcv.name,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ppcv.save()
|
||||||
|
return ppcv
|
||||||
|
|
||||||
|
def set_processing_date_status(self, date, ppcv, rpt_type, parentfield, status):
|
||||||
|
frappe.db.set_value(
|
||||||
|
"Process Period Closing Voucher Detail",
|
||||||
|
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||||
|
"status",
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_processing_date_closing_balance(self, date, ppcv, rpt_type, parentfield):
|
||||||
|
return frappe.db.get_value(
|
||||||
|
"Process Period Closing Voucher Detail",
|
||||||
|
{"processing_date": date, "parent": ppcv, "report_type": rpt_type, "parentfield": parentfield},
|
||||||
|
"closing_balance",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_opening_balance_double_counting(self):
|
||||||
|
ppcv = self.make_process_pcv()
|
||||||
|
self.assertEqual(self.pcv.is_first_period_closing_voucher(), True)
|
||||||
|
opening_jv = make_journal_entry(
|
||||||
|
posting_date=today(),
|
||||||
|
amount=10,
|
||||||
|
account1="Cash - _TC",
|
||||||
|
account2="Debtors - _TC",
|
||||||
|
company=self.company,
|
||||||
|
save=False,
|
||||||
|
)
|
||||||
|
opening_jv.accounts[1].party_type = "Customer"
|
||||||
|
opening_jv.accounts[1].party = "_Test Customer"
|
||||||
|
opening_jv.is_opening = "Yes"
|
||||||
|
opening_jv.save()
|
||||||
|
opening_jv.submit()
|
||||||
|
|
||||||
|
jv = make_journal_entry(
|
||||||
|
posting_date=today(),
|
||||||
|
amount=120,
|
||||||
|
account1="Debtors - _TC",
|
||||||
|
account2="Sales - _TC",
|
||||||
|
company=self.company,
|
||||||
|
save=False,
|
||||||
|
)
|
||||||
|
jv.accounts[0].party_type = "Customer"
|
||||||
|
jv.accounts[0].party = "_Test Customer"
|
||||||
|
jv.save()
|
||||||
|
jv.submit()
|
||||||
|
|
||||||
|
# P&L balance
|
||||||
|
parentfield = "normal_balances"
|
||||||
|
rpt_type = "Profit and Loss"
|
||||||
|
# status has to be set to 'Running' for logic to run
|
||||||
|
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||||
|
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||||
|
bal = frappe.parse_json(
|
||||||
|
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(bal), 1)
|
||||||
|
expected_pl = {
|
||||||
|
"account": "Sales - _TC",
|
||||||
|
"cost_center": "_Test Cost Center - _TC",
|
||||||
|
"debit": 0.0,
|
||||||
|
"credit": 120.0,
|
||||||
|
"debit_in_account_currency": 0.0,
|
||||||
|
"credit_in_account_currency": 120.0,
|
||||||
|
}
|
||||||
|
for k in expected_pl.keys():
|
||||||
|
with self.subTest(k):
|
||||||
|
self.assertEqual(expected_pl[k], bal[0][k])
|
||||||
|
|
||||||
|
# Balance sheet balance
|
||||||
|
rpt_type = "Balance Sheet"
|
||||||
|
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||||
|
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||||
|
bal = frappe.parse_json(
|
||||||
|
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(bal), 1)
|
||||||
|
expected_bs = {
|
||||||
|
"account": "Debtors - _TC",
|
||||||
|
"cost_center": "_Test Cost Center - _TC",
|
||||||
|
"debit": 120.0,
|
||||||
|
"credit": 0.0,
|
||||||
|
"debit_in_account_currency": 120.0,
|
||||||
|
"credit_in_account_currency": 0.0,
|
||||||
|
}
|
||||||
|
for k in expected_bs.keys():
|
||||||
|
with self.subTest(k):
|
||||||
|
self.assertEqual(expected_bs[k], bal[0][k])
|
||||||
|
|
||||||
|
# Opening balance
|
||||||
|
parentfield = "z_opening_balances"
|
||||||
|
rpt_type = "Balance Sheet"
|
||||||
|
self.set_processing_date_status(today(), ppcv.name, rpt_type, parentfield, "Running")
|
||||||
|
process_individual_date(ppcv.name, today(), rpt_type, parentfield)
|
||||||
|
bal = frappe.parse_json(
|
||||||
|
self.get_processing_date_closing_balance(today(), ppcv.name, rpt_type, parentfield)
|
||||||
|
)
|
||||||
|
self.assertEqual(len(bal), 2)
|
||||||
|
opening_cash = next(x for x in bal if x["account"] == "Cash - _TC")
|
||||||
|
expected_opening_cash = {
|
||||||
|
"account": "Cash - _TC",
|
||||||
|
"cost_center": "_Test Cost Center - _TC",
|
||||||
|
"debit": 10.0,
|
||||||
|
"credit": 0.0,
|
||||||
|
"debit_in_account_currency": 10.0,
|
||||||
|
"credit_in_account_currency": 0.0,
|
||||||
|
"account_currency": "INR",
|
||||||
|
}
|
||||||
|
for k in expected_opening_cash.keys():
|
||||||
|
with self.subTest(k):
|
||||||
|
self.assertEqual(expected_opening_cash[k], opening_cash[k])
|
||||||
|
|
||||||
|
opening_debtors = next(x for x in bal if x["account"] == "Debtors - _TC")
|
||||||
|
expected_opening_debtors = {
|
||||||
|
"account": "Debtors - _TC",
|
||||||
|
"cost_center": "_Test Cost Center - _TC",
|
||||||
|
"debit": 0.0,
|
||||||
|
"credit": 10.0,
|
||||||
|
"debit_in_account_currency": 0.0,
|
||||||
|
"credit_in_account_currency": 10.0,
|
||||||
|
"account_currency": "INR",
|
||||||
|
}
|
||||||
|
for k in expected_opening_debtors.keys():
|
||||||
|
with self.subTest(k):
|
||||||
|
self.assertEqual(expected_opening_debtors[k], opening_debtors[k])
|
||||||
|
|||||||
@@ -591,6 +591,25 @@ frappe.ui.form.on("Purchase Invoice", {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
frm.set_query("write_off_account", function (doc) {
|
||||||
|
return {
|
||||||
|
filters: {
|
||||||
|
report_type: "Profit and Loss",
|
||||||
|
is_group: 0,
|
||||||
|
company: doc.company,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
frm.set_query("write_off_cost_center", function (doc) {
|
||||||
|
return {
|
||||||
|
filters: {
|
||||||
|
is_group: 0,
|
||||||
|
company: doc.company,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) {
|
frm.fields_dict["items"].grid.get_field("deferred_expense_account").get_query = function (doc) {
|
||||||
return {
|
return {
|
||||||
filters: {
|
filters: {
|
||||||
|
|||||||
@@ -28,8 +28,6 @@
|
|||||||
"update_billed_amount_in_purchase_receipt",
|
"update_billed_amount_in_purchase_receipt",
|
||||||
"apply_tds",
|
"apply_tds",
|
||||||
"amended_from",
|
"amended_from",
|
||||||
"section_break_ecfi",
|
|
||||||
"title",
|
|
||||||
"supplier_invoice_details",
|
"supplier_invoice_details",
|
||||||
"bill_no",
|
"bill_no",
|
||||||
"column_break_15",
|
"column_break_15",
|
||||||
@@ -202,6 +200,7 @@
|
|||||||
"hold_comment",
|
"hold_comment",
|
||||||
"additional_info_section",
|
"additional_info_section",
|
||||||
"is_internal_supplier",
|
"is_internal_supplier",
|
||||||
|
"title",
|
||||||
"represents_company",
|
"represents_company",
|
||||||
"supplier_group",
|
"supplier_group",
|
||||||
"sender",
|
"sender",
|
||||||
@@ -1684,10 +1683,6 @@
|
|||||||
"fieldname": "automation_section",
|
"fieldname": "automation_section",
|
||||||
"fieldtype": "Section Break",
|
"fieldtype": "Section Break",
|
||||||
"label": "Automation"
|
"label": "Automation"
|
||||||
},
|
|
||||||
{
|
|
||||||
"fieldname": "section_break_ecfi",
|
|
||||||
"fieldtype": "Section Break"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"grid_page_length": 50,
|
"grid_page_length": 50,
|
||||||
@@ -1695,7 +1690,7 @@
|
|||||||
"idx": 204,
|
"idx": 204,
|
||||||
"is_submittable": 1,
|
"is_submittable": 1,
|
||||||
"links": [],
|
"links": [],
|
||||||
"modified": "2026-05-04 10:10:11.717131",
|
"modified": "2026-05-28 12:36:55.215363",
|
||||||
"modified_by": "Administrator",
|
"modified_by": "Administrator",
|
||||||
"module": "Accounts",
|
"module": "Accounts",
|
||||||
"name": "Purchase Invoice",
|
"name": "Purchase Invoice",
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user