mirror of
https://github.com/frappe/erpnext.git
synced 2026-06-11 17:03:03 +00:00
Compare commits
3 Commits
assets-ver
...
fix/operat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdecfcab9b | ||
|
|
7a9fd6937d | ||
|
|
95c0d49a0a |
52
.github/helper/merge_po_files.py
vendored
52
.github/helper/merge_po_files.py
vendored
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Overlay develop's .po translations onto hotfix's .po files.
|
||||
|
||||
Called by sync_hotfix_translations.sh before `bench update-po-files`.
|
||||
Merge rules:
|
||||
a. msgid absent from develop → keep hotfix's existing msgstr
|
||||
b. language not yet in hotfix → copy file as-is (bench will filter to main.pot)
|
||||
c. msgid present in both → use develop's msgstr
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from babel.messages.pofile import read_po, write_po
|
||||
|
||||
DEVELOP = Path("/tmp/develop-po/erpnext/locale/")
|
||||
LOCALE = Path("./apps/erpnext/erpnext/locale/")
|
||||
|
||||
added = updated = 0
|
||||
|
||||
for src in sorted(DEVELOP.glob("*.po")):
|
||||
dst = LOCALE / src.name
|
||||
|
||||
with src.open("rb") as f:
|
||||
dev = read_po(f)
|
||||
|
||||
if not dst.exists():
|
||||
dev.revision_date = datetime.now(timezone.utc)
|
||||
with dst.open("wb") as f:
|
||||
write_po(f, dev)
|
||||
added += 1
|
||||
print(f" [new] {src.name}")
|
||||
continue
|
||||
|
||||
with dst.open("rb") as f:
|
||||
hf = read_po(f)
|
||||
|
||||
changes = 0
|
||||
for msg in hf:
|
||||
if msg.id and msg.id in dev and dev[msg.id].string and dev[msg.id].string != msg.string:
|
||||
msg.string = dev[msg.id].string
|
||||
changes += 1
|
||||
|
||||
if changes:
|
||||
hf.revision_date = datetime.now(timezone.utc)
|
||||
with dst.open("wb") as f:
|
||||
write_po(f, hf)
|
||||
updated += 1
|
||||
print(f" [updated] {src.name} ({changes} msgstr(s) from develop)")
|
||||
else:
|
||||
print(f" [no-op] {src.name}")
|
||||
|
||||
print(f"\n{added} new language(s), {updated} updated.")
|
||||
121
.github/helper/sync_hotfix_translations.sh
vendored
121
.github/helper/sync_hotfix_translations.sh
vendored
@@ -1,121 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Syncs Crowdin translations from develop to a hotfix branch.
|
||||
# Merge logic: see merge_po_files.py.
|
||||
# Env: GH_TOKEN, PR_REVIEWER, GITHUB_WORKSPACE, APP_NAME, GITHUB_REPOSITORY
|
||||
# (all set by Actions).
|
||||
|
||||
set -e
|
||||
|
||||
HOTFIX_BRANCH="${HOTFIX_BRANCH:?HOTFIX_BRANCH env var is required}"
|
||||
APP_NAME="${APP_NAME:?APP_NAME env var is required}"
|
||||
|
||||
cd ~ || exit
|
||||
|
||||
echo "=== Setting up bench ==="
|
||||
pip install frappe-bench
|
||||
bench -v init frappe-bench --skip-assets --skip-redis-config-generation --python "$(which python)"
|
||||
cd ./frappe-bench || exit
|
||||
bench get-app --skip-assets "${APP_NAME}" "${GITHUB_WORKSPACE}"
|
||||
|
||||
echo "=== Setting up sync_translations_${HOTFIX_BRANCH} branch ==="
|
||||
cd "./apps/${APP_NAME}" || exit
|
||||
git config user.email "developers@erpnext.com"
|
||||
git config user.name "frappe-pr-bot"
|
||||
git remote set-url upstream "https://github.com/${GITHUB_REPOSITORY}.git"
|
||||
git config remote.upstream.fetch "+refs/heads/*:refs/remotes/upstream/*"
|
||||
gh auth setup-git
|
||||
git fetch upstream "${HOTFIX_BRANCH}"
|
||||
|
||||
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
|
||||
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
|
||||
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/sync_translations_${HOTFIX_BRANCH}"
|
||||
git merge -X theirs "upstream/${HOTFIX_BRANCH}" --no-edit
|
||||
else
|
||||
git checkout -b "sync_translations_${HOTFIX_BRANCH}" "upstream/${HOTFIX_BRANCH}"
|
||||
fi
|
||||
cd ../.. || exit
|
||||
|
||||
echo "=== Fetching develop's .po files ==="
|
||||
mkdir -p /tmp/develop-po
|
||||
git -C "${GITHUB_WORKSPACE}" fetch origin develop
|
||||
git -C "${GITHUB_WORKSPACE}" archive origin/develop "${APP_NAME}/locale/" \
|
||||
| tar -xf - -C /tmp/develop-po/
|
||||
|
||||
po_count=$(find "/tmp/develop-po/${APP_NAME}/locale" -name "*.po" | wc -l)
|
||||
if [ "${po_count}" -eq 0 ]; then
|
||||
echo "ERROR: No .po files found in develop's archive. Aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Extracted ${po_count} .po file(s) from develop."
|
||||
|
||||
echo "=== Merging and reconciling ==="
|
||||
env/bin/python "${GITHUB_WORKSPACE}/.github/helper/merge_po_files.py"
|
||||
bench update-po-files --app "${APP_NAME}"
|
||||
|
||||
cd "./apps/${APP_NAME}" || exit
|
||||
|
||||
if git diff --quiet "${APP_NAME}/locale/" && [ -z "$(git ls-files --others --exclude-standard "${APP_NAME}/locale/")" ]; then
|
||||
echo "Translations are already up to date. No PR needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
git diff --name-only "${APP_NAME}/locale/"
|
||||
git ls-files --others --exclude-standard "${APP_NAME}/locale/"
|
||||
|
||||
echo "=== Committing ==="
|
||||
while IFS= read -r file; do
|
||||
git add "${file}"
|
||||
lang=$(basename "${file}" .po)
|
||||
git commit -m "chore: add ${lang} translation to ${HOTFIX_BRANCH}"
|
||||
done < <(git ls-files --others --exclude-standard "${APP_NAME}/locale/" | grep '\.po$' | sort)
|
||||
|
||||
while IFS= read -r file; do
|
||||
git add "${file}"
|
||||
if ! git diff --staged --quiet -- "${file}"; then
|
||||
lang=$(basename "${file}" .po)
|
||||
git commit -m "chore: sync ${lang} translation to ${HOTFIX_BRANCH}"
|
||||
else
|
||||
git restore --staged -- "${file}"
|
||||
fi
|
||||
done < <(git diff --name-only "${APP_NAME}/locale/" | grep '\.po$' | sort)
|
||||
|
||||
if git ls-remote --exit-code --heads upstream "sync_translations_${HOTFIX_BRANCH}" >/dev/null 2>&1; then
|
||||
git fetch upstream "sync_translations_${HOTFIX_BRANCH}"
|
||||
git merge -X ours "upstream/sync_translations_${HOTFIX_BRANCH}" --no-edit
|
||||
fi
|
||||
git push -u upstream sync_translations_${HOTFIX_BRANCH}
|
||||
|
||||
echo "=== Opening PR (if not already open) ==="
|
||||
existing_pr=$(gh pr list \
|
||||
--base "${HOTFIX_BRANCH}" \
|
||||
--head "sync_translations_${HOTFIX_BRANCH}" \
|
||||
--state open \
|
||||
--json number \
|
||||
--jq 'length' \
|
||||
-R "${GITHUB_REPOSITORY}")
|
||||
|
||||
if [ "${existing_pr}" -gt 0 ]; then
|
||||
echo "PR already open — branch updated in place. No new PR needed."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--base "${HOTFIX_BRANCH}" \
|
||||
--head "sync_translations_${HOTFIX_BRANCH}" \
|
||||
--title "chore: sync translations to ${HOTFIX_BRANCH}" \
|
||||
--body "Automated sync of Crowdin translations from \`develop\` to \`${HOTFIX_BRANCH}\`.
|
||||
|
||||
A 3-way merge is performed per language, then \`bench update-po-files\` reconciles each \`.po\` against hotfix's \`main.pot\`:
|
||||
|
||||
| Case | Condition | Result |
|
||||
|------|-----------|--------|
|
||||
| **a** | \`msgid\` in hotfix's \`main.pot\`, **not** in develop's \`.po\` | Hotfix's existing \`msgstr\` is **preserved** (string removed from develop but still needed in hotfix) |
|
||||
| **b** | \`msgid\` **not** in hotfix's \`main.pot\` | **Dropped** from hotfix's \`.po\` |
|
||||
| **c** | \`msgid\` in both hotfix's \`main.pot\` and develop's \`.po\` | Develop's \`msgstr\` is used (Crowdin translation wins) |
|
||||
|
||||
Generated by the \`sync-hotfix-translations\` workflow." \
|
||||
--label "translation" \
|
||||
--label "skip-release-notes" \
|
||||
--reviewer "${PR_REVIEWER}" \
|
||||
-R "${GITHUB_REPOSITORY}"
|
||||
70
.github/workflows/build-and-commit-assets.yml
vendored
70
.github/workflows/build-and-commit-assets.yml
vendored
@@ -1,70 +0,0 @@
|
||||
name: Build and Upload Assets
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- 'version-*'
|
||||
|
||||
concurrency:
|
||||
group: build-assets-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-assets:
|
||||
name: Build JS/CSS and upload to release
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: frappe/frappe
|
||||
path: apps/frappe
|
||||
ref: ${{ github.ref_name }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: apps/erpnext
|
||||
|
||||
- name: Create bench structure
|
||||
run: |
|
||||
mkdir -p sites
|
||||
printf "frappe\nerpnext\n" > sites/apps.txt
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
cache: yarn
|
||||
cache-dependency-path: apps/frappe/yarn.lock
|
||||
|
||||
- name: Install frappe JS dependencies
|
||||
working-directory: apps/frappe
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install erpnext JS dependencies
|
||||
working-directory: apps/erpnext
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Link node_modules into public/
|
||||
working-directory: apps/frappe
|
||||
run: ln -s "$PWD/node_modules" frappe/public/node_modules
|
||||
|
||||
- name: Build assets (production)
|
||||
working-directory: apps/frappe
|
||||
run: yarn run production
|
||||
|
||||
- name: Package assets
|
||||
working-directory: apps/erpnext
|
||||
run: tar czf erpnext-assets.tar.gz -C ../../sites/assets/erpnext dist
|
||||
|
||||
- name: Upload to rolling release
|
||||
working-directory: apps/erpnext
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="assets-${GITHUB_REF_NAME//\//-}"
|
||||
gh release create "$TAG" --prerelease --title "Assets: $GITHUB_REF_NAME" --notes "" 2>/dev/null || true
|
||||
gh release upload "$TAG" erpnext-assets.tar.gz --clobber
|
||||
@@ -1,52 +0,0 @@
|
||||
# Runner — maintain this file on each hotfix branch, not on develop.
|
||||
#
|
||||
# Fires when main.pot changes on this branch (i.e. after a POT update PR
|
||||
# merges), or when dispatched by the orchestrator on develop (weekly schedule).
|
||||
#
|
||||
# Uses github.ref_name so the file is identical across all hotfix branches
|
||||
# with no branch-specific edits required.
|
||||
|
||||
name: Run hotfix translation sync
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# One run at a time per branch. cancel-in-progress: false to avoid leaving
|
||||
# an orphaned remote branch from a mid-flight git push + gh pr create.
|
||||
concurrency:
|
||||
group: sync-hotfix-translations-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync-translations:
|
||||
name: Sync translations from develop into ${{ github.ref_name }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
HOTFIX_BRANCH: ${{ github.ref_name }}
|
||||
APP_NAME: ${{ github.event.repository.name }}
|
||||
|
||||
steps:
|
||||
- name: Checkout ${{ env.HOTFIX_BRANCH }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ env.HOTFIX_BRANCH }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Run sync script
|
||||
run: |
|
||||
bash "${GITHUB_WORKSPACE}/.github/helper/sync_hotfix_translations.sh"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
PR_REVIEWER: diptanilsaha
|
||||
36
.github/workflows/sync-hotfix-translations.yml
vendored
36
.github/workflows/sync-hotfix-translations.yml
vendored
@@ -1,36 +0,0 @@
|
||||
# Orchestrator — lives on develop only.
|
||||
#
|
||||
# Triggers on the weekly schedule and dispatches the runner workflow on each
|
||||
# hotfix branch listed in the matrix. To add or remove a branch, edit the
|
||||
# matrix below.
|
||||
#
|
||||
# POT-change triggers are handled by the runner on each hotfix branch
|
||||
# (run-hotfix-translation-sync.yml), since GitHub only evaluates a workflow
|
||||
# from the branch that receives the push.
|
||||
|
||||
name: Sync translations to hotfix branches
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# 10:00 UTC Monday
|
||||
- cron: "0 10 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
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,18 +9,16 @@ export default defineConfig([
|
||||
globalIgnores(["dist"]),
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
extends: [js.configs.recommended, tseslint.configs.recommended, reactRefresh.configs.vite],
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
"react-hooks/exhaustive-deps": "warn",
|
||||
"react-refresh/only-export-components": "off",
|
||||
},
|
||||
onlyExportComponents: false,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.15.0",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-virtuoso": "^4.18.6",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
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 common_site_config = require('../../../sites/common_site_config.json');
|
||||
const { webserver_port } = common_site_config;
|
||||
|
||||
export default {
|
||||
'^/(app|api|assets|files|private)': {
|
||||
target: `http://127.0.0.1:${webserver_port}`,
|
||||
ws: true,
|
||||
router: function (req) {
|
||||
const site_name = req.headers?.host?.split(':')[0];
|
||||
return `http://${site_name ?? 'localhost'}:${webserver_port}`;
|
||||
router: function(req) {
|
||||
const site_name = req.headers.host.split(':')[0];
|
||||
return `http://${site_name}:${webserver_port}`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { lazy, useEffect } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { FrappeProvider } from 'frappe-react-sdk'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import BankReconciliation from '@/pages/BankReconciliation'
|
||||
import BankStatementImporterContainer from '@/pages/BankStatementImporterContainer'
|
||||
import { TooltipProvider } from './components/ui/tooltip'
|
||||
import BankStatementImporter from '@/pages/BankStatementImporter'
|
||||
import { LucideProvider } from 'lucide-react'
|
||||
import { ThemeProvider } from './components/ui/theme-provider'
|
||||
|
||||
const BankStatementImporter = lazy(() => import('@/pages/BankStatementImporter'))
|
||||
const ViewBankStatementImportLog = lazy(() => import('@/pages/ViewBankStatementImportLog'))
|
||||
import ViewBankStatementImportLog from './pages/ViewBankStatementImportLog'
|
||||
import BankStatementImporterContainer from './pages/BankStatementImporterContainer'
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
@@ -44,6 +43,7 @@ function App() {
|
||||
>
|
||||
{window.frappe?.boot?.user?.name && window.frappe?.boot?.user?.name !== 'Guest' &&
|
||||
<BrowserRouter basename={import.meta.env.VITE_BASE_NAME ? `/${import.meta.env.VITE_BASE_NAME}` : ''}>
|
||||
|
||||
<Routes>
|
||||
<Route index element={<BankReconciliation />} />
|
||||
<Route path="/statement-importer" element={<BankStatementImporterContainer />}>
|
||||
|
||||
@@ -1,42 +1,475 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { HistoryIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { 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 { useHotkeys } from 'react-hotkeys-hook'
|
||||
import ActionLogDialog from './ActionLogDialog'
|
||||
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 ActionLog = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useHotkeys('meta+z', () => {
|
||||
setIsOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<HistoryIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Reconciliation History")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOpen && (
|
||||
<ActionLogDialog onClose={() => setIsOpen(false)} />
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
useHotkeys('meta+z', () => {
|
||||
setIsOpen(true)
|
||||
}, {
|
||||
enabled: true,
|
||||
enableOnFormTags: false,
|
||||
preventDefault: true
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<HistoryIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Reconciliation History")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<DialogContent className='min-w-[90vw]'>
|
||||
<DialogHeader>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionLog
|
||||
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
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { Loader2Icon } from 'lucide-react'
|
||||
import { lazy, Suspense } from 'react'
|
||||
|
||||
const ActionLogDialogBody = lazy(() => import('./ActionLogDialogBody'))
|
||||
|
||||
const ActionLogDialogFallback = () => (
|
||||
<div className="flex flex-1 items-center justify-center min-h-[40vh]">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const ActionLogDialog = ({ onClose }: { onClose: () => void }) => {
|
||||
return (
|
||||
<DialogContent className='min-w-[90vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Reconciliation History")}</DialogTitle>
|
||||
<DialogDescription>{_("View all reconciliation actions taken in this session.")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Suspense fallback={<ActionLogDialogFallback />}>
|
||||
<ActionLogDialogBody />
|
||||
</Suspense>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant={'outline'} size='md' onClick={onClose}>{_("Close")}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionLogDialog
|
||||
@@ -1,431 +0,0 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import _ from '@/lib/translate'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ArrowDownRight, ArrowRightLeftIcon, ArrowUpRight, CalendarIcon, CircleXIcon, GitCompareIcon, HistoryIcon, LandmarkIcon, Loader2Icon, ReceiptIcon, ReceiptTextIcon, UserIcon, WalletIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ActionLogItem, ActionLog as ActionLogType, bankRecActionLog, bankRecDateAtom, bankRecMatchFilters, SelectedBank, selectedBankAccountAtom } from '../BankReconciliation/bankRecAtoms'
|
||||
import { useGetBankAccounts } from '../BankReconciliation/utils'
|
||||
import { getCompanyCurrency } from '@/lib/company'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import dayjs from 'dayjs'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { slug } from '@/lib/frappe'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { JournalEntry } from '@/types/Accounts/JournalEntry'
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
|
||||
import { Table, TableCell, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
|
||||
import { useFrappePostCall, useSWRConfig } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import { getErrorMessage } from '@/lib/frappe'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import SelectedTransactionDetails from '../BankReconciliation/SelectedTransactionDetails'
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
|
||||
const ActionLogDialogBody = () => {
|
||||
|
||||
const actionLog = useAtomValue(bankRecActionLog)
|
||||
|
||||
return <div className='flex flex-col gap-2'>
|
||||
{actionLog.map((action) => (
|
||||
<div key={action.timestamp} className='flex flex-col gap-1'>
|
||||
<ActionGroupHeader action={action} />
|
||||
<div>
|
||||
<div className='ms-2 border-s border-s-outline-gray-2 py-1'>
|
||||
<div className='ms-5'>
|
||||
{action.items.map((item, index) => (
|
||||
<Row
|
||||
item={item}
|
||||
key={item.bankTransaction.name}
|
||||
index={index}
|
||||
action={action}
|
||||
isLast={index === action.items.length - 1} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{actionLog.length === 0 && <Empty>
|
||||
<EmptyMedia>
|
||||
<HistoryIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyHeader>
|
||||
<EmptyTitle>{_("No reconciliation actions found")}</EmptyTitle>
|
||||
<EmptyDescription>{_("You have not performed any reconciliations in this session yet.")}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>}
|
||||
</div>
|
||||
}
|
||||
|
||||
|
||||
|
||||
const ActionGroupHeader = ({ action }: { action: ActionLogType }) => {
|
||||
|
||||
const label = useMemo(() => {
|
||||
switch (action.type) {
|
||||
case 'match':
|
||||
return _("Matched")
|
||||
case 'payment':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Payment")
|
||||
}
|
||||
return _("Payment")
|
||||
|
||||
case 'transfer':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Transfer")
|
||||
}
|
||||
return _("Transfer")
|
||||
|
||||
case 'bank_entry':
|
||||
if (action.isBulk) {
|
||||
return _("Bulk Bank Entry")
|
||||
}
|
||||
return _("Bank Entry")
|
||||
|
||||
default:
|
||||
return _("Action")
|
||||
}
|
||||
}, [action])
|
||||
|
||||
return <div className='flex items-center gap-2 text-ink-gray-5'>
|
||||
{action.type === 'match' && <GitCompareIcon className='w-4 h-4' />}
|
||||
{action.type === 'payment' && <ReceiptIcon className='w-4 h-4' />}
|
||||
{action.type === 'transfer' && <ArrowRightLeftIcon className='w-4 h-4' />}
|
||||
{action.type === 'bank_entry' && <LandmarkIcon className='w-4 h-4' />}
|
||||
<span className='flex items-center gap-2 text-sm'>
|
||||
{label} - {dayjs(action.timestamp).fromNow()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const Row = ({ item, index, isLast, action }: { item: ActionLogItem, index: number, isLast: boolean, action: ActionLogType }) => {
|
||||
|
||||
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (item.bankTransaction.bank_account) {
|
||||
return banks?.find((bank) => bank.name === item.bankTransaction.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [item.bankTransaction.bank_account, banks])
|
||||
|
||||
const amount = item.bankTransaction.withdrawal ? item.bankTransaction.withdrawal : item.bankTransaction.deposit
|
||||
|
||||
const currency = item.bankTransaction.currency || getCompanyCurrency(item.bankTransaction.company ?? '')
|
||||
|
||||
return <div className='flex items-center gap-2 group'>
|
||||
<div className={cn('p-3.5 group-hover:bg-surface-gray-1 border-s border-e border-t w-full', isLast ? 'rounded-b border-b' : '', index === 0 ? 'rounded-t' : '')}>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<p className='text-p-base'>{item.bankTransaction.description}</p>
|
||||
<div className='flex items-center gap-3'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<BankLogo bank={bank} className='h-4 mb-0' iconSize='16px' />
|
||||
<span className='text-sm text-ink-gray-5'>{item.bankTransaction.bank_account}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<div className='flex items-center gap-2 text-ink-gray-5 text-sm' title={_("Transaction Date")}>
|
||||
<CalendarIcon className='w-4 h-4' />
|
||||
<span className='text-sm'>{formatDate(item.bankTransaction.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<div>
|
||||
<div className='flex items-center gap-1' title={isWithdrawal ? _("Spent") : _("Received")}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm text-ink-gray-5'>{formatCurrency(amount, currency)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end items-center gap-2'>
|
||||
<div className='text-end flex flex-col gap-2'>
|
||||
<a
|
||||
href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`}
|
||||
target='_blank'
|
||||
className='underline underline-offset-4 text-base'>
|
||||
{["Payment Entry", "Journal Entry"].includes(item.voucher.reference_doctype) ? "" : _("{} :", [item.voucher.reference_doctype])} {item.voucher.reference_name}
|
||||
</a>
|
||||
{item.voucher.reference_doctype === "Payment Entry" && item.voucher.doc && <PaymentEntryDetails item={item} />}
|
||||
{item.voucher.reference_doctype === "Journal Entry" && <JournalEntryDetails item={item} bank={bank} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='w-10 h-10 flex items-center justify-center'>
|
||||
<CancelActionLogItem item={item} type={action.type} timestamp={action.timestamp} bank={bank} />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const JournalEntryDetails = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||
|
||||
return <div className='flex items-center gap-2 text-ink-gray-5 justify-end'>
|
||||
<WalletIcon className='w-4 h-4' />
|
||||
<JournalEntryAccountsTable item={item} bank={bank} />
|
||||
</div>
|
||||
}
|
||||
|
||||
const JournalEntryAccountsTable = ({ item, bank }: { item: ActionLogItem, bank?: SelectedBank | null }) => {
|
||||
|
||||
const accounts = useMemo(() => {
|
||||
|
||||
const allAccounts = (item.voucher.doc as JournalEntry).accounts
|
||||
|
||||
return allAccounts.filter((acc) => bank ? acc.account !== bank.account : true)
|
||||
|
||||
}, [item, bank])
|
||||
|
||||
return <>
|
||||
{accounts.length === 1 ? <span className='text-sm'>{accounts[0].account}</span> :
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{_("Split across {} accounts", [accounts.length.toString()])}</span>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='w-full p-2' align='end'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Debit")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.account}>
|
||||
<TableCell>{account.account}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.debit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(account.credit ?? 0, account.account_currency ?? '')}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
}</>
|
||||
}
|
||||
|
||||
const PaymentEntryDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||
if ((item.voucher.doc as PaymentEntry).payment_type === "Internal Transfer") {
|
||||
return <TransferDetails item={item} className={className} />
|
||||
}
|
||||
|
||||
const invoices = (item.voucher.doc as PaymentEntry).references ?? []
|
||||
|
||||
const currency = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0 ? (item.voucher.doc as PaymentEntry)?.paid_to_account_currency : (item.voucher.doc as PaymentEntry)?.paid_from_account_currency
|
||||
|
||||
return <div className='flex items-center gap-3'>
|
||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<UserIcon className='w-4 h-4' />
|
||||
<span className='text-sm'>{(item.voucher.doc as PaymentEntry).party_name}</span>
|
||||
</div>
|
||||
<Separator orientation='vertical' />
|
||||
<HoverCard>
|
||||
<HoverCardTrigger>
|
||||
<div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<ReceiptTextIcon className='w-4 h-4' />
|
||||
<span className='text-sm cursor-pointer hover:underline underline-offset-4'>{invoices.length === 0 ? _("No invoice linked") : invoices.length === 1 ? _("1 invoice") : _("{} invoices", [invoices.length.toString()])}</span>
|
||||
</div>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className='w-full p-2' align='end'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
{invoices.map((invoice) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Document")}</TableHead>
|
||||
<TableHead>{_("Invoice No")}</TableHead>
|
||||
<TableHead>{_("Due Date")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Grand Total")}</TableHead>
|
||||
<TableHead className='text-end'>{_("Allocated")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell><a href={`/desk/${slug(invoice.reference_doctype)}/${invoice.reference_name}`} target='_blank' className='underline underline-offset-4'>{invoice.reference_doctype}: {invoice.reference_name}</a></TableCell>
|
||||
<TableCell>{invoice.bill_no ?? "-"}</TableCell>
|
||||
<TableCell>{formatDate(invoice.due_date)}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.total_amount, currency ?? '')}</TableCell>
|
||||
<TableCell className='text-end font-numeric'>{formatCurrency(invoice.allocated_amount, currency ?? '')}</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
const TransferDetails = ({ item, className }: { item: ActionLogItem, className?: string }) => {
|
||||
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
|
||||
const isWithdrawal = item.bankTransaction.withdrawal && item.bankTransaction.withdrawal > 0
|
||||
|
||||
let transferAccount = ""
|
||||
|
||||
if (isWithdrawal) {
|
||||
transferAccount = (item.voucher.doc as PaymentEntry).paid_to
|
||||
} else {
|
||||
transferAccount = (item.voucher.doc as PaymentEntry).paid_from
|
||||
}
|
||||
|
||||
const transferBankAccount = banks?.find((bank) => bank.account === transferAccount)
|
||||
|
||||
return transferBankAccount
|
||||
|
||||
}, [banks, item])
|
||||
|
||||
return <div className={cn('flex items-center gap-2 text-ink-gray-5 text-sm', className)}>
|
||||
<BankLogo bank={bank} className='h-5 mb-0' iconSize='16px' imageClassName='max-h-5' />
|
||||
<span className='text-sm'>{bank?.account}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ACTION_TYPE_MAP = {
|
||||
'bank_entry': _("Bank Entry"),
|
||||
'payment': _("Payment"),
|
||||
'transfer': _("Transfer"),
|
||||
'match': _("Match"),
|
||||
}
|
||||
|
||||
const CancelActionLogItem = ({ item, type, timestamp, bank }: { item: ActionLogItem, type: ActionLogType['type'], timestamp: number, bank?: SelectedBank | null }) => {
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
const { call, loading, error } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction_entry')
|
||||
const { mutate } = useSWRConfig()
|
||||
const actionLog = useSetAtom(bankRecActionLog)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
const matchFilters = useAtomValue(bankRecMatchFilters)
|
||||
const selectedBank = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const onUndo = () => {
|
||||
call({
|
||||
bank_transaction_id: item.bankTransaction.name,
|
||||
voucher_type: item.voucher.reference_doctype,
|
||||
voucher_id: item.voucher.reference_name,
|
||||
}).then(() => {
|
||||
toast.success(type === 'match' ? _("Unmatched") : _("Cancelled"))
|
||||
|
||||
if (selectedBank?.name === item.bankTransaction.bank_account) {
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${selectedBank?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${selectedBank?.name}-${dates.toDate}`)
|
||||
// Update the matching vouchers for the selected transaction
|
||||
mutate(`bank-reconciliation-vouchers-${item.bankTransaction.name}-${dates.fromDate}-${dates.toDate}-${matchFilters.join(',')}`)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
actionLog((prev) => {
|
||||
// Find the action and then remove the item from the action. If the action is empty, remove the action from the array
|
||||
const action = prev.find((action) => action.timestamp === timestamp)
|
||||
|
||||
if (action) {
|
||||
action.items = action.items.filter((i) => i.bankTransaction.name !== item.bankTransaction.name)
|
||||
}
|
||||
// If the action is empty, remove the action from the array
|
||||
if (action && action.items.length === 0) {
|
||||
return prev.filter((a) => a.timestamp !== timestamp)
|
||||
} else {
|
||||
return prev.map((a) => a.timestamp === timestamp ? { ...a, items: action?.items ?? [] } : a)
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
|
||||
setIsOpen(false)
|
||||
|
||||
}).catch((error) => {
|
||||
toast.error(_("There was an error while performing the action."), {
|
||||
duration: 5000,
|
||||
description: getErrorMessage(error),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return <AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={'ghost'}
|
||||
isIconButton
|
||||
theme='red'
|
||||
title={_("Cancel")}
|
||||
className='hover:text-ink-red-3 hover:bg-destructive/5 text-ink-gray-5 hidden group-hover:inline-flex'>
|
||||
<CircleXIcon className='w-8 h-8' />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Cancel")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<AlertDialogContent className='min-w-3xl'>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{type === 'match' ? _("Unmatch Transaction?") : _("Undo {}?", [item.voucher.reference_doctype])}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{type === 'match' ? _("Are you sure you want to unmatch the voucher from this transaction?") : _("Are you sure you want to cancel this {} {}?", [_(item.voucher.reference_doctype), item.voucher.reference_name])}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='flex flex-col gap-2'>
|
||||
<SelectedTransactionDetails transaction={item.bankTransaction} />
|
||||
<Table>
|
||||
<TableRow>
|
||||
<TableHead>{_("Action Type")}</TableHead>
|
||||
<TableCell>{ACTION_TYPE_MAP[type]}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Voucher Type")}</TableHead>
|
||||
<TableCell>{_(item.voucher.reference_doctype)}</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Voucher Name")}</TableHead>
|
||||
<TableCell><a href={`/desk/${slug(item.voucher.reference_doctype)}/${item.voucher.reference_name}`} target='_blank' className='underline underline-offset-4'>{item.voucher.reference_name}</a></TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHead>{_("Posting Date")}</TableHead>
|
||||
<TableCell>{formatDate(item.voucher.posting_date, 'Do MMM YYYY')}</TableCell>
|
||||
</TableRow>
|
||||
{type === 'transfer' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Transfer Account")}</TableHead>
|
||||
<TableCell>
|
||||
<TransferDetails item={item} className='text-ink-gray-8' />
|
||||
</TableCell>
|
||||
</TableRow>}
|
||||
{type === 'payment' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Payment Details")}</TableHead>
|
||||
<TableCell>
|
||||
<PaymentEntryDetails item={item} className='text-ink-gray-8' />
|
||||
</TableCell>
|
||||
</TableRow>}
|
||||
{type === 'bank_entry' && item.voucher.doc && <TableRow>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableCell><JournalEntryAccountsTable item={item} bank={bank} /></TableCell>
|
||||
</TableRow>}
|
||||
</Table>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>
|
||||
{_("Close")}
|
||||
</AlertDialogCancel>
|
||||
<Button theme="red" size='md' disabled={loading} onClick={onUndo}>
|
||||
{loading ? <Loader2Icon className='w-4 h-4 animate-spin' /> : _(("Undo"))}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
}
|
||||
|
||||
export default ActionLogDialogBody
|
||||
@@ -83,7 +83,7 @@ const BankClearanceSummaryView = () => {
|
||||
toast.success(_("Copied to clipboard"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard],
|
||||
[copyToClipboard, _],
|
||||
)
|
||||
|
||||
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">
|
||||
|
||||
@@ -1,32 +1,831 @@
|
||||
import { useAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader } from "@/components/ui/dialog"
|
||||
import { ModalContentFallback } from "@/components/ui/modal-content-fallback"
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription, DialogHeader, DialogFooter, DialogClose } from "@/components/ui/dialog"
|
||||
import _ from "@/lib/translate"
|
||||
import { lazy, Suspense } from "react"
|
||||
|
||||
const RecordBankEntryModalContent = lazy(() => import('./BankEntryModalContent'))
|
||||
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 { 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 [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<RecordBankEntryModalContent />
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-[95vw]'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Bank Entry")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record a journal entry for expenses, income or split transactions.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<RecordBankEntryModalContent />
|
||||
</DialogContent>
|
||||
</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
|
||||
|
||||
@@ -1,811 +0,0 @@
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { bankRecRecordJournalEntryModalAtom, bankRecSelectedTransactionAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { DialogFooter, DialogClose } from "@/components/ui/dialog"
|
||||
import _ from "@/lib/translate"
|
||||
import { UnreconciledTransaction, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from "./utils"
|
||||
import { useFieldArray, useForm, useFormContext, useWatch } from "react-hook-form"
|
||||
import { JournalEntry } from "@/types/Accounts/JournalEntry"
|
||||
import { getCompanyCostCenter, getCompanyCurrency } from "@/lib/company"
|
||||
import { FrappeConfig, FrappeContext, useFrappePostCall } from "frappe-react-sdk"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import { AccountFormField, CurrencyFormField, DataField, DateField, LinkFormField, PartyTypeFormField, SmallTextField } from "@/components/ui/form-elements"
|
||||
import { Form } from "@/components/ui/form"
|
||||
import { useCallback, useContext, useMemo, useRef, useState } from "react"
|
||||
import { useMultiFileUploadProgress } from "@/hooks/useMultiFileUploadProgress"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { ArrowDownRight, ArrowUpRight, Plus, Trash2 } from "lucide-react"
|
||||
import { flt, formatCurrency } from "@/lib/numbers"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import { JournalEntryAccount } from "@/types/Accounts/JournalEntryAccount"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import FileUploadBanner from "@/components/common/FileUploadBanner"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { FileDropzone } from "@/components/ui/file-dropzone"
|
||||
import { useGetAccounts } from "@/components/common/AccountsDropdown"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
const RecordBankEntryModalContent = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
|
||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
||||
return <div className='p-4'>
|
||||
<span className='text-center'>{_("No transaction selected")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (selectedTransaction.length === 1) {
|
||||
return <BankEntryForm
|
||||
selectedTransaction={selectedTransaction[0]} />
|
||||
}
|
||||
|
||||
return <BulkBankEntryForm
|
||||
selectedTransactions={selectedTransaction}
|
||||
/>
|
||||
|
||||
}
|
||||
|
||||
const BulkBankEntryForm = ({ selectedTransactions }: { selectedTransactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const form = useForm<{
|
||||
account: string
|
||||
}>({
|
||||
defaultValues: {
|
||||
account: ''
|
||||
}
|
||||
})
|
||||
|
||||
const { call, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_bank_entry_and_reconcile')
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const onSubmit = (data: { account: string }) => {
|
||||
|
||||
call({
|
||||
bank_transactions: selectedTransactions.map(transaction => transaction.name),
|
||||
account: data.account
|
||||
}).then(({ message }) => {
|
||||
|
||||
addToActionLog({
|
||||
type: 'bank_entry',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: true,
|
||||
items: message.map((item) => ({
|
||||
bankTransaction: item.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Journal Entry",
|
||||
reference_name: item.journal_entry.name,
|
||||
doc: item.journal_entry,
|
||||
posting_date: item.journal_entry.posting_date,
|
||||
}
|
||||
})),
|
||||
bulkCommonData: {
|
||||
account: data.account,
|
||||
}
|
||||
})
|
||||
|
||||
toast.success(_("Bank Entries Created"), {
|
||||
duration: 4000,
|
||||
})
|
||||
|
||||
// Set this to the last selected transaction
|
||||
onReconcile(selectedTransactions[selectedTransactions.length - 1])
|
||||
setIsOpen(false)
|
||||
})
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<AccountFormField
|
||||
name='account'
|
||||
filterFunction={(acc) => {
|
||||
// Do not allow payable and receivable accounts
|
||||
return acc.account_type !== 'Payable' && acc.account_type !== 'Receivable'
|
||||
}}
|
||||
label={_('Account')}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
|
||||
interface BankEntryFormData extends Pick<JournalEntry, 'voucher_type' | 'cheque_date' | 'posting_date' | 'cheque_no' | 'user_remark'> {
|
||||
entries: JournalEntry['accounts']
|
||||
}
|
||||
|
||||
|
||||
const BankEntryForm = ({ selectedTransaction }: { selectedTransaction: UnreconciledTransaction }) => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const defaultAccounts = useMemo(() => {
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const accounts: Partial<JournalEntryAccount>[] = [
|
||||
{
|
||||
account: selectedBankAccount?.account ?? '',
|
||||
bank_account: selectedTransaction.bank_account,
|
||||
// Bank is debited if it's a deposit
|
||||
debit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
credit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
party_type: '',
|
||||
party: '',
|
||||
cost_center: ''
|
||||
}]
|
||||
|
||||
// If there is no rule, we can just add the entries for the bank account transaction and the other side will be the reverse
|
||||
if (!rule) {
|
||||
accounts.push(
|
||||
{
|
||||
account: '',
|
||||
// Amounts will be the reverse of the bank account transaction
|
||||
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// Rule exists, so we need to check the type of rule
|
||||
if (!rule.bank_entry_type || rule.bank_entry_type === "Single Account") {
|
||||
// Only a single account needs to be added
|
||||
accounts.push({
|
||||
account: rule.account ?? '',
|
||||
// Amounts will be the reverse of the bank account transaction
|
||||
debit: isWithdrawal ? selectedTransaction.unallocated_amount : 0,
|
||||
credit: isWithdrawal ? 0 : selectedTransaction.unallocated_amount,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
})
|
||||
} else {
|
||||
// For multiple accounts, we need to loop over and add entries for each
|
||||
// The last row will just be the remaining amount
|
||||
let hasTotallyEmptyRowEarlier = false;
|
||||
|
||||
let totalDebits = isWithdrawal ? 0 : selectedTransaction.unallocated_amount ?? 0
|
||||
let totalCredits = isWithdrawal ? selectedTransaction.unallocated_amount ?? 0 : 0
|
||||
|
||||
for (let i = 0; i < (rule.accounts?.length ?? 0); i++) {
|
||||
|
||||
const acc = rule.accounts?.[i]
|
||||
// If it's the last row, add the difference amount
|
||||
if (i === (rule.accounts?.length ?? 0) - 1 && !hasTotallyEmptyRowEarlier) {
|
||||
|
||||
const differenceAmount = flt(totalDebits - totalCredits, 2)
|
||||
accounts.push({
|
||||
account: acc?.account ?? '',
|
||||
debit: differenceAmount > 0 ? 0 : Math.abs(differenceAmount),
|
||||
credit: differenceAmount > 0 ? Math.abs(differenceAmount) : 0,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
user_remark: acc?.user_remark ?? '',
|
||||
})
|
||||
} else {
|
||||
|
||||
/**
|
||||
* The debit and credit amounts can also be expressions - like "transaction_amount * 0.5"
|
||||
* So we need to compute the value of the expression
|
||||
* We can use the eval function to do this. But we need to expose certain variables to the expression.
|
||||
* One of them is transaction_amount which is the unallocated amount of the selected transaction
|
||||
* @param expression - The expression to compute
|
||||
* @returns The computed value
|
||||
*/
|
||||
const computeExpression = (expression: string) => {
|
||||
|
||||
const script = `
|
||||
const transaction_amount = ${selectedTransaction.unallocated_amount ?? 0}
|
||||
${expression};
|
||||
`
|
||||
|
||||
let value = 0;
|
||||
|
||||
try {
|
||||
value = window.eval(script);
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
value = 0;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
if (!acc?.debit && !acc?.credit) {
|
||||
hasTotallyEmptyRowEarlier = true;
|
||||
}
|
||||
|
||||
const computedDebit = acc?.debit ? flt(computeExpression(acc.debit), 2) : 0
|
||||
const computedCredit = acc?.credit ? flt(computeExpression(acc.credit), 2) : 0
|
||||
|
||||
totalDebits = flt(totalDebits + computedDebit, 2)
|
||||
totalCredits = flt(totalCredits + computedCredit, 2)
|
||||
accounts.push({
|
||||
account: acc?.account ?? '',
|
||||
debit: computedDebit,
|
||||
credit: computedCredit,
|
||||
cost_center: getCompanyCostCenter(selectedTransaction.company ?? '') ?? '',
|
||||
user_remark: acc?.user_remark ?? '',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accounts
|
||||
|
||||
}, [rule, selectedTransaction, selectedBankAccount])
|
||||
|
||||
const form = useForm<BankEntryFormData>({
|
||||
defaultValues: {
|
||||
voucher_type: selectedBankAccount?.is_credit_card ? 'Credit Card Entry' : 'Bank Entry',
|
||||
cheque_date: selectedTransaction.date,
|
||||
posting_date: selectedTransaction.date,
|
||||
cheque_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
|
||||
user_remark: selectedTransaction.description,
|
||||
entries: defaultAccounts,
|
||||
}
|
||||
})
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
|
||||
const { call: createBankEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, journal_entry: JournalEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bank_entry_and_reconcile')
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onSubmit = (data: BankEntryFormData) => {
|
||||
|
||||
createBankEntry({
|
||||
bank_transaction_name: selectedTransaction.name,
|
||||
...data
|
||||
}).then(async ({ message }) => {
|
||||
|
||||
addToActionLog({
|
||||
type: 'bank_entry',
|
||||
isBulk: false,
|
||||
timestamp: (new Date()).getTime(),
|
||||
items: [
|
||||
{
|
||||
bankTransaction: message.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Journal Entry",
|
||||
reference_name: message.journal_entry.name,
|
||||
reference_no: message.journal_entry.cheque_no,
|
||||
reference_date: message.journal_entry.cheque_date,
|
||||
posting_date: message.journal_entry.posting_date,
|
||||
doc: message.journal_entry,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
toast.success(_("Bank Entry Created"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true)
|
||||
startTracking(files.length)
|
||||
|
||||
const uploadPromises = files.map((f, fileIndex) => {
|
||||
return frappeFile.uploadFile(f, {
|
||||
isPrivate: true,
|
||||
doctype: "Journal Entry",
|
||||
docname: message.journal_entry.name,
|
||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
||||
updateFileProgress(fileIndex, progress?.progress ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
}).catch((error) => {
|
||||
console.error(error)
|
||||
toast.error(_("Error uploading attachments"), {
|
||||
duration: 4000,
|
||||
})
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
}).then(() => {
|
||||
onReconcile(selectedTransaction)
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
if (isUploading && isCompleted) {
|
||||
return <FileUploadBanner uploadProgress={uploadProgress} />
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<DateField
|
||||
name='posting_date'
|
||||
label={_("Posting Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
<DateField
|
||||
name='cheque_date'
|
||||
label={_("Reference Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
rules={{
|
||||
required: _("Reference Date is required"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<DataField name='cheque_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }}
|
||||
rules={{
|
||||
required: _("Reference is required"),
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Entries company={selectedTransaction.company ?? ''} isWithdrawal={isWithdrawal} currency={selectedTransaction.currency ?? getCompanyCurrency(selectedTransaction.company ?? '')} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SmallTextField
|
||||
name='user_remark'
|
||||
label={_("Remarks")}
|
||||
/>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label>{_("Attachments")}</Label>
|
||||
<FileDropzone files={files} setFiles={setFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Submit")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
}
|
||||
|
||||
const Entries = ({ company, isWithdrawal, currency }: { company: string, isWithdrawal: boolean, currency: string }) => {
|
||||
|
||||
const { getValues, setValue, control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const { call } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const partyMapRef = useRef<Record<string, string>>({})
|
||||
|
||||
const onPartyChange = (value: string, index: number) => {
|
||||
// Get the account for the party type
|
||||
if (value) {
|
||||
if (partyMapRef.current[value]) {
|
||||
setValue(`entries.${index}.account`, partyMapRef.current[value])
|
||||
} else {
|
||||
call.get('erpnext.accounts.party.get_party_account', {
|
||||
party: value,
|
||||
party_type: getValues(`entries.${index}.party_type`),
|
||||
company: company
|
||||
}).then((result: { message: string }) => {
|
||||
setValue(`entries.${index}.account`, result.message)
|
||||
partyMapRef.current[value] = result.message
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setValue(`entries.${index}.account`, '')
|
||||
}
|
||||
}
|
||||
|
||||
const { data: accounts } = useGetAccounts()
|
||||
|
||||
const onAccountChange = (value: string, index: number) => {
|
||||
// If it's an income or expense account, get the default cost center
|
||||
if (value) {
|
||||
const account = accounts?.find((acc) => acc.name === value)
|
||||
if (account && account.report_type === "Profit and Loss") {
|
||||
// Set the default company cost center
|
||||
setValue(`entries.${index}.cost_center`, getCompanyCostCenter(company) ?? '')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setValue(`entries.${index}.cost_center`, '')
|
||||
}
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: control,
|
||||
name: 'entries'
|
||||
})
|
||||
|
||||
const onAdd = useCallback(() => {
|
||||
const existingEntries = getValues('entries')
|
||||
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
|
||||
const remainingAmount = flt(totalDebits - totalCredits, 2)
|
||||
|
||||
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
|
||||
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
|
||||
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
|
||||
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: debitAmount,
|
||||
credit: creditAmount,
|
||||
cost_center: getCompanyCostCenter(company) ?? ''
|
||||
} as JournalEntryAccount, {
|
||||
focusName: `entries.${existingEntries.length}.account`
|
||||
})
|
||||
}, [company, append, getValues])
|
||||
|
||||
const [selectedRows, setSelectedRows] = useState<number[]>([])
|
||||
|
||||
const onSelectRow = useCallback((index: number) => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.includes(index)) {
|
||||
return prev.filter(i => i !== index)
|
||||
}
|
||||
return [...prev, index]
|
||||
})
|
||||
}, [])
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
setSelectedRows(prev => {
|
||||
if (prev.length === fields.length) {
|
||||
return []
|
||||
}
|
||||
return [...fields.map((_, index) => index)]
|
||||
})
|
||||
}, [fields])
|
||||
|
||||
const onRemove = useCallback(() => {
|
||||
// Do not remove the first row
|
||||
remove(selectedRows.filter(index => index !== 0))
|
||||
setSelectedRows([])
|
||||
}, [remove, selectedRows])
|
||||
|
||||
/**
|
||||
* When add difference is clicked, check if the last row has nothing filled in.
|
||||
* If last row is empty (no debit or credit), then set that row's amount. Else, add a new row with the difference amount.
|
||||
*/
|
||||
const onAddDifferenceClicked = () => {
|
||||
|
||||
const existingEntries = getValues('entries')
|
||||
const totalDebits = existingEntries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = existingEntries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
|
||||
const lastIndex = existingEntries.length - 1
|
||||
|
||||
const isLastRowEmpty = (existingEntries[lastIndex]?.debit === 0 || existingEntries[lastIndex]?.debit === undefined) && (existingEntries[lastIndex]?.credit === 0 || existingEntries[lastIndex]?.credit === undefined)
|
||||
|
||||
const remainingAmount = flt(totalDebits - totalCredits, 2)
|
||||
|
||||
// Remaining amount is credit if it's positive - since some debit is pending to be cleared.
|
||||
const debitAmount = remainingAmount > 0 ? 0 : Math.abs(remainingAmount)
|
||||
const creditAmount = remainingAmount > 0 ? Math.abs(remainingAmount) : 0
|
||||
|
||||
if (isLastRowEmpty) {
|
||||
setValue(`entries.${lastIndex}.debit`, debitAmount)
|
||||
setValue(`entries.${lastIndex}.credit`, creditAmount)
|
||||
} else {
|
||||
append({
|
||||
party_type: '',
|
||||
party: '',
|
||||
account: '',
|
||||
debit: debitAmount,
|
||||
credit: creditAmount,
|
||||
cost_center: getCompanyCostCenter(company) ?? ''
|
||||
} as JournalEntryAccount, {
|
||||
focusName: `entries.${existingEntries.length}.account`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return <div className="flex flex-col gap-2">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead><Checkbox
|
||||
disabled={fields.length === 0}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select all")}
|
||||
checked={selectedRows.length > 0 && selectedRows.length === fields.length}
|
||||
onCheckedChange={onSelectAll} /></TableHead>
|
||||
<TableHead>{_("Party")}</TableHead>
|
||||
<TableHead>{_("Account")}</TableHead>
|
||||
<TableHead>{_("Cost Center")}</TableHead>
|
||||
<TableHead>{_("Remarks")}</TableHead>
|
||||
<TableHead className="text-end">{_("Debit")}</TableHead>
|
||||
<TableHead className="text-end">{_("Credit")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{fields.map((field, index) => (
|
||||
<TableRow key={field.id} className={index === 0 ? 'bg-surface-gray-1 cursor-not-allowed' : ''} title={index === 0 ? _("This is the bank account entry. You cannot edit it.") : ''}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(index)}
|
||||
onCheckedChange={() => onSelectRow(index)}
|
||||
// Make this accessible to screen readers
|
||||
aria-label={_("Select row {0}", [String(index + 1)])}
|
||||
disabled={index === 0}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="align-top">
|
||||
<div className="flex">
|
||||
<PartyTypeFormField
|
||||
name={`entries.${index}.party_type`}
|
||||
label={_("Party Type")}
|
||||
isRequired
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
inputProps={{
|
||||
type: isWithdrawal ? 'Payable' : 'Receivable',
|
||||
triggerProps: {
|
||||
className: 'rounded-e-none',
|
||||
tabIndex: -1
|
||||
},
|
||||
readOnly: index === 0,
|
||||
}} />
|
||||
<PartyField index={index} onChange={onPartyChange} readOnly={index === 0} />
|
||||
</div>
|
||||
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<AccountFormField
|
||||
name={`entries.${index}.account`}
|
||||
label={_("Account")}
|
||||
rules={{
|
||||
required: _("Account is required"),
|
||||
onChange: (event) => {
|
||||
onAccountChange(event.target.value, index)
|
||||
}
|
||||
}}
|
||||
buttonClassName="min-w-64"
|
||||
readOnly={index === 0}
|
||||
isRequired
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<LinkFormField
|
||||
doctype="Cost Center"
|
||||
name={`entries.${index}.cost_center`}
|
||||
label={_("Cost Center")}
|
||||
filters={[["company", "=", company], ["is_group", "=", 0], ["disabled", "=", 0]]}
|
||||
buttonClassName="min-w-48"
|
||||
readOnly={index === 0}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<DataField
|
||||
name={`entries.${index}.user_remark`}
|
||||
label={_("Remarks")}
|
||||
readOnly={index === 0}
|
||||
inputProps={{
|
||||
placeholder: _("e.g. Bank Charges"),
|
||||
className: 'min-w-64',
|
||||
readOnly: index === 0
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end align-top")}>
|
||||
<CurrencyFormField
|
||||
name={`entries.${index}.debit`}
|
||||
label={_("Debit")}
|
||||
isRequired
|
||||
hideLabel
|
||||
readOnly={index === 0}
|
||||
style={index === 0 ? !isWithdrawal ? {
|
||||
color: "var(--color-ink-gray-8)",
|
||||
} : {} : {}}
|
||||
currency={currency}
|
||||
leftSlot={index === 0 && !isWithdrawal ? <Tooltip>
|
||||
<TooltipTrigger asChild><ArrowDownRight className="text-ink-green-3" /></TooltipTrigger>
|
||||
<TooltipContent>{_("Bank account debit for deposit")}</TooltipContent>
|
||||
</Tooltip> : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className={cn("text-end align-top")}>
|
||||
<CurrencyFormField
|
||||
name={`entries.${index}.credit`}
|
||||
style={index === 0 && isWithdrawal ? {
|
||||
color: "var(--color-ink-gray-8)",
|
||||
} : {}}
|
||||
label={_("Credit")}
|
||||
isRequired
|
||||
hideLabel
|
||||
readOnly={index === 0}
|
||||
currency={currency}
|
||||
leftSlot={index === 0 && isWithdrawal ? <Tooltip>
|
||||
<TooltipTrigger asChild><ArrowUpRight className="text-ink-red-3" /></TooltipTrigger>
|
||||
<TooltipContent>{_("Bank account credit for withdrawal")}</TooltipContent>
|
||||
</Tooltip> : undefined}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex justify-between gap-2">
|
||||
<div className="flex gap-2 justify-end">
|
||||
<div>
|
||||
<Button size='sm' type='button' variant={'outline'} onClick={onAdd}><Plus /> {_("Add Row")}</Button>
|
||||
</div>
|
||||
{selectedRows.length > 0 && <div>
|
||||
<Button size='sm' type='button' theme="red" onClick={onRemove}><Trash2 /> {_("Remove")}</Button>
|
||||
</div>}
|
||||
</div>
|
||||
<Summary currency={currency} addRow={onAddDifferenceClicked} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const PartyField = ({ index, onChange, readOnly }: { index: number, onChange: (value: string, index: number) => void, readOnly: boolean }) => {
|
||||
|
||||
const { control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const party_type = useWatch({
|
||||
control,
|
||||
name: `entries.${index}.party_type`
|
||||
})
|
||||
|
||||
if (!party_type) {
|
||||
return <DataField
|
||||
name={`entries.${index}.party`}
|
||||
label={_("Party")}
|
||||
isRequired
|
||||
inputProps={{
|
||||
disabled: true,
|
||||
className: 'rounded-s-none border-s-0 min-w-64'
|
||||
}}
|
||||
hideLabel
|
||||
/>
|
||||
}
|
||||
|
||||
return <LinkFormField
|
||||
name={`entries.${index}.party`}
|
||||
label={_("Party")}
|
||||
rules={{
|
||||
onChange: (event) => {
|
||||
onChange(event.target.value, index)
|
||||
},
|
||||
}}
|
||||
hideLabel
|
||||
readOnly={readOnly}
|
||||
buttonClassName="rounded-s-none border-s-0 min-w-64"
|
||||
doctype={party_type}
|
||||
|
||||
/>
|
||||
}
|
||||
|
||||
const Summary = ({ currency, addRow }: { currency: string, addRow: () => void }) => {
|
||||
|
||||
const { control } = useFormContext<BankEntryFormData>()
|
||||
|
||||
const entries = useWatch({ control, name: 'entries' })
|
||||
|
||||
const { total, totalCredits, totalDebits } = useMemo(() => {
|
||||
// Do a total debits - total credits
|
||||
const totalDebits = entries.reduce((acc, curr) => flt(acc + (curr.debit ?? 0), 2), 0)
|
||||
const totalCredits = entries.reduce((acc, curr) => flt(acc + (curr.credit ?? 0), 2), 0)
|
||||
return { total: flt(totalDebits - totalCredits, 2), totalDebits, totalCredits }
|
||||
}, [entries])
|
||||
|
||||
const onAddRow = useCallback(() => {
|
||||
addRow()
|
||||
}, [addRow])
|
||||
|
||||
const TextComponent = ({ className, children }: { className?: string, children: React.ReactNode }) => {
|
||||
return <span className={cn("w-32 text-end font-medium text-sm font-numeric", className)}>{children}</span>
|
||||
}
|
||||
|
||||
return <div className="flex flex-col gap-2 items-end">
|
||||
<div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Total Debit")}</TextComponent>
|
||||
<TextComponent>{formatCurrency(totalDebits, currency)}</TextComponent>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Total Credit")}</TextComponent>
|
||||
<TextComponent>{formatCurrency(totalCredits, currency)}</TextComponent>
|
||||
</div>
|
||||
{total !== 0 && <div className="flex gap-2 justify-between">
|
||||
<TextComponent>{_("Difference")}</TextComponent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button type='button' variant='link' className="p-0 text-ink-red-3 underline h-fit" role='button' onClick={onAddRow}>
|
||||
<TextComponent className='text-ink-red-3'>{formatCurrency(total, currency)}</TextComponent>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Add a row with the difference amount")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export default RecordBankEntryModalContent
|
||||
@@ -76,7 +76,7 @@ const BankReconciliationStatementView = () => {
|
||||
toast.success(_("Copied to clipboard"))
|
||||
})
|
||||
},
|
||||
[copyToClipboard],
|
||||
[copyToClipboard, _],
|
||||
)
|
||||
|
||||
const statementColumns = useMemo<ColumnDef<BankClearanceSummaryEntry, unknown>[]>(
|
||||
@@ -181,7 +181,7 @@ const BankReconciliationStatementView = () => {
|
||||
cell: ({ row }) => formatDate(row.original.clearance_date),
|
||||
},
|
||||
],
|
||||
[onCopy],
|
||||
[_, onCopy],
|
||||
)
|
||||
|
||||
const statementRows = useMemo(() => {
|
||||
|
||||
@@ -176,7 +176,7 @@ const BankTransactionListView = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[accountCurrency, onUndo],
|
||||
[_, accountCurrency, onUndo],
|
||||
)
|
||||
|
||||
const [search, setSearch] = useDebounceValue('', 250)
|
||||
|
||||
@@ -1,52 +1,125 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import { useAtom } from "jotai"
|
||||
import { Loader2Icon } from "lucide-react"
|
||||
import { lazy, Suspense } from "react"
|
||||
import { bankRecUnreconcileModalAtom } from "./bankRecAtoms"
|
||||
import { AlertDialog, AlertDialogOverlay, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } 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 = lazy(() => import('./BankTransactionUnreconcileModalBody'))
|
||||
|
||||
const BankTransactionUnreconcileModalFallback = () => (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2Icon className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
)
|
||||
|
||||
const BankTransactionUnreconcileModal = () => {
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onOpenChange = (v: boolean) => {
|
||||
if (!v) {
|
||||
setBankRecUnreconcileModal('')
|
||||
}
|
||||
}
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const onOpenChange = (v: boolean) => {
|
||||
if (!v) {
|
||||
setBankRecUnreconcileModal('')
|
||||
}
|
||||
}
|
||||
|
||||
return <AlertDialog open={!!unreconcileModal} onOpenChange={onOpenChange}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogContent className="min-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{_("Are you sure you want to unreconcile this transaction?")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<BankTransactionUnreconcileModalContent />
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
if (!unreconcileModal) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="min-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{_("Undo Transaction Reconciliation")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{_("Are you sure you want to unreconcile this transaction?")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<Suspense fallback={<BankTransactionUnreconcileModalFallback />}>
|
||||
<BankTransactionUnreconcileModalBody />
|
||||
</Suspense>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankTransactionUnreconcileModal
|
||||
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
|
||||
@@ -1,109 +0,0 @@
|
||||
import { AlertDialogAction, AlertDialogCancel, AlertDialogFooter } from "@/components/ui/alert-dialog"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { bankRecDateAtom, bankRecUnreconcileModalAtom, selectedBankAccountAtom } from "./bankRecAtoms"
|
||||
import { useMemo } from "react"
|
||||
import { useFrappeGetDoc, useFrappePostCall, useSWRConfig } from "frappe-react-sdk"
|
||||
import { BankTransaction } from "@/types/Accounts/BankTransaction"
|
||||
import { toast } from "sonner"
|
||||
import ErrorBanner from "@/components/ui/error-banner"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { formatCurrency } from "@/lib/numbers"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import SelectedTransactionDetails from "./SelectedTransactionDetails"
|
||||
import _ from "@/lib/translate"
|
||||
|
||||
const BankTransactionUnreconcileModalBody = () => {
|
||||
const bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
const dates = useAtomValue(bankRecDateAtom)
|
||||
|
||||
const { mutate } = useSWRConfig()
|
||||
|
||||
const [unreconcileModal, setBankRecUnreconcileModal] = useAtom(bankRecUnreconcileModalAtom)
|
||||
|
||||
const { data: transaction, error, isLoading } = useFrappeGetDoc<BankTransaction>('Bank Transaction', unreconcileModal)
|
||||
|
||||
const { call, loading, error: unreconcileError } = useFrappePostCall('erpnext.accounts.doctype.bank_transaction.bank_transaction.unreconcile_transaction')
|
||||
|
||||
const onUnreconcile = (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
call({
|
||||
transaction_name: unreconcileModal
|
||||
}).then(() => {
|
||||
mutate(`bank-reconciliation-bank-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-unreconciled-transactions-${bankAccount?.name}-${dates.fromDate}-${dates.toDate}`)
|
||||
mutate(`bank-reconciliation-account-closing-balance-${bankAccount?.name}-${dates.toDate}`)
|
||||
toast.success(_("Transaction Unreconciled"))
|
||||
setBankRecUnreconcileModal('')
|
||||
})
|
||||
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const vouchersWhichWillBeCancelled = useMemo(() => {
|
||||
return transaction?.payment_entries?.filter((payment) => payment.reconciliation_type === 'Voucher Created')
|
||||
}, [transaction])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-3">
|
||||
{error && <ErrorBanner error={error} />}
|
||||
{unreconcileError && <ErrorBanner error={unreconcileError} />}
|
||||
{transaction && <SelectedTransactionDetails transaction={transaction} />}
|
||||
<span className="font-medium text-sm">{_("This transaction has been reconciled with the following document(s):")}</span>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{_("Document")}</TableHead>
|
||||
<TableHead>{_("Amount")}</TableHead>
|
||||
<TableHead>{_("Reconciliation Type")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{transaction?.payment_entries?.map((voucher) => {
|
||||
return (
|
||||
<TableRow key={voucher.name}>
|
||||
<TableCell>
|
||||
<a
|
||||
className="underline underline-offset-4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`/desk/${slug(voucher.payment_document as string)}/${voucher.payment_entry}`}
|
||||
>
|
||||
{`${_(voucher.payment_document)}: ${voucher.payment_entry}`}
|
||||
</a>
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(voucher.allocated_amount)}</TableCell>
|
||||
<TableCell>
|
||||
{voucher.reconciliation_type === 'Voucher Created' ?
|
||||
<Badge theme="green">{_(voucher.reconciliation_type)}</Badge> :
|
||||
<Badge theme="blue">{_(voucher.reconciliation_type ?? "Matched")}</Badge>}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="py-4">
|
||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
|
||||
<span>The following documents will be <strong>cancelled</strong>:</span>
|
||||
)}
|
||||
{vouchersWhichWillBeCancelled && vouchersWhichWillBeCancelled?.length > 0 && (
|
||||
<ol className="ms-6 list-disc [&>li]:mt-2">
|
||||
{vouchersWhichWillBeCancelled?.map((voucher) => {
|
||||
return <li key={voucher.name}>{_(voucher.payment_document)}: {voucher.payment_entry}</li>
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={loading}>{_("Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onUnreconcile} theme="red" disabled={loading || isLoading}>
|
||||
{_("Unreconcile")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BankTransactionUnreconcileModalBody
|
||||
@@ -91,7 +91,7 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
})
|
||||
})
|
||||
},
|
||||
[clearClearingDate, mutate],
|
||||
[clearClearingDate, mutate, _],
|
||||
)
|
||||
|
||||
const accountCurrency = useMemo(
|
||||
@@ -174,7 +174,7 @@ const IncorrectlyClearedEntriesView = () => {
|
||||
),
|
||||
},
|
||||
],
|
||||
[accountCurrency, onClearClick],
|
||||
[_, accountCurrency, onClearClick],
|
||||
)
|
||||
|
||||
return <div className="space-y-4 py-2">
|
||||
|
||||
@@ -14,7 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuIte
|
||||
import { Button } from "@/components/ui/button"
|
||||
import CurrencyInput from 'react-currency-input-field'
|
||||
import { getCurrencySymbol } from "@/lib/currency"
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Virtuoso } from 'react-virtuoso'
|
||||
import { formatDate } from "@/lib/date"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { formatCurrency, getCurrencyFormatInfo } from "@/lib/numbers"
|
||||
@@ -22,10 +22,10 @@ import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from "@/comp
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { slug } from "@/lib/frappe"
|
||||
import _ from "@/lib/translate"
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import TransferModal from "./TransferModal"
|
||||
import BankEntryModal from "./BankEntryModal"
|
||||
import RecordPaymentModal from "./RecordPaymentModal"
|
||||
import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import SelectedTransactionsTable from "./SelectedTransactionsTable"
|
||||
import MatchFilters from "./MatchFilters"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
@@ -69,59 +69,6 @@ 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 bankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
@@ -187,7 +134,6 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
}
|
||||
|
||||
const hasFilters = search !== '' || typeFilter !== 'All' || amountFilter.value !== 0
|
||||
const listHeight = contentHeight - 72
|
||||
|
||||
if (isLoading) {
|
||||
return <UnreconciledTransactionsLoadingState />
|
||||
@@ -276,14 +222,14 @@ const UnreconciledTransactions = ({ contentHeight }: { contentHeight: number })
|
||||
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.")} />}
|
||||
|
||||
<VirtualizedListBody
|
||||
items={results}
|
||||
height={listHeight}
|
||||
estimateSize={74}
|
||||
getItemKey={(transaction) => transaction.name}
|
||||
>
|
||||
{(transaction) => <UnreconciledTransactionItem transaction={transaction} />}
|
||||
</VirtualizedListBody>
|
||||
<Virtuoso
|
||||
data={results}
|
||||
itemContent={(_index, transaction) => (
|
||||
<UnreconciledTransactionItem transaction={transaction} />
|
||||
)}
|
||||
style={{ minHeight: Math.max(contentHeight - 80, 400) }}
|
||||
totalCount={results?.length}
|
||||
/>
|
||||
|
||||
</div>
|
||||
}
|
||||
@@ -613,8 +559,11 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
const setRecordPaymentModalOpen = useSetAtom(bankRecRecordPaymentModalAtom)
|
||||
const setRecordJournalEntryModalOpen = useSetAtom(bankRecRecordJournalEntryModalAtom)
|
||||
|
||||
if (!rule) {
|
||||
return null
|
||||
}
|
||||
|
||||
const getActionIcon = () => {
|
||||
if (!rule) return null
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return <Landmark />
|
||||
@@ -628,7 +577,6 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const getActionStyles = () => {
|
||||
if (!rule) return {}
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return {
|
||||
@@ -662,7 +610,6 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const handleActionClick = () => {
|
||||
if (!rule) return
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
setRecordJournalEntryModalOpen(true)
|
||||
@@ -677,7 +624,6 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
|
||||
const getActionDescription = () => {
|
||||
if (!rule) return ""
|
||||
switch (rule.classify_as) {
|
||||
case "Bank Entry":
|
||||
return _("Create a journal entry for expenses, income or split transactions")
|
||||
@@ -690,7 +636,8 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
}
|
||||
}
|
||||
|
||||
useHotkeys('alt+r', () => {
|
||||
useHotkeys('meta+r', () => {
|
||||
//
|
||||
handleActionClick()
|
||||
}, {
|
||||
enabled: true,
|
||||
@@ -700,10 +647,6 @@ const RuleAction = ({ transaction }: { transaction: UnreconciledTransaction }) =
|
||||
|
||||
const styles = getActionStyles()
|
||||
|
||||
if (!rule) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={`border ${styles.border} ${styles.bg} shadow-sm hover:shadow-md transition-all duration-200`}>
|
||||
<CardHeader className="pb-0">
|
||||
@@ -778,9 +721,6 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
|
||||
const { data: vouchers, isLoading, error } = useGetVouchersForTransaction(transaction)
|
||||
|
||||
const voucherList = vouchers?.message ?? []
|
||||
const listHeight = contentHeight - 120
|
||||
|
||||
if (error) {
|
||||
return <ErrorBanner error={error} />
|
||||
}
|
||||
@@ -807,7 +747,7 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
<span>or</span>
|
||||
<Separator className="flex-1" />
|
||||
</div>
|
||||
{voucherList.length === 0 && <Empty className="my-4">
|
||||
{vouchers?.message.length === 0 && <Empty className="my-4">
|
||||
<EmptyMedia>
|
||||
<ReceiptIcon />
|
||||
</EmptyMedia>
|
||||
@@ -816,14 +756,14 @@ const VouchersForTransaction = ({ transaction, contentHeight }: { transaction: U
|
||||
<EmptyTitle>{_("No vouchers found for this transaction")}</EmptyTitle>
|
||||
</EmptyHeader>
|
||||
</Empty>}
|
||||
<VirtualizedListBody
|
||||
items={voucherList}
|
||||
height={listHeight}
|
||||
estimateSize={121}
|
||||
getItemKey={(voucher) => voucher.name}
|
||||
>
|
||||
{(voucher, index) => <VoucherItem voucher={voucher} index={index} />}
|
||||
</VirtualizedListBody>
|
||||
<Virtuoso
|
||||
data={vouchers?.message}
|
||||
itemContent={(index, voucher) => (
|
||||
<VoucherItem voucher={voucher} index={index} />
|
||||
)}
|
||||
style={{ height: contentHeight }}
|
||||
totalCount={vouchers?.message.length}
|
||||
/>
|
||||
</div >
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,555 @@
|
||||
import { useAtom } from 'jotai'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { ModalContentFallback } from '@/components/ui/modal-content-fallback'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogFooter, DialogClose, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { bankRecTransferModalAtom } from './bankRecAtoms'
|
||||
|
||||
const TransferModalContent = lazy(() => import('./TransferModalContent'))
|
||||
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 { 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 [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Transfer")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{isOpen && (
|
||||
<Suspense fallback={<ModalContentFallback />}>
|
||||
<TransferModalContent />
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
const [isOpen, setIsOpen] = useAtom(bankRecTransferModalAtom)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent className='min-w-7xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{_("Transfer")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{_("Record an internal transfer to another bank/credit card/cash account.")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<TransferModalContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default TransferModal
|
||||
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
|
||||
@@ -1,530 +0,0 @@
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { bankRecSelectedTransactionAtom, bankRecTransferModalAtom, bankRecUnreconcileModalAtom, SelectedBank, selectedBankAccountAtom } from './bankRecAtoms'
|
||||
import { DialogFooter, DialogClose } from '@/components/ui/dialog'
|
||||
import _ from '@/lib/translate'
|
||||
import { UnreconciledTransaction, useGetBankAccounts, useGetRuleForTransaction, useRefreshUnreconciledTransactions, useUpdateActionLog } from './utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import SelectedTransactionDetails from './SelectedTransactionDetails'
|
||||
import { PaymentEntry } from '@/types/Accounts/PaymentEntry'
|
||||
import { useForm, useFormContext, useWatch } from 'react-hook-form'
|
||||
import { FrappeConfig, FrappeContext, useFrappeGetCall, useFrappePostCall } from 'frappe-react-sdk'
|
||||
import { toast } from 'sonner'
|
||||
import ErrorBanner from '@/components/ui/error-banner'
|
||||
import { H4 } from '@/components/ui/typography'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowRight, Banknote, BadgeCheck, Calendar, ArrowUpRight, ArrowDownRight, CheckIcon, CheckCircle, ArrowLeft } from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Form } from '@/components/ui/form'
|
||||
import { AccountFormField, DataField, DateField, SmallTextField } from '@/components/ui/form-elements'
|
||||
import SelectedTransactionsTable from './SelectedTransactionsTable'
|
||||
import { useCurrentCompany } from '@/hooks/useCurrentCompany'
|
||||
import { useMultiFileUploadProgress } from '@/hooks/useMultiFileUploadProgress'
|
||||
import { formatDate } from '@/lib/date'
|
||||
import { useContext, useMemo, useState } from 'react'
|
||||
import { formatCurrency } from '@/lib/numbers'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone'
|
||||
import FileUploadBanner from '@/components/common/FileUploadBanner'
|
||||
import { BankTransaction } from '@/types/Accounts/BankTransaction'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
import BankLogo from '@/components/common/BankLogo'
|
||||
const TransferModalContent = () => {
|
||||
|
||||
const selectedBankAccount = useAtomValue(selectedBankAccountAtom)
|
||||
|
||||
const selectedTransaction = useAtomValue(bankRecSelectedTransactionAtom(selectedBankAccount?.name ?? ''))
|
||||
|
||||
if (!selectedTransaction || !selectedBankAccount || selectedTransaction.length === 0) {
|
||||
return <div className='p-4'>
|
||||
<span className='text-center'>{_("No transaction selected")}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
if (selectedTransaction.length === 1) {
|
||||
return <InternalTransferForm
|
||||
selectedBankAccount={selectedBankAccount}
|
||||
selectedTransaction={selectedTransaction[0]} />
|
||||
}
|
||||
|
||||
return <BulkInternalTransferForm transactions={selectedTransaction} />
|
||||
|
||||
}
|
||||
|
||||
const BulkInternalTransferForm = ({ transactions }: { transactions: UnreconciledTransaction[] }) => {
|
||||
|
||||
const form = useForm<{
|
||||
bank_account: string
|
||||
}>()
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
|
||||
const { call: createPaymentEntry, loading, error } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry }[] }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_bulk_internal_transfer')
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const onSubmit = (data: { bank_account: string }) => {
|
||||
|
||||
createPaymentEntry({
|
||||
bank_transaction_names: transactions.map((transaction) => transaction.name),
|
||||
bank_account: data.bank_account
|
||||
}).then(({ message }) => {
|
||||
addToActionLog({
|
||||
type: 'transfer',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: true,
|
||||
items: message.map((item) => ({
|
||||
bankTransaction: item.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Payment Entry",
|
||||
reference_name: item.payment_entry.name,
|
||||
posting_date: item.payment_entry.posting_date,
|
||||
doc: item.payment_entry,
|
||||
}
|
||||
})),
|
||||
bulkCommonData: {
|
||||
bank_account: data.bank_account,
|
||||
}
|
||||
})
|
||||
toast.success(_("Transfer Recorded"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
})
|
||||
onReconcile(transactions[transactions.length - 1])
|
||||
setIsOpen(false)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const onAccountChange = (account: string) => {
|
||||
form.setValue('bank_account', account)
|
||||
}
|
||||
|
||||
const selectedAccount = useWatch({ control: form.control, name: 'bank_account' })
|
||||
|
||||
const currentCompany = useCurrentCompany()
|
||||
|
||||
const company = transactions && transactions.length > 0 ? transactions[0].company : (currentCompany ?? '')
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
|
||||
{error && <ErrorBanner error={error} />}
|
||||
|
||||
<SelectedTransactionsTable />
|
||||
|
||||
<BankOrCashPicker company={company} bankAccount={transactions[0]?.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</Form>
|
||||
}
|
||||
|
||||
interface InternalTransferFormFields extends PaymentEntry {
|
||||
mirror_transaction_name?: string
|
||||
}
|
||||
|
||||
const InternalTransferForm = ({ selectedBankAccount, selectedTransaction }: { selectedBankAccount: SelectedBank, selectedTransaction: UnreconciledTransaction }) => {
|
||||
|
||||
|
||||
const setIsOpen = useSetAtom(bankRecTransferModalAtom)
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const { data: rule } = useGetRuleForTransaction(selectedTransaction)
|
||||
|
||||
const isWithdrawal = (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) ? true : false
|
||||
|
||||
const form = useForm<InternalTransferFormFields>({
|
||||
defaultValues: {
|
||||
payment_type: 'Internal Transfer',
|
||||
company: selectedTransaction?.company,
|
||||
// If the transaction is a withdrawal, set the paid from to the selected bank account
|
||||
paid_from: isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
|
||||
// If the transaction is a deposit, set the paid to to the selected bank account
|
||||
paid_to: !isWithdrawal ? selectedBankAccount.account : (rule?.account ?? ''),
|
||||
// Set the amount to the amount of the selected transaction
|
||||
paid_amount: selectedTransaction.unallocated_amount,
|
||||
received_amount: selectedTransaction.unallocated_amount,
|
||||
reference_date: selectedTransaction.date,
|
||||
posting_date: selectedTransaction.date,
|
||||
reference_no: (selectedTransaction.reference_number || selectedTransaction.description || '').slice(0, 140),
|
||||
}
|
||||
})
|
||||
|
||||
const onReconcile = useRefreshUnreconciledTransactions()
|
||||
|
||||
const { call: createPaymentEntry, loading, error, isCompleted } = useFrappePostCall<{ message: { transaction: BankTransaction, payment_entry: PaymentEntry } }>('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.create_internal_transfer')
|
||||
|
||||
const setBankRecUnreconcileModalAtom = useSetAtom(bankRecUnreconcileModalAtom)
|
||||
const addToActionLog = useUpdateActionLog()
|
||||
|
||||
const { file: frappeFile } = useContext(FrappeContext) as FrappeConfig
|
||||
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const { uploadProgress, startTracking, updateFileProgress, resetProgress } = useMultiFileUploadProgress()
|
||||
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
|
||||
const onSubmit = (data: InternalTransferFormFields) => {
|
||||
|
||||
createPaymentEntry({
|
||||
bank_transaction_name: selectedTransaction.name,
|
||||
...data,
|
||||
custom_remarks: data.remarks ? true : false,
|
||||
// Pass this to reconcile both at the same time
|
||||
mirror_transaction_name: data.mirror_transaction_name
|
||||
}).then(async ({ message }) => {
|
||||
addToActionLog({
|
||||
type: 'transfer',
|
||||
timestamp: (new Date()).getTime(),
|
||||
isBulk: false,
|
||||
items: [
|
||||
{
|
||||
bankTransaction: message.transaction,
|
||||
voucher: {
|
||||
reference_doctype: "Payment Entry",
|
||||
reference_name: message.payment_entry.name,
|
||||
reference_no: message.payment_entry.reference_no,
|
||||
reference_date: message.payment_entry.reference_date,
|
||||
posting_date: message.payment_entry.posting_date,
|
||||
doc: message.payment_entry,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
toast.success(_("Transfer Recorded"), {
|
||||
duration: 4000,
|
||||
closeButton: true,
|
||||
action: {
|
||||
label: _("Undo"),
|
||||
onClick: () => setBankRecUnreconcileModalAtom(selectedTransaction.name)
|
||||
},
|
||||
actionButtonStyle: {
|
||||
backgroundColor: "rgb(0, 138, 46)"
|
||||
}
|
||||
})
|
||||
|
||||
if (files.length > 0) {
|
||||
setIsUploading(true)
|
||||
startTracking(files.length)
|
||||
|
||||
const uploadPromises = files.map((f, fileIndex) => {
|
||||
return frappeFile.uploadFile(f, {
|
||||
isPrivate: true,
|
||||
doctype: "Payment Entry",
|
||||
docname: message.payment_entry.name,
|
||||
}, (_bytesUploaded, _totalBytes, progress) => {
|
||||
updateFileProgress(fileIndex, progress?.progress ?? 0)
|
||||
})
|
||||
})
|
||||
|
||||
return Promise.all(uploadPromises).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve()
|
||||
}
|
||||
}).then(() => {
|
||||
resetProgress()
|
||||
setIsUploading(false)
|
||||
onReconcile(selectedTransaction)
|
||||
onClose()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
useHotkeys('meta+s', () => {
|
||||
form.handleSubmit(onSubmit)()
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: true
|
||||
})
|
||||
|
||||
const onAccountChange = (account: string, is_mirror: boolean = false) => {
|
||||
//If the transaction is a withdrawal, set the paid to to the selected account - since this is the account where the money is deposited into
|
||||
if (selectedTransaction.withdrawal && selectedTransaction.withdrawal > 0) {
|
||||
form.setValue('paid_to', account)
|
||||
} else {
|
||||
form.setValue('paid_from', account)
|
||||
}
|
||||
|
||||
if (!is_mirror) {
|
||||
// Reset the mirror transaction name
|
||||
form.setValue('mirror_transaction_name', '')
|
||||
}
|
||||
}
|
||||
|
||||
const selectedAccount = useWatch({ control: form.control, name: (selectedTransaction.deposit && selectedTransaction.deposit > 0) ? 'paid_from' : 'paid_to' })
|
||||
|
||||
const direction = useDirection()
|
||||
|
||||
if (isUploading && isCompleted) {
|
||||
return <FileUploadBanner uploadProgress={uploadProgress} />
|
||||
}
|
||||
|
||||
return <Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className='flex flex-col gap-4'>
|
||||
{error && <ErrorBanner error={error} />}
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<SelectedTransactionDetails transaction={selectedTransaction} />
|
||||
|
||||
<div className='flex flex-col gap-4'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<DateField
|
||||
name='posting_date'
|
||||
label={_("Posting Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
<DateField
|
||||
name='reference_date'
|
||||
label={_("Reference Date")}
|
||||
isRequired
|
||||
inputProps={{ autoFocus: false }}
|
||||
/>
|
||||
</div>
|
||||
<DataField name='reference_no' label={_("Reference")} isRequired inputProps={{ autoFocus: false }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-2'>
|
||||
<H4 className='text-base'>{isWithdrawal ? _('Transferred to') : _('Transferred from')}</H4>
|
||||
<RecommendedTransferAccount transaction={selectedTransaction} onAccountChange={onAccountChange} />
|
||||
<BankOrCashPicker company={selectedTransaction.company ?? ''} bankAccount={selectedTransaction.bank_account ?? ''} onAccountChange={onAccountChange} selectedAccount={selectedAccount} />
|
||||
</div>
|
||||
<div className='flex flex-col gap-2 py-2'>
|
||||
<div className='flex items-end justify-between gap-4'>
|
||||
<div className='flex-1'>
|
||||
<AccountFormField
|
||||
name="paid_from"
|
||||
label={_("Paid From")}
|
||||
account_type={['Bank', 'Cash']}
|
||||
readOnly={isWithdrawal}
|
||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='pb-2'>
|
||||
{direction === 'ltr' ? <ArrowRight /> : <ArrowLeft />}
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<AccountFormField
|
||||
name="paid_to"
|
||||
label={_("Paid To")}
|
||||
account_type={['Bank', 'Cash']}
|
||||
isRequired
|
||||
readOnly={!isWithdrawal}
|
||||
filterFunction={(account) => account.name !== selectedBankAccount.account}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
|
||||
|
||||
<SmallTextField
|
||||
name='remarks'
|
||||
label={_("Custom Remarks")}
|
||||
formDescription={_("This will be auto-populated if not set.")}
|
||||
/>
|
||||
<div
|
||||
data-slot="form-item"
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Label>{_("Attachments")}</Label>
|
||||
<FileDropzone files={files} setFiles={setFiles} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button size='md' variant={'outline'} disabled={loading}>{_("Cancel")}</Button>
|
||||
</DialogClose>
|
||||
<Button size='md' type='submit' disabled={loading}>{_("Transfer")}</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
}
|
||||
|
||||
|
||||
const BankOrCashPicker = ({ bankAccount, onAccountChange, selectedAccount, company }: { selectedAccount: string, bankAccount: string, onAccountChange: (account: string) => void, company?: string }) => {
|
||||
|
||||
const { banks } = useGetBankAccounts(undefined, (bank) => bank.name !== bankAccount)
|
||||
|
||||
return <div className='grid grid-cols-4 gap-4'>
|
||||
{banks.map((bank) => (
|
||||
<button
|
||||
className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||
selectedAccount === bank.account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||
)}
|
||||
type='button'
|
||||
key={bank.account}
|
||||
onClick={() => onAccountChange(bank.account ?? '')}
|
||||
>
|
||||
<BankLogo bank={bank} iconSize='24px' imageClassName='w-12 h-12' />
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='font-semibold text-sm'>{bank.account_name} {bank.bank_account_no && <span className='text-xs text-ink-gray-5'>({bank.bank_account_no})</span>}</span>
|
||||
<span className='text-xs text-ink-gray-5'>{bank.account}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<CashPicker company={company ?? ''} selectedAccount={selectedAccount} setSelectedAccount={onAccountChange} />
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
const CashPicker = ({ company, selectedAccount, setSelectedAccount }: { company: string, selectedAccount: string, setSelectedAccount: (account: string) => void }) => {
|
||||
|
||||
const { data } = useFrappeGetCall('frappe.client.get_value', {
|
||||
doctype: 'Company',
|
||||
filters: company,
|
||||
fieldname: 'default_cash_account'
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
})
|
||||
|
||||
const account = data?.message?.default_cash_account
|
||||
|
||||
if (account) {
|
||||
return <button className={cn('text-left border p-2 rounded-md flex items-center gap-2 cursor-pointer outline-[0.5px] transition-all duration-200 hover:bg-surface-gray-1 dark:hover:bg-surface-gray-3',
|
||||
selectedAccount === account ? 'border-outline-gray-5 outline-outline-gray-5 bg-surface-gray-1 dark:bg-surface-gray-3' : 'border-outline-gray-2 outline-outline-gray-2'
|
||||
)}
|
||||
type='button'
|
||||
onClick={() => setSelectedAccount(account ?? '')}
|
||||
>
|
||||
<div className='flex items-center justify-center h-10 w-10'>
|
||||
<Banknote size='24px' />
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='font-semibold text-sm'>Cash</span>
|
||||
<span className='text-xs text-ink-gray-5'>{data?.message?.default_cash_account}</span>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
const RecommendedTransferAccount = ({ transaction, onAccountChange }: { transaction: UnreconciledTransaction, onAccountChange: (account: string, is_mirror: boolean) => void }) => {
|
||||
|
||||
const { setValue, watch } = useFormContext<InternalTransferFormFields>()
|
||||
|
||||
const mirrorTransactionName = watch('mirror_transaction_name')
|
||||
const paid_from = watch('paid_from')
|
||||
const paid_to = watch('paid_to')
|
||||
|
||||
const { data } = useFrappeGetCall('erpnext.accounts.doctype.bank_reconciliation_tool.bank_reconciliation_tool.search_for_transfer_transaction', {
|
||||
transaction_id: transaction.name
|
||||
}, undefined, {
|
||||
revalidateOnFocus: false,
|
||||
revalidateIfStale: false,
|
||||
})
|
||||
|
||||
// Get bank accounts to find the logo
|
||||
const { banks } = useGetBankAccounts()
|
||||
|
||||
const bank = useMemo(() => {
|
||||
if (data?.message?.bank_account && banks) {
|
||||
return banks.find(bank => bank.name === data.message.bank_account)
|
||||
}
|
||||
return null
|
||||
}, [data?.message?.bank_account, banks])
|
||||
|
||||
const selectTransaction = () => {
|
||||
if (data?.message) {
|
||||
setValue('mirror_transaction_name', data.message.name)
|
||||
onAccountChange(data.message.account, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (data?.message) {
|
||||
|
||||
const isWithdrawal = data.message.withdrawal && data.message.withdrawal > 0
|
||||
|
||||
const amount = isWithdrawal ? data.message.withdrawal : data.message.deposit
|
||||
const currency = data.message.currency
|
||||
|
||||
const isAccountSelected = isWithdrawal ? paid_from === data.message.account : paid_to === data.message.account
|
||||
|
||||
const isSuggested = mirrorTransactionName === data?.message?.name && isAccountSelected
|
||||
|
||||
return (<div className='pb-2'>
|
||||
<div className={cn("flex justify-between items-start gap-3 p-3 border rounded-lg shadow-sm",
|
||||
isSuggested ? "border-outline-green-4 bg-surface-green-1" : "border-outline-violet-2 bg-surface-violet-2/50")}>
|
||||
<div>
|
||||
<div className='flex flex-col gap-3'>
|
||||
<div className={cn("flex items-center gap-2 shrink-0",
|
||||
isSuggested ? "text-ink-green-4" : "text-ink-violet-4"
|
||||
)}>
|
||||
<BadgeCheck className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{_("Suggested Transfer to {0}", [data.message.account])}</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1'>
|
||||
<span className='text-p-sm'>{_("The system found a mirror transaction ({0}) in another account with the same amount and date.", [data.message.name])}</span>
|
||||
<span className='text-p-sm'>{_("Accepting the suggestion will reconcile both transactions.")}</span>
|
||||
</div>
|
||||
|
||||
<div className='flex flex-col gap-1.5'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<Calendar size='16px' />
|
||||
<span className='text-sm'>{formatDate(data.message.date, 'Do MMM YYYY')}</span>
|
||||
</div>
|
||||
<span className='text-sm line-clamp-1' title={data.message.description}>{data.message.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col items-end justify-between gap-2 h-full w-[30%]'>
|
||||
<div className="flex items-center gap-2">
|
||||
<BankLogo bank={bank} iconSize='24px' imageClassName='h-8 max-w-24' iconClassName={cn(isSuggested ? "text-ink-green-3" : "text-purple-600")} />
|
||||
</div>
|
||||
<div className='flex gap-1'>
|
||||
<div className={cn('flex items-center gap-1 text-end px-0 justify-end py-1 rounded-sm',
|
||||
isWithdrawal ? 'text-ink-red-3' : 'text-ink-green-3'
|
||||
)}>
|
||||
{isWithdrawal ? <ArrowUpRight className="w-5 h-5 text-ink-red-3" /> : <ArrowDownRight className="w-5 h-5 text-ink-green-3" />}
|
||||
<span className='text-sm font-semibold uppercase'>{isWithdrawal ? _('Transferred Out') : _('Received')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className='font-semibold font-numeric text-lg text-end pe-0.5'>{formatCurrency(amount, currency)}</span>
|
||||
<div className='pt-1'>
|
||||
<Button
|
||||
onClick={selectTransaction}
|
||||
theme={isSuggested ? "green" : "violet"}
|
||||
size="md"
|
||||
type='button'
|
||||
>
|
||||
{isSuggested ? <CheckCircle /> : <CheckIcon />}
|
||||
{isSuggested ? _("Accepted") : _("Use Suggestion")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default TransferModalContent
|
||||
@@ -1,5 +1,6 @@
|
||||
import CSVRawDataPreview from './CSVRawDataPreview'
|
||||
import StatementDetails from './StatementDetails'
|
||||
import _ from '@/lib/translate'
|
||||
import { GetStatementDetailsResponse } from '../import_utils'
|
||||
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import _ from '@/lib/translate'
|
||||
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, OptionIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
||||
import { ArrowRightLeftIcon, HistoryIcon, LandmarkIcon, ReceiptIcon, SaveIcon, SettingsIcon, ZapIcon } from 'lucide-react'
|
||||
|
||||
const Shortcuts = [
|
||||
{
|
||||
@@ -32,7 +32,7 @@ const Shortcuts = [
|
||||
}
|
||||
},
|
||||
{
|
||||
shortcut: <KbdGroup><Kbd><OptionIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||
shortcut: <KbdGroup><Kbd><KeyboardMetaKeyIcon /></Kbd><Kbd>R</Kbd></KbdGroup>,
|
||||
action: {
|
||||
icon: <ZapIcon />,
|
||||
label: _("Accept Matching Rule"),
|
||||
|
||||
@@ -20,7 +20,7 @@ export const Preferences = () => {
|
||||
|
||||
const { updateDoc, error } = useFrappeUpdateDoc<AccountsSettings>()
|
||||
|
||||
const onUpdate = <K extends keyof AccountsSettings>(field: K, value: AccountsSettings[K]) => {
|
||||
const onUpdate = (field: keyof AccountsSettings, value: any) => {
|
||||
mutate(updateDoc("Accounts Settings", "Accounts Settings", {
|
||||
[field]: value
|
||||
}), {
|
||||
|
||||
@@ -1,42 +1,95 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
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 _ from '@/lib/translate'
|
||||
import { SettingsIcon } from 'lucide-react'
|
||||
import { KeyboardIcon, SettingsIcon, SlidersVerticalIcon, ZapIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { Preferences } from './Preferences'
|
||||
import MatchingRules from './MatchingRules'
|
||||
import KeyboardShortcuts from './KeyboardShortcuts'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import SettingsDialogContent from './SettingsDialogContent'
|
||||
|
||||
const Settings = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
useHotkeys('shift+meta+g', () => {
|
||||
setIsOpen(x => !x)
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: false
|
||||
})
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md' aria-label={_("Settings")}>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Settings")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{isOpen && (
|
||||
<SettingsDialogContent onClose={() => setIsOpen(false)} />
|
||||
)}
|
||||
</Dialog>
|
||||
)
|
||||
useHotkeys('shift+meta+g', () => {
|
||||
setIsOpen(x => !x)
|
||||
}, {
|
||||
enabled: true,
|
||||
preventDefault: true,
|
||||
enableOnFormTags: false
|
||||
})
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={'outline'} isIconButton size='md'>
|
||||
<SettingsIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{_("Settings")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SettingsDialog defaultValue="preferences" onClose={() => setIsOpen(false)}>
|
||||
<SettingsTabs>
|
||||
<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 >
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
@@ -1,24 +0,0 @@
|
||||
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> &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size" | "theme">) {
|
||||
return (
|
||||
<Button variant={variant} size={size} theme={theme} asChild>
|
||||
<Button variant={variant} size={size} asChild>
|
||||
<AlertDialogPrimitive.Cancel
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
|
||||
@@ -18,7 +18,7 @@ interface 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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
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
|
||||
className={cn(
|
||||
"flex-1 shrink-0 truncate text-sm leading-4 duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
||||
"flex-1 shrink-0 truncate text-sm duration-300 ease-in-out w-auto opacity-100 text-ink-gray-6",
|
||||
icon && "ms-2"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
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,3 +1,4 @@
|
||||
import { in_list } from "./checks";
|
||||
import { getCurrencyNumberFormat, getCurrencyProperty, getCurrencySymbol } from "./currency";
|
||||
import { getSystemDefault } from "./frappe";
|
||||
import _ from "@/lib/translate";
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import BankBalance from "@/components/features/BankReconciliation/BankBalance"
|
||||
import BankClearanceSummary from "@/components/features/BankReconciliation/BankClearanceSummary"
|
||||
import BankPicker from "@/components/features/BankReconciliation/BankPicker"
|
||||
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 CompanySelector from "@/components/features/BankReconciliation/CompanySelector"
|
||||
import IncorrectlyClearedEntries from "@/components/features/BankReconciliation/IncorrectlyClearedEntries"
|
||||
import MatchAndReconcile from "@/components/features/BankReconciliation/MatchAndReconcile"
|
||||
import Settings from "@/components/features/Settings/Settings"
|
||||
import ActionLog from "@/components/features/ActionLog/ActionLog"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { TooltipProvider } from "@/components/ui/tooltip"
|
||||
import _ from "@/lib/translate"
|
||||
import { lazy, Suspense, useLayoutEffect, useRef, useState } from "react"
|
||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, Loader2Icon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||
import { useLayoutEffect, useRef, useState } from "react"
|
||||
import { AlertTriangleIcon, CheckCircleIcon, HomeIcon, LandmarkIcon, ListIcon, ScrollTextIcon, ShuffleIcon } from "lucide-react"
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"
|
||||
@@ -18,10 +22,6 @@ import { Button } from "@/components/ui/button"
|
||||
import { useAtomValue } from "jotai"
|
||||
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 = () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ const BankReconciliation = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 220
|
||||
const remainingHeightAfterTabs = window.innerHeight - headerHeight - 270
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -122,24 +122,18 @@ const BankRecTabs = ({ remainingHeightAfterTabs }: { remainingHeightAfterTabs: n
|
||||
<TabsContent value="Match and Reconcile">
|
||||
<MatchAndReconcile contentHeight={remainingHeightAfterTabs} />
|
||||
</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">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Suspense>
|
||||
<TabsContent value="Bank Reconciliation Statement">
|
||||
<BankReconciliationStatement />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Transactions">
|
||||
<BankTransactions />
|
||||
</TabsContent>
|
||||
<TabsContent value="Bank Clearance Summary">
|
||||
<BankClearanceSummary />
|
||||
</TabsContent>
|
||||
<TabsContent value="Incorrectly Cleared Entries">
|
||||
<IncorrectlyClearedEntries />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Suspense } from 'react'
|
||||
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbPage, BreadcrumbSeparator, BreadcrumbList } from '@/components/ui/breadcrumb'
|
||||
import _ from '@/lib/translate'
|
||||
import { HomeIcon, Loader2Icon } from 'lucide-react'
|
||||
import { HomeIcon } from 'lucide-react'
|
||||
import { Link, Outlet } from 'react-router'
|
||||
|
||||
const BankStatementImporterContainer = () => {
|
||||
@@ -30,13 +29,7 @@ const BankStatementImporterContainer = () => {
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</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 />
|
||||
</Suspense>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy } from 'react'
|
||||
import CSVImport from '@/components/features/BankStatementImporter/CSV/CSVImport'
|
||||
import { useGetStatementDetails } from '@/components/features/BankStatementImporter/import_utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDirection } from '@/components/ui/direction'
|
||||
@@ -8,8 +8,6 @@ import { useFrappeDocumentEventListener } from 'frappe-react-sdk'
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
|
||||
import { Link, useParams } from 'react-router'
|
||||
|
||||
const CSVImport = lazy(() => import('@/components/features/BankStatementImporter/CSV/CSVImport'))
|
||||
|
||||
const ViewBankStatementImportLog = () => {
|
||||
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BankStatementImportLogColumnMap } from './BankStatementImportLogColumnMap'
|
||||
|
||||
export interface BankStatementImportLog {
|
||||
export interface BankStatementImportLog{
|
||||
name: string
|
||||
creation: string
|
||||
modified: string
|
||||
@@ -38,7 +38,7 @@ export interface BankStatementImportLog {
|
||||
/** Detected Date Format : Data */
|
||||
detected_date_format?: string
|
||||
/** 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?: number
|
||||
/** Detected Transaction Starting Index : Int */
|
||||
|
||||
@@ -21,35 +21,5 @@ export default defineConfig({
|
||||
outDir: '../erpnext/public/banking',
|
||||
emptyOutDir: true,
|
||||
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,6 +3333,11 @@ react-style-singleton@^2.2.2, react-style-singleton@^2.2.3:
|
||||
get-nonce "^1.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:
|
||||
version "19.2.6"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.2.6.tgz#3dadb8e12b2a7934c1d5317973e5dce1301f9a4d"
|
||||
|
||||
@@ -175,19 +175,16 @@ class Account(NestedSet):
|
||||
if cint(self.is_group):
|
||||
db_value = self.get_doc_before_save()
|
||||
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:
|
||||
query = query.set(Account.report_type, self.report_type)
|
||||
updated = True
|
||||
frappe.db.sql(
|
||||
"update `tabAccount` set report_type=%s where lft > %s and rgt < %s",
|
||||
(self.report_type, self.lft, self.rgt),
|
||||
)
|
||||
if self.root_type != db_value.root_type:
|
||||
query = query.set(Account.root_type, self.root_type)
|
||||
updated = True
|
||||
|
||||
if updated:
|
||||
query.run()
|
||||
frappe.db.sql(
|
||||
"update `tabAccount` set root_type=%s where lft > %s and rgt < %s",
|
||||
(self.root_type, self.lft, self.rgt),
|
||||
)
|
||||
|
||||
if self.root_type and not self.report_type:
|
||||
self.report_type = (
|
||||
@@ -452,7 +449,11 @@ class Account(NestedSet):
|
||||
return frappe.db.get_value("GL Entry", {"account": self.name})
|
||||
|
||||
def check_if_child_exists(self):
|
||||
return frappe.db.exists("Account", {"parent_account": self.name, "docstatus": ["!=", 2]})
|
||||
return frappe.db.sql(
|
||||
"""select name from `tabAccount` where parent_account = %s
|
||||
and docstatus != 2""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
def validate_mandatory(self):
|
||||
if not self.root_type:
|
||||
@@ -472,24 +473,14 @@ class Account(NestedSet):
|
||||
@frappe.whitelist()
|
||||
@frappe.validate_and_sanitize_search_inputs
|
||||
def get_parent_account(doctype: str, txt: str, searchfield: str, start: int, page_len: int, filters: dict):
|
||||
Account = frappe.qb.DocType("Account")
|
||||
|
||||
search_field_obj = getattr(Account, searchfield)
|
||||
|
||||
query = (
|
||||
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 frappe.db.sql(
|
||||
"""select name from tabAccount
|
||||
where is_group = 1 and docstatus != 2 and company = {}
|
||||
and {} like {} order by name limit {} offset {}""".format("%s", searchfield, "%s", "%s", "%s"),
|
||||
(filters["company"], "%%%s%%" % txt, page_len, start),
|
||||
as_list=1,
|
||||
)
|
||||
|
||||
return query.run(as_list=1)
|
||||
|
||||
|
||||
def get_account_currency(account):
|
||||
"""Helper function to get account currency"""
|
||||
@@ -530,7 +521,6 @@ def update_account_number(
|
||||
):
|
||||
_ensure_idle_system()
|
||||
account = frappe.get_cached_doc("Account", name)
|
||||
account.check_permission("write")
|
||||
if not account:
|
||||
return
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "format:Bank Statement Import on {creation}",
|
||||
"beta": 1,
|
||||
"creation": "2019-08-04 14:16:08.318714",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -226,11 +226,11 @@
|
||||
],
|
||||
"hide_toolbar": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-31 00:41:11.251215",
|
||||
"modified": "2025-06-11 02:23:22.159961",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Bank Statement Import",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_events_in_timeline": 1,
|
||||
"autoname": "naming_series:",
|
||||
"beta": 1,
|
||||
"creation": "2019-07-05 16:34:31.013238",
|
||||
"doctype": "DocType",
|
||||
"engine": "InnoDB",
|
||||
@@ -400,7 +400,7 @@
|
||||
],
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-30 23:18:04.712528",
|
||||
"modified": "2024-11-26 13:46:07.760867",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Dunning",
|
||||
@@ -449,10 +449,9 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "ASC",
|
||||
"states": [],
|
||||
"title_field": "customer_name",
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_rename": 1,
|
||||
"beta": 1,
|
||||
"creation": "2019-12-04 04:59:08.003664",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -107,7 +107,7 @@
|
||||
"link_fieldname": "dunning_type"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-30 23:18:20.740726",
|
||||
"modified": "2024-03-27 13:08:19.584112",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Dunning Type",
|
||||
@@ -151,9 +151,8 @@
|
||||
"write": 1
|
||||
}
|
||||
],
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": [],
|
||||
"track_changes": 1
|
||||
}
|
||||
}
|
||||
@@ -565,19 +565,18 @@ class FinancialQueryBuilder:
|
||||
frappe.qb.from_(acb_table)
|
||||
.select(
|
||||
acb_table.account,
|
||||
Sum(acb_table.debit - acb_table.credit).as_("balance"),
|
||||
(acb_table.debit - acb_table.credit).as_("balance"),
|
||||
)
|
||||
.where(acb_table.company == self.company)
|
||||
.where(acb_table.account.isin(account_names))
|
||||
.where(acb_table.period_closing_voucher == closing_voucher)
|
||||
.groupby(acb_table.account)
|
||||
)
|
||||
|
||||
query = self._apply_standard_filters(query, acb_table, "Account Closing Balance")
|
||||
results = self._execute_with_permissions(query, "Account Closing Balance")
|
||||
|
||||
for row in results:
|
||||
closing_balances[row["account"]] = row["balance"] or 0.0
|
||||
closing_balances[row["account"]] = row["balance"]
|
||||
|
||||
return closing_balances
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ 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.utils import get_currency_precision, get_fiscal_year
|
||||
from erpnext.tests.utils import change_settings
|
||||
|
||||
|
||||
class TestDependencyResolver(FinancialReportTemplateTestCase):
|
||||
@@ -1951,104 +1950,6 @@ class TestFinancialQueryBuilder(FinancialReportTemplateTestCase):
|
||||
|
||||
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):
|
||||
"""
|
||||
Sequence:
|
||||
|
||||
@@ -9,14 +9,6 @@ from erpnext.tests.utils import ERPNextTestSuite
|
||||
class FinancialReportTemplateTestCase(ERPNextTestSuite):
|
||||
"""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):
|
||||
"""Set up test data"""
|
||||
self.create_test_template()
|
||||
|
||||
@@ -433,17 +433,15 @@ erpnext.accounts.JournalEntry = class JournalEntry extends frappe.ui.form.Contro
|
||||
|
||||
accounts_add(doc, cdt, cdn) {
|
||||
var row = frappe.get_doc(cdt, cdn);
|
||||
if (!row.exchange_rate) row.exchange_rate = 1;
|
||||
if (!row.account) {
|
||||
$.each(doc.accounts, function (i, d) {
|
||||
if (d.account && d.party && d.party_type) {
|
||||
row.account = d.account;
|
||||
row.party = d.party;
|
||||
row.party_type = d.party_type;
|
||||
row.exchange_rate = d.exchange_rate;
|
||||
}
|
||||
});
|
||||
}
|
||||
row.exchange_rate = 1;
|
||||
$.each(doc.accounts, function (i, d) {
|
||||
if (d.account && d.party && d.party_type) {
|
||||
row.account = d.account;
|
||||
row.party = d.party;
|
||||
row.party_type = d.party_type;
|
||||
row.exchange_rate = d.exchange_rate;
|
||||
}
|
||||
});
|
||||
|
||||
// set difference
|
||||
if (doc.difference) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"allow_copy": 1,
|
||||
"beta": 1,
|
||||
"creation": "2017-08-29 02:22:54.947711",
|
||||
"doctype": "DocType",
|
||||
"editable_grid": 1,
|
||||
@@ -90,7 +90,7 @@
|
||||
"hide_toolbar": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-30 23:18:48.691227",
|
||||
"modified": "2026-03-31 01:47:20.360352",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Opening Invoice Creation Tool",
|
||||
|
||||
@@ -29,7 +29,6 @@
|
||||
{
|
||||
"fieldname": "advance_account",
|
||||
"fieldtype": "Link",
|
||||
"in_list_view": 1,
|
||||
"label": "Advance Account",
|
||||
"options": "Account"
|
||||
}
|
||||
@@ -37,15 +36,14 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-27 14:19:00.888437",
|
||||
"modified": "2024-03-27 13:10:08.489183",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Party Account",
|
||||
"owner": "Administrator",
|
||||
"permissions": [],
|
||||
"quick_entry": 1,
|
||||
"row_format": "Dynamic",
|
||||
"sort_field": "creation",
|
||||
"sort_order": "DESC",
|
||||
"states": []
|
||||
}
|
||||
}
|
||||
@@ -807,14 +807,11 @@ frappe.ui.form.on("Payment Entry", {
|
||||
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;
|
||||
if (!frm.doc.received_amount) {
|
||||
frm.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
if (company_currency == frm.doc.paid_to_account_currency) {
|
||||
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);
|
||||
} 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.set_value("base_received_amount", frm.doc.base_paid_amount);
|
||||
}
|
||||
}
|
||||
frm.trigger("reset_received_amount");
|
||||
@@ -831,14 +828,15 @@ frappe.ui.form.on("Payment Entry", {
|
||||
);
|
||||
|
||||
if (!frm.doc.paid_amount) {
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
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);
|
||||
} else if (company_currency == frm.doc.paid_from_account_currency) {
|
||||
frm.set_value("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)
|
||||
);
|
||||
frm.set_value("base_paid_amount", frm.doc.base_received_amount);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1293,16 +1293,11 @@ def get_open_payment_requests_query(
|
||||
)
|
||||
|
||||
return [
|
||||
{
|
||||
"value": pr.name,
|
||||
"description": ", ".join(
|
||||
[
|
||||
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
|
||||
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
|
||||
]
|
||||
),
|
||||
"description_html": True,
|
||||
}
|
||||
(
|
||||
pr.name,
|
||||
_("<strong>Grand Total:</strong> {0}").format(pr.grand_total),
|
||||
_("<strong>Outstanding Amount:</strong> {0}").format(pr.outstanding_amount),
|
||||
)
|
||||
for pr in open_payment_requests
|
||||
]
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ from frappe.utils import today
|
||||
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.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.tests.utils import ERPNextTestSuite
|
||||
|
||||
@@ -334,48 +333,6 @@ class TestPeriodClosingVoucher(ERPNextTestSuite):
|
||||
|
||||
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():
|
||||
company = frappe.get_doc(
|
||||
|
||||
@@ -26,6 +26,8 @@
|
||||
"due_date",
|
||||
"amended_from",
|
||||
"return_against",
|
||||
"section_break_clmv",
|
||||
"title",
|
||||
"accounting_dimensions_section",
|
||||
"project",
|
||||
"dimension_col_break",
|
||||
@@ -170,7 +172,6 @@
|
||||
"is_discounted",
|
||||
"col_break23",
|
||||
"status",
|
||||
"title",
|
||||
"more_info",
|
||||
"debit_to",
|
||||
"party_account_currency",
|
||||
@@ -1624,6 +1625,10 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Auto Repeat"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_clmv",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "title",
|
||||
@@ -1636,7 +1641,7 @@
|
||||
"icon": "fa fa-file-text",
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:22:50.253090",
|
||||
"modified": "2026-05-01 02:37:30.580568",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "POS Invoice",
|
||||
|
||||
@@ -209,14 +209,15 @@ class POSProfile(Document):
|
||||
def set_defaults(self, include_current_pos=True):
|
||||
frappe.defaults.clear_default("is_pos")
|
||||
|
||||
pfu = frappe.qb.DocType("POS Profile User")
|
||||
|
||||
query = frappe.qb.from_(pfu).select(pfu.user).where(pfu.default == 1)
|
||||
|
||||
if not include_current_pos:
|
||||
query = query.where(pfu.name != self.name)
|
||||
condition = " where pfu.name != '%s' and pfu.default = 1 " % self.name.replace("'", "'")
|
||||
else:
|
||||
condition = " where pfu.default = 1 "
|
||||
|
||||
pos_view_users = query.run(as_list=1, pluck=True)
|
||||
pos_view_users = frappe.db.sql_list(
|
||||
f"""select pfu.user
|
||||
from `tabPOS Profile User` as pfu {condition}"""
|
||||
)
|
||||
|
||||
for user in pos_view_users:
|
||||
if user:
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"actions": [],
|
||||
"allow_bulk_edit": 1,
|
||||
"autoname": "format:Process-PCV-{###}",
|
||||
"creation": "2025-09-25 15:44:03.534699",
|
||||
"doctype": "DocType",
|
||||
@@ -8,13 +7,11 @@
|
||||
"field_order": [
|
||||
"parent_pcv",
|
||||
"status",
|
||||
"amended_from",
|
||||
"section_normal_balances",
|
||||
"p_l_closing_balance",
|
||||
"bs_closing_balance",
|
||||
"normal_balances",
|
||||
"section_opening_balances",
|
||||
"z_opening_balances"
|
||||
"bs_closing_balance",
|
||||
"z_opening_balances",
|
||||
"amended_from"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@@ -67,27 +64,17 @@
|
||||
"fieldname": "bs_closing_balance",
|
||||
"fieldtype": "JSON",
|
||||
"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,
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-06-01 12:16:37.374412",
|
||||
"modified": "2025-11-05 11:40:24.996403",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Process Period Closing Voucher",
|
||||
"naming_rule": "Expression (old style)",
|
||||
"naming_rule": "Expression",
|
||||
"owner": "Administrator",
|
||||
"permissions": [
|
||||
{
|
||||
|
||||
@@ -36,8 +36,8 @@ class ProcessPeriodClosingVoucher(Document):
|
||||
parent_pcv: DF.Link
|
||||
status: DF.Literal["Queued", "Running", "Paused", "Completed", "Cancelled"]
|
||||
z_opening_balances: DF.Table[ProcessPeriodClosingVoucherDetail]
|
||||
# end: auto-generated types
|
||||
|
||||
# end: auto-generated types
|
||||
def on_discard(self):
|
||||
self.db_set("status", "Cancelled")
|
||||
|
||||
@@ -137,10 +137,9 @@ def pause_pcv_processing(docname: str):
|
||||
ppcv = qb.DocType("Process Period Closing Voucher")
|
||||
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(
|
||||
"Process Period Closing Voucher Detail",
|
||||
filters={"parent": docname, "status": ["in", ["Queued", "Running"]]},
|
||||
filters={"parent": docname, "status": "Queued"},
|
||||
pluck="name",
|
||||
):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
@@ -174,9 +173,6 @@ def resume_pcv_processing(docname: str):
|
||||
ppcvd = qb.DocType("Process Period Closing Voucher Detail")
|
||||
qb.update(ppcvd).set(ppcvd.status, "Queued").where(ppcvd.name.isin(paused_dates)).run()
|
||||
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):
|
||||
@@ -292,21 +288,7 @@ def schedule_next_date(docname: str):
|
||||
)
|
||||
# Ensure both normal and opening balances are processed for all dates
|
||||
if total_no_of_dates == completed:
|
||||
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,
|
||||
)
|
||||
summarize_and_post_ledger_entries(docname)
|
||||
|
||||
|
||||
def make_dict_json_compliant(dimension_wise_balance) -> dict:
|
||||
@@ -562,9 +544,6 @@ def process_individual_date(docname: str, date, report_type, parentfield):
|
||||
|
||||
if parentfield == "z_opening_balances":
|
||||
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)
|
||||
for dim in dimensions:
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// 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,173 +1,4 @@
|
||||
# Copyright (c) 2025, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# See license.txt
|
||||
|
||||
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])
|
||||
# import frappe
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"update_billed_amount_in_purchase_receipt",
|
||||
"apply_tds",
|
||||
"amended_from",
|
||||
"section_break_ecfi",
|
||||
"title",
|
||||
"supplier_invoice_details",
|
||||
"bill_no",
|
||||
"column_break_15",
|
||||
@@ -200,7 +202,6 @@
|
||||
"hold_comment",
|
||||
"additional_info_section",
|
||||
"is_internal_supplier",
|
||||
"title",
|
||||
"represents_company",
|
||||
"supplier_group",
|
||||
"sender",
|
||||
@@ -1683,6 +1684,10 @@
|
||||
"fieldname": "automation_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Automation"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_ecfi",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -1690,7 +1695,7 @@
|
||||
"idx": 204,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:36:55.215363",
|
||||
"modified": "2026-05-04 10:10:11.717131",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Purchase Invoice",
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"is_created_using_pos",
|
||||
"pos_closing_entry",
|
||||
"has_subcontracted",
|
||||
"section_break_sgnf",
|
||||
"title",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
@@ -230,7 +232,6 @@
|
||||
"status",
|
||||
"remarks",
|
||||
"customer_group",
|
||||
"title",
|
||||
"column_break_imbx",
|
||||
"is_internal_customer",
|
||||
"represents_company",
|
||||
@@ -2332,6 +2333,10 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Automation"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_sgnf",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "title",
|
||||
@@ -2352,7 +2357,7 @@
|
||||
"link_fieldname": "consolidated_invoice"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-28 12:15:12.486443",
|
||||
"modified": "2026-05-21 17:31:11.190958",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice",
|
||||
|
||||
@@ -969,6 +969,9 @@ class SalesInvoice(SellingController):
|
||||
if selling_price_list:
|
||||
self.set("selling_price_list", selling_price_list)
|
||||
|
||||
if not for_validate:
|
||||
self.update_stock = cint(pos.get("update_stock"))
|
||||
|
||||
# set pos values in items
|
||||
for item in self.get("items"):
|
||||
if item.get("item_code"):
|
||||
@@ -979,10 +982,6 @@ class SalesInvoice(SellingController):
|
||||
if (not for_validate) or (for_validate and not item.get(fname)):
|
||||
item.set(fname, val)
|
||||
|
||||
if not for_validate:
|
||||
dn_flag = any(d.get("dn_detail") for d in self.get("items"))
|
||||
self.update_stock = 0 if dn_flag else cint(pos.get("update_stock"))
|
||||
|
||||
# fetch terms
|
||||
if self.tc_name and not self.terms:
|
||||
self.terms = frappe.db.get_value("Terms and Conditions", self.tc_name, "terms")
|
||||
@@ -994,7 +993,7 @@ class SalesInvoice(SellingController):
|
||||
return pos
|
||||
|
||||
def get_company_abbr(self):
|
||||
return frappe.db.get_value("Company", self.company, "abbr")
|
||||
return frappe.db.sql("select abbr from tabCompany where name=%s", self.company)[0][0]
|
||||
|
||||
def validate_debit_to_acc(self):
|
||||
if not self.debit_to:
|
||||
@@ -1034,7 +1033,11 @@ class SalesInvoice(SellingController):
|
||||
def clear_unallocated_mode_of_payments(self):
|
||||
self.set("payments", self.get("payments", {"amount": ["not in", [0, None, ""]]}))
|
||||
|
||||
frappe.db.delete("Sales Invoice Payment", filters={"parent": self.name, "amount": 0})
|
||||
frappe.db.sql(
|
||||
"""delete from `tabSales Invoice Payment` where parent = %s
|
||||
and amount = 0""",
|
||||
self.name,
|
||||
)
|
||||
|
||||
def validate_with_previous_doc(self):
|
||||
super().validate_with_previous_doc(
|
||||
@@ -1130,20 +1133,12 @@ class SalesInvoice(SellingController):
|
||||
def validate_proj_cust(self):
|
||||
"""check for does customer belong to same project as entered.."""
|
||||
if self.project and self.customer:
|
||||
Project = frappe.qb.DocType("Project")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(Project)
|
||||
.select(Project.name)
|
||||
.where(Project.name == self.project)
|
||||
.where(
|
||||
(Project.customer == self.customer)
|
||||
| (Project.customer.isnull())
|
||||
| (Project.customer == "")
|
||||
)
|
||||
res = frappe.db.sql(
|
||||
"""select name from `tabProject`
|
||||
where name = %s and (customer = %s or customer is null or customer = '')""",
|
||||
(self.project, self.customer),
|
||||
)
|
||||
|
||||
if not query.run():
|
||||
if not res:
|
||||
throw(_("Customer {0} does not belong to project {1}").format(self.customer, self.project))
|
||||
|
||||
def validate_pos(self):
|
||||
@@ -1368,28 +1363,19 @@ class SalesInvoice(SellingController):
|
||||
self.total_billing_hours = timesheet_sum("billing_hours")
|
||||
|
||||
def get_warehouse(self):
|
||||
POSProfile = frappe.qb.DocType("POS Profile")
|
||||
|
||||
user_query = (
|
||||
frappe.qb.from_(POSProfile)
|
||||
.select(POSProfile.name, POSProfile.warehouse)
|
||||
.where(POSProfile.company == self.company)
|
||||
.where(
|
||||
(POSProfile.user == frappe.session["user"])
|
||||
| ((POSProfile.user.isnull() | (POSProfile.user == "")) & (frappe.session["user"] == ""))
|
||||
)
|
||||
user_pos_profile = frappe.db.sql(
|
||||
"""select name, warehouse from `tabPOS Profile`
|
||||
where ifnull(user,'') = %s and company = %s""",
|
||||
(frappe.session["user"], self.company),
|
||||
)
|
||||
user_pos_profile = user_query.run()
|
||||
warehouse = user_pos_profile[0][1] if user_pos_profile else None
|
||||
|
||||
if not warehouse:
|
||||
global_query = (
|
||||
frappe.qb.from_(POSProfile)
|
||||
.select(POSProfile.name, POSProfile.warehouse)
|
||||
.where(POSProfile.company == self.company)
|
||||
.where(POSProfile.user.isnull() | (POSProfile.user == ""))
|
||||
global_pos_profile = frappe.db.sql(
|
||||
"""select name, warehouse from `tabPOS Profile`
|
||||
where (user is null or user = '') and company = %s""",
|
||||
self.company,
|
||||
)
|
||||
global_pos_profile = global_query.run()
|
||||
|
||||
if global_pos_profile:
|
||||
warehouse = global_pos_profile[0][1]
|
||||
@@ -2119,24 +2105,15 @@ class SalesInvoice(SellingController):
|
||||
def update_billing_status_in_dn(self, update_modified=True):
|
||||
if self.is_return and not self.update_billed_amount_in_delivery_note:
|
||||
return
|
||||
|
||||
updated_delivery_notes = []
|
||||
|
||||
SalesInvoiceItem = frappe.qb.DocType("Sales Invoice Item")
|
||||
from frappe.query_builder.functions import Coalesce, Sum
|
||||
|
||||
for d in self.get("items"):
|
||||
if d.dn_detail:
|
||||
query = (
|
||||
frappe.qb.from_(SalesInvoiceItem)
|
||||
.select(Coalesce(Sum(SalesInvoiceItem.amount), 0))
|
||||
.where(SalesInvoiceItem.dn_detail == d.dn_detail)
|
||||
.where(SalesInvoiceItem.docstatus == 1)
|
||||
billed_amt = frappe.db.sql(
|
||||
"""select sum(amount) from `tabSales Invoice Item`
|
||||
where dn_detail=%s and docstatus=1""",
|
||||
d.dn_detail,
|
||||
)
|
||||
|
||||
res = query.run()
|
||||
billed_amt = res[0][0] if res else 0
|
||||
|
||||
billed_amt = billed_amt and billed_amt[0][0] or 0
|
||||
frappe.db.set_value(
|
||||
"Delivery Note Item",
|
||||
d.dn_detail,
|
||||
@@ -2410,21 +2387,19 @@ def is_overdue(doc, total):
|
||||
def get_discounting_status(sales_invoice):
|
||||
status = None
|
||||
|
||||
InvoiceDiscounting = frappe.qb.DocType("Invoice Discounting")
|
||||
DiscountedInvoice = frappe.qb.DocType("Discounted Invoice")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(InvoiceDiscounting)
|
||||
.join(DiscountedInvoice)
|
||||
.on(InvoiceDiscounting.name == DiscountedInvoice.parent)
|
||||
.select(InvoiceDiscounting.status)
|
||||
.where(DiscountedInvoice.sales_invoice == sales_invoice)
|
||||
.where(InvoiceDiscounting.docstatus == 1)
|
||||
.where(InvoiceDiscounting.status.isin(["Disbursed", "Settled"]))
|
||||
invoice_discounting_list = frappe.db.sql(
|
||||
"""
|
||||
select status
|
||||
from `tabInvoice Discounting` id, `tabDiscounted Invoice` d
|
||||
where
|
||||
id.name = d.parent
|
||||
and d.sales_invoice=%s
|
||||
and id.docstatus=1
|
||||
and status in ('Disbursed', 'Settled')
|
||||
""",
|
||||
sales_invoice,
|
||||
)
|
||||
|
||||
invoice_discounting_list = query.run()
|
||||
|
||||
for d in invoice_discounting_list:
|
||||
status = d[0]
|
||||
if status == "Disbursed":
|
||||
@@ -3142,22 +3117,15 @@ def update_multi_mode_option(doc, pos_profile):
|
||||
|
||||
|
||||
def get_all_mode_of_payments(doc):
|
||||
ModeOfPaymentAccount = frappe.qb.DocType("Mode of Payment Account")
|
||||
ModeOfPayment = frappe.qb.DocType("Mode of Payment")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(ModeOfPaymentAccount)
|
||||
.join(ModeOfPayment)
|
||||
.on(ModeOfPaymentAccount.parent == ModeOfPayment.name)
|
||||
.select(
|
||||
ModeOfPaymentAccount.default_account, ModeOfPaymentAccount.parent, ModeOfPayment.type.as_("type")
|
||||
)
|
||||
.where(ModeOfPaymentAccount.company == doc.company)
|
||||
.where(ModeOfPayment.enabled == 1)
|
||||
return frappe.db.sql(
|
||||
"""
|
||||
select mpa.default_account, mpa.parent, mp.type as type
|
||||
from `tabMode of Payment Account` mpa,`tabMode of Payment` mp
|
||||
where mpa.parent = mp.name and mpa.company = %(company)s and mp.enabled = 1""",
|
||||
{"company": doc.company},
|
||||
as_dict=1,
|
||||
)
|
||||
|
||||
return query.run(as_dict=1)
|
||||
|
||||
|
||||
def get_mode_of_payments_info(mode_of_payments, company):
|
||||
data = frappe.db.sql(
|
||||
@@ -3249,36 +3217,37 @@ def create_dunning(
|
||||
def check_if_return_invoice_linked_with_payment_entry(self):
|
||||
# If a Return invoice is linked with payment entry along with other invoices,
|
||||
# the cancellation of the Return causes allocated amount to be greater than paid
|
||||
|
||||
if not frappe.get_single_value("Accounts Settings", "unlink_payment_on_cancellation_of_invoice"):
|
||||
return
|
||||
|
||||
payment_entries = []
|
||||
if self.is_return and self.return_against:
|
||||
invoice = self.return_against
|
||||
else:
|
||||
invoice = self.name
|
||||
|
||||
PaymentEntry = frappe.qb.DocType("Payment Entry")
|
||||
PaymentEntryReference = frappe.qb.DocType("Payment Entry Reference")
|
||||
|
||||
query = (
|
||||
frappe.qb.from_(PaymentEntry)
|
||||
.join(PaymentEntryReference)
|
||||
.on(PaymentEntry.name == PaymentEntryReference.parent)
|
||||
.select(PaymentEntry.name)
|
||||
.where(PaymentEntry.docstatus == 1)
|
||||
.where(PaymentEntryReference.reference_name == invoice)
|
||||
.where(PaymentEntryReference.allocated_amount < 0)
|
||||
payment_entries = frappe.db.sql_list(
|
||||
"""
|
||||
SELECT
|
||||
t1.name
|
||||
FROM
|
||||
`tabPayment Entry` t1, `tabPayment Entry Reference` t2
|
||||
WHERE
|
||||
t1.name = t2.parent
|
||||
and t1.docstatus = 1
|
||||
and t2.reference_name = %s
|
||||
and t2.allocated_amount < 0
|
||||
""",
|
||||
invoice,
|
||||
)
|
||||
|
||||
payment_entries = query.run(pluck=True)
|
||||
|
||||
links_to_pe = []
|
||||
if payment_entries:
|
||||
for payment in payment_entries:
|
||||
payment_entry = frappe.get_doc("Payment Entry", payment)
|
||||
if len(payment_entry.references) > 1:
|
||||
links_to_pe.append(payment_entry.name)
|
||||
|
||||
if links_to_pe:
|
||||
payment_entries_link = [
|
||||
get_link_to_form("Payment Entry", name, label=name) for name in links_to_pe
|
||||
|
||||
@@ -947,8 +947,7 @@
|
||||
"fieldtype": "Currency",
|
||||
"label": "Distributed Discount Amount",
|
||||
"options": "currency",
|
||||
"print_hide": 1,
|
||||
"read_only": 1
|
||||
"print_hide": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "available_quantity_section",
|
||||
@@ -1017,7 +1016,7 @@
|
||||
"idx": 1,
|
||||
"istable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-29 12:23:28.259905",
|
||||
"modified": "2026-02-24 14:37:16.853941",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Accounts",
|
||||
"name": "Sales Invoice Item",
|
||||
|
||||
@@ -7,7 +7,7 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.query_builder.functions import Sum
|
||||
from frappe.utils import cstr, getdate
|
||||
from frappe.utils import getdate
|
||||
|
||||
from erpnext import allow_regional
|
||||
from erpnext.controllers.accounts_controller import validate_account_head
|
||||
@@ -48,7 +48,7 @@ class TaxWithholdingCategory(Document):
|
||||
for d in self.get("rates"):
|
||||
if getdate(d.from_date) >= getdate(d.to_date):
|
||||
frappe.throw(_("Row #{0}: From Date cannot be before To Date").format(d.idx))
|
||||
group_rates[cstr(d.tax_withholding_group)].append(d)
|
||||
group_rates[d.tax_withholding_group].append(d)
|
||||
|
||||
# Validate overlapping dates within each group
|
||||
for group, rates in group_rates.items():
|
||||
@@ -92,9 +92,10 @@ class TaxWithholdingCategory(Document):
|
||||
|
||||
def get_applicable_tax_row(self, posting_date, tax_withholding_group):
|
||||
for row in self.rates:
|
||||
if getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date) and cstr(
|
||||
row.tax_withholding_group
|
||||
) == cstr(tax_withholding_group):
|
||||
if (
|
||||
getdate(row.from_date) <= getdate(posting_date) <= getdate(row.to_date)
|
||||
and row.tax_withholding_group == tax_withholding_group
|
||||
):
|
||||
return row
|
||||
|
||||
frappe.throw(_("No Tax Withholding data found for the current posting date."))
|
||||
@@ -115,7 +116,7 @@ class TaxWithholdingDetails:
|
||||
def __init__(
|
||||
self,
|
||||
tax_withholding_categories: list[str],
|
||||
tax_withholding_group: str | None,
|
||||
tax_withholding_group: str,
|
||||
posting_date: str,
|
||||
party_type: str,
|
||||
party: str,
|
||||
|
||||
@@ -999,47 +999,6 @@ class TestTaxWithholdingCategory(ERPNextTestSuite):
|
||||
|
||||
self.cleanup_invoices(invoices)
|
||||
|
||||
def test_null_and_empty_tax_withholding_group_are_equivalent(self):
|
||||
"""
|
||||
NULL and empty-string `tax_withholding_group` must be treated as the
|
||||
same value.
|
||||
"""
|
||||
category = frappe.get_doc("Tax Withholding Category", "Cumulative Threshold TDS")
|
||||
original_row = category.rates[0]
|
||||
original_row.tax_withholding_group = None
|
||||
|
||||
# Part 1: validate_dates must detect overlap between NULL-group and
|
||||
# empty-string-group rows covering the same date range.
|
||||
category.append(
|
||||
"rates",
|
||||
{
|
||||
"from_date": original_row.from_date,
|
||||
"to_date": original_row.to_date,
|
||||
"tax_withholding_group": "",
|
||||
"tax_withholding_rate": original_row.tax_withholding_rate,
|
||||
},
|
||||
)
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
category.validate_dates()
|
||||
category.rates.pop()
|
||||
|
||||
# Part 2: get_applicable_tax_row must match NULL <-> "" in either direction.
|
||||
posting_date = original_row.from_date
|
||||
|
||||
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="")
|
||||
self.assertEqual(row.name, original_row.name)
|
||||
|
||||
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
|
||||
self.assertEqual(row.name, original_row.name)
|
||||
|
||||
original_row.tax_withholding_group = ""
|
||||
row = category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group=None)
|
||||
self.assertEqual(row.name, original_row.name)
|
||||
|
||||
original_row.tax_withholding_group = None
|
||||
with self.assertRaises(frappe.ValidationError):
|
||||
category.get_applicable_tax_row(posting_date=posting_date, tax_withholding_group="194R")
|
||||
|
||||
def test_tds_calculation_on_net_total(self):
|
||||
self.setup_party_with_category("Supplier", "Test TDS Supplier4", "Cumulative Threshold TDS")
|
||||
invoices = []
|
||||
|
||||
@@ -718,12 +718,7 @@ def make_reverse_gl_entries(
|
||||
check_freezing_date(gl_entries[0]["posting_date"], adv_adj)
|
||||
|
||||
is_opening = any(d.get("is_opening") == "Yes" for d in gl_entries)
|
||||
|
||||
# For reverse entries, use the posting_date parameter if provided and valid
|
||||
# Otherwise fall back to original posting_date
|
||||
validation_date = posting_date if posting_date else gl_entries[0]["posting_date"]
|
||||
validate_against_pcv(is_opening, validation_date, gl_entries[0]["company"])
|
||||
|
||||
validate_against_pcv(is_opening, gl_entries[0]["posting_date"], gl_entries[0]["company"])
|
||||
if partial_cancel:
|
||||
# Partial cancel is only used by `Advance` in separate account feature.
|
||||
# Only cancel GL entries for unlinked reference using `voucher_detail_no`
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
"section_break_vwgg",
|
||||
"maintain_same_rate",
|
||||
"column_break_lwxs",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
"maintain_same_rate_action",
|
||||
"role_to_override_stop_action",
|
||||
"transaction_settings_section",
|
||||
@@ -25,8 +24,7 @@
|
||||
"po_required",
|
||||
"pr_required",
|
||||
"project_update_frequency",
|
||||
"over_order_allowance",
|
||||
"column_break_kdcm",
|
||||
"column_break_12",
|
||||
"allow_multiple_items",
|
||||
"allow_negative_rates_for_items",
|
||||
"set_valuation_rate_for_rejected_materials",
|
||||
@@ -35,6 +33,7 @@
|
||||
"purchase_invoice_settings_section",
|
||||
"bill_for_rejected_quantity_in_purchase_invoice",
|
||||
"use_transaction_date_exchange_rate",
|
||||
"set_landed_cost_based_on_purchase_invoice_rate",
|
||||
"zero_quantity_line_items_section",
|
||||
"allow_zero_qty_in_supplier_quotation",
|
||||
"allow_zero_qty_in_request_for_quotation",
|
||||
@@ -157,6 +156,10 @@
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "Transaction Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_12",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Prevents the system from automatically using the rate from the last purchase transaction when creating new purchase orders or transactions.",
|
||||
@@ -332,16 +335,6 @@
|
||||
"hidden": 1,
|
||||
"is_virtual": 1,
|
||||
"label": "Naming Series options"
|
||||
},
|
||||
{
|
||||
"description": "The percentage by which you are allowed to order more on a Purchase Order than the quantity requested on the originating Material Request. For example, if the Material Request has 100 units and the allowance is 10%, you can order up to 110 units",
|
||||
"fieldname": "over_order_allowance",
|
||||
"fieldtype": "Float",
|
||||
"label": "Over Order Allowance (%)"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_kdcm",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -350,7 +343,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-27 23:04:00.842393",
|
||||
"modified": "2026-05-05 16:30:37.184607",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Buying Settings",
|
||||
|
||||
@@ -34,7 +34,6 @@ class BuyingSettings(Document):
|
||||
fixed_email: DF.Link | None
|
||||
maintain_same_rate: DF.Check
|
||||
maintain_same_rate_action: DF.Literal["Stop", "Warn"]
|
||||
over_order_allowance: DF.Float
|
||||
over_transfer_allowance: DF.Float
|
||||
po_required: DF.Literal["No", "Yes"]
|
||||
pr_required: DF.Literal["No", "Yes"]
|
||||
|
||||
@@ -24,6 +24,8 @@
|
||||
"is_subcontracted",
|
||||
"has_unit_price_items",
|
||||
"supplier_warehouse",
|
||||
"section_break_zymg",
|
||||
"title",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
@@ -55,7 +57,9 @@
|
||||
"net_total",
|
||||
"section_break_48",
|
||||
"pricing_rules",
|
||||
"raw_material_details",
|
||||
"set_reserve_warehouse",
|
||||
"supplied_items",
|
||||
"taxes_section",
|
||||
"tax_category",
|
||||
"taxes_and_charges",
|
||||
@@ -153,7 +157,6 @@
|
||||
"auto_repeat",
|
||||
"update_auto_repeat_reference",
|
||||
"additional_info_section",
|
||||
"title",
|
||||
"party_account_currency",
|
||||
"represents_company",
|
||||
"ref_sq",
|
||||
@@ -1291,6 +1294,10 @@
|
||||
"fieldname": "auto_repeat_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Auto Repeat"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_zymg",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -1298,7 +1305,7 @@
|
||||
"idx": 105,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:34:19.659621",
|
||||
"modified": "2026-05-04 10:10:22.608381",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Purchase Order",
|
||||
|
||||
@@ -178,9 +178,6 @@ class PurchaseOrder(BuyingController):
|
||||
"target_ref_field": "stock_qty",
|
||||
"source_field": "stock_qty",
|
||||
"percent_join_field": "material_request",
|
||||
"global_allowance_field": "over_order_allowance",
|
||||
"global_allowance_doctype": "Buying Settings",
|
||||
"item_allowance_field": "over_order_allowance",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -128,44 +128,6 @@ class TestPurchaseOrder(ERPNextTestSuite):
|
||||
frappe.db.set_value("Item", "_Test Item", "over_billing_allowance", 0)
|
||||
frappe.db.set_single_value("Accounts Settings", "over_billing_allowance", 0)
|
||||
|
||||
def test_over_order_allowance_against_material_request(self) -> None:
|
||||
"""Over Order Allowance in Buying Settings must govern PO qty vs MR qty independently
|
||||
from Over Delivery/Receipt Allowance which governs receipt/delivery against a PO."""
|
||||
mr = make_material_request(qty=100)
|
||||
po = make_purchase_order(mr.name)
|
||||
po.supplier = "_Test Supplier"
|
||||
po.items[0].qty = 110 # 10% over the MR qty
|
||||
|
||||
# Without any allowance, submitting should raise an OverAllowanceError
|
||||
from erpnext.controllers.status_updater import OverAllowanceError
|
||||
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
|
||||
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
|
||||
self.assertRaises(OverAllowanceError, po.submit)
|
||||
|
||||
# Granting 10% in Over Order Allowance (Buying Settings) must allow the submit
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 10)
|
||||
po.reload()
|
||||
po.items[0].qty = 110
|
||||
po.submit()
|
||||
self.assertEqual(po.docstatus, 1)
|
||||
po.cancel()
|
||||
|
||||
# Over Delivery/Receipt Allowance must remain independent — changing it must not
|
||||
# affect the MR → PO validation when Over Order Allowance is 0.
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
|
||||
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 50)
|
||||
|
||||
mr2 = make_material_request(qty=100)
|
||||
po2 = make_purchase_order(mr2.name)
|
||||
po2.supplier = "_Test Supplier"
|
||||
po2.items[0].qty = 110
|
||||
self.assertRaises(OverAllowanceError, po2.submit)
|
||||
|
||||
# cleanup
|
||||
frappe.db.set_single_value("Buying Settings", "over_order_allowance", 0)
|
||||
frappe.db.set_single_value("Stock Settings", "over_delivery_receipt_allowance", 0)
|
||||
|
||||
def test_update_remove_child_linked_to_mr(self):
|
||||
"""Test impact on linked PO and MR on deleting/updating row."""
|
||||
mr = make_material_request(qty=10)
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"status",
|
||||
"has_unit_price_items",
|
||||
"amended_from",
|
||||
"section_break_trpf",
|
||||
"title",
|
||||
"suppliers_section",
|
||||
"suppliers",
|
||||
"items_section",
|
||||
@@ -42,7 +44,6 @@
|
||||
"letter_head",
|
||||
"more_info",
|
||||
"opportunity",
|
||||
"title",
|
||||
"address_and_contact_tab",
|
||||
"billing_address",
|
||||
"billing_address_display",
|
||||
@@ -373,6 +374,10 @@
|
||||
"label": "Shipping Address Details",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_trpf",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"allow_on_submit": 1,
|
||||
"fieldname": "title",
|
||||
@@ -387,7 +392,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:28:46.606963",
|
||||
"modified": "2026-03-30 12:18:08.451201",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Request for Quotation",
|
||||
|
||||
@@ -6,7 +6,6 @@ import json
|
||||
|
||||
import frappe
|
||||
from frappe import _
|
||||
from frappe.contacts.doctype.contact.contact import get_full_name
|
||||
from frappe.core.doctype.communication.email import make
|
||||
from frappe.desk.form.load import get_attachments
|
||||
from frappe.model.document import Document
|
||||
@@ -277,20 +276,12 @@ class RequestforQuotation(BuyingController):
|
||||
supplier_doc.save()
|
||||
|
||||
def create_user(self, rfq_supplier, link):
|
||||
contact_name = None
|
||||
if rfq_supplier.contact:
|
||||
name_fields = frappe.get_value(
|
||||
"Contact", rfq_supplier.contact, ["first_name", "middle_name", "last_name"]
|
||||
)
|
||||
if name_fields:
|
||||
contact_name = get_full_name(*name_fields)
|
||||
|
||||
user = frappe.get_doc(
|
||||
{
|
||||
"doctype": "User",
|
||||
"send_welcome_email": 0,
|
||||
"email": rfq_supplier.email_id,
|
||||
"first_name": contact_name or rfq_supplier.supplier_name or rfq_supplier.supplier,
|
||||
"first_name": rfq_supplier.supplier_name or rfq_supplier.supplier,
|
||||
"user_type": "Website User",
|
||||
"redirect_url": link,
|
||||
}
|
||||
|
||||
@@ -68,31 +68,6 @@ frappe.ui.form.on("Supplier", {
|
||||
});
|
||||
|
||||
frm.make_methods = {
|
||||
"Purchase Order": () =>
|
||||
frappe.model.with_doctype("Purchase Order", function () {
|
||||
const po = frappe.model.get_new_doc("Purchase Order");
|
||||
po.supplier = frm.doc.name;
|
||||
frappe.set_route("Form", "Purchase Order", po.name);
|
||||
}),
|
||||
"Purchase Invoice": () =>
|
||||
frappe.model.with_doctype("Purchase Invoice", function () {
|
||||
const pi = frappe.model.get_new_doc("Purchase Invoice");
|
||||
pi.supplier = frm.doc.name;
|
||||
frappe.set_route("Form", "Purchase Invoice", pi.name);
|
||||
}),
|
||||
"Request for Quotation": () =>
|
||||
frappe.model.with_doctype("Request for Quotation", function () {
|
||||
const rfq = frappe.model.get_new_doc("Request for Quotation");
|
||||
const row = frappe.model.add_child(rfq, "suppliers");
|
||||
row.supplier = frm.doc.name;
|
||||
frappe.set_route("Form", "Request for Quotation", rfq.name);
|
||||
}),
|
||||
"Supplier Quotation": () =>
|
||||
frappe.model.with_doctype("Supplier Quotation", function () {
|
||||
const sq = frappe.model.get_new_doc("Supplier Quotation");
|
||||
sq.supplier = frm.doc.name;
|
||||
frappe.set_route("Form", "Supplier Quotation", sq.name);
|
||||
}),
|
||||
"Bank Account": () => erpnext.utils.make_bank_account(frm.doc.doctype, frm.doc.name),
|
||||
"Pricing Rule": () => frm.trigger("make_pricing_rule"),
|
||||
};
|
||||
@@ -142,20 +117,6 @@ frappe.ui.form.on("Supplier", {
|
||||
__("View")
|
||||
);
|
||||
|
||||
for (const doctype in frm.make_methods) {
|
||||
frm.add_custom_button(__(doctype), frm.make_methods[doctype], __("Create"));
|
||||
}
|
||||
|
||||
if (frm.doc.supplier_group) {
|
||||
frm.add_custom_button(
|
||||
__("Get Supplier Group Details"),
|
||||
function () {
|
||||
frm.trigger("get_supplier_group_details");
|
||||
},
|
||||
__("Actions")
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
cint(frappe.defaults.get_default("enable_common_party_accounting")) &&
|
||||
frappe.model.can_create("Party Link")
|
||||
@@ -212,8 +173,6 @@ frappe.ui.form.on("Supplier", {
|
||||
frm.toggle_reqd("represents_company", true);
|
||||
} else {
|
||||
frm.toggle_reqd("represents_company", false);
|
||||
frm.set_value("represents_company", "");
|
||||
frm.set_value("companies", []);
|
||||
}
|
||||
},
|
||||
show_party_link_dialog: function (frm) {
|
||||
|
||||
@@ -11,75 +11,72 @@
|
||||
"engine": "InnoDB",
|
||||
"field_order": [
|
||||
"naming_series",
|
||||
"supplier_name",
|
||||
"supplier_type",
|
||||
"supplier_name",
|
||||
"gender",
|
||||
"column_break0",
|
||||
"supplier_group",
|
||||
"country",
|
||||
"is_transporter",
|
||||
"image",
|
||||
"defaults_section",
|
||||
"default_currency",
|
||||
"default_bank_account",
|
||||
"column_break_10",
|
||||
"default_price_list",
|
||||
"payment_terms",
|
||||
"column_break2",
|
||||
"supplier_details",
|
||||
"column_break_30",
|
||||
"website",
|
||||
"language",
|
||||
"customer_numbers",
|
||||
"contact_and_address_tab",
|
||||
"address_contacts",
|
||||
"address_html",
|
||||
"column_break1",
|
||||
"contact_html",
|
||||
"primary_address_and_contact_detail_section",
|
||||
"column_break_44",
|
||||
"supplier_primary_address",
|
||||
"primary_address",
|
||||
"column_break_mglr",
|
||||
"supplier_primary_contact",
|
||||
"mobile_no",
|
||||
"email_id",
|
||||
"tax_tab",
|
||||
"tax_id",
|
||||
"tax_category",
|
||||
"column_break_27",
|
||||
"tax_withholding_category",
|
||||
"tax_withholding_group",
|
||||
"accounting_tab",
|
||||
"payment_terms",
|
||||
"default_accounts_section",
|
||||
"accounts",
|
||||
"internal_supplier_section",
|
||||
"is_internal_supplier",
|
||||
"represents_company",
|
||||
"column_break_16",
|
||||
"section_break_pgad",
|
||||
"companies",
|
||||
"tax_tab",
|
||||
"taxation_section",
|
||||
"tax_id",
|
||||
"tax_category",
|
||||
"column_break_27",
|
||||
"tax_withholding_category",
|
||||
"tax_withholding_group",
|
||||
"settings_tab",
|
||||
"invoice_settings_section",
|
||||
"is_transporter",
|
||||
"allow_purchase_invoice_creation_without_purchase_order",
|
||||
"allow_purchase_invoice_creation_without_purchase_receipt",
|
||||
"column_break_54",
|
||||
"disabled",
|
||||
"is_frozen",
|
||||
"block_supplier_section",
|
||||
"on_hold",
|
||||
"hold_type",
|
||||
"release_date",
|
||||
"rfq_and_purchase_order_settings_section",
|
||||
"warn_rfqs",
|
||||
"prevent_rfqs",
|
||||
"column_break_oxjw",
|
||||
"warn_pos",
|
||||
"prevent_pos",
|
||||
"block_supplier_section",
|
||||
"on_hold",
|
||||
"hold_type",
|
||||
"column_break_59",
|
||||
"release_date",
|
||||
"portal_users_tab",
|
||||
"portal_users",
|
||||
"more_info_tab",
|
||||
"column_break2",
|
||||
"website",
|
||||
"language",
|
||||
"column_break_30",
|
||||
"supplier_details",
|
||||
"section_break_jqla",
|
||||
"customer_numbers",
|
||||
"dashboard_tab"
|
||||
],
|
||||
"fields": [
|
||||
@@ -113,24 +110,21 @@
|
||||
{
|
||||
"fieldname": "default_bank_account",
|
||||
"fieldtype": "Link",
|
||||
"label": "Company Bank Account",
|
||||
"label": "Default Company Bank Account",
|
||||
"options": "Bank Account"
|
||||
},
|
||||
{
|
||||
"description": "Supplier's tax identification number (e.g. PAN, VAT, GST)",
|
||||
"fieldname": "tax_id",
|
||||
"fieldtype": "Data",
|
||||
"label": "Tax ID"
|
||||
},
|
||||
{
|
||||
"description": "Determines which tax rules apply to this supplier",
|
||||
"fieldname": "tax_category",
|
||||
"fieldtype": "Link",
|
||||
"label": "Tax Category",
|
||||
"options": "Tax Category"
|
||||
},
|
||||
{
|
||||
"description": "TDS / withholding tax category applied when paying this supplier",
|
||||
"fieldname": "tax_withholding_category",
|
||||
"fieldtype": "Link",
|
||||
"label": "Tax Withholding Category",
|
||||
@@ -138,18 +132,15 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Enable to make this supplier selectable as a transporter on Delivery Notes and Stock Entries",
|
||||
"fieldname": "is_transporter",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Transporter"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Used for inter-company transactions",
|
||||
"fieldname": "is_internal_supplier",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Internal Supplier",
|
||||
"show_description_on_click": 1
|
||||
"label": "Is Internal Supplier"
|
||||
},
|
||||
{
|
||||
"depends_on": "is_internal_supplier",
|
||||
@@ -201,7 +192,6 @@
|
||||
{
|
||||
"bold": 1,
|
||||
"default": "0",
|
||||
"description": "Disabled suppliers are hidden from selection in new transactions but remain in historical records",
|
||||
"fieldname": "disabled",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disabled"
|
||||
@@ -242,7 +232,7 @@
|
||||
"depends_on": "represents_company",
|
||||
"fieldname": "companies",
|
||||
"fieldtype": "Table",
|
||||
"label": "Allowed to transact with",
|
||||
"label": "Allowed To Transact With",
|
||||
"options": "Allowed To Transact With"
|
||||
},
|
||||
{
|
||||
@@ -268,24 +258,21 @@
|
||||
{
|
||||
"fieldname": "payment_terms",
|
||||
"fieldtype": "Link",
|
||||
"label": "Payment Terms Template",
|
||||
"label": "Default Payment Terms Template",
|
||||
"options": "Payment Terms Template"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "When enabled, transactions with this supplier will be blocked based on the Hold Type below",
|
||||
"fieldname": "on_hold",
|
||||
"fieldtype": "Check",
|
||||
"label": "Block Supplier",
|
||||
"show_description_on_click": 1
|
||||
"label": "Block Supplier"
|
||||
},
|
||||
{
|
||||
"default": "All",
|
||||
"depends_on": "eval:doc.on_hold",
|
||||
"fieldname": "hold_type",
|
||||
"fieldtype": "Select",
|
||||
"label": "Hold Type",
|
||||
"options": "All\nInvoices\nPayments"
|
||||
"options": "\nAll\nInvoices\nPayments"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:doc.on_hold",
|
||||
@@ -320,13 +307,14 @@
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Override the default payable / advance accounts on a per-company basis. Leave blank to use each company's defaults from Company settings.",
|
||||
"description": "Mention if non-standard payable account",
|
||||
"fieldname": "accounts",
|
||||
"fieldtype": "Table",
|
||||
"label": "Per-Company Accounts",
|
||||
"label": "Accounts",
|
||||
"options": "Party Account"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"collapsible_depends_on": "supplier_details",
|
||||
"fieldname": "column_break2",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -341,7 +329,7 @@
|
||||
"oldfieldtype": "Data"
|
||||
},
|
||||
{
|
||||
"description": "General information about your Supplier",
|
||||
"description": "Statutory info and other general information about your Supplier",
|
||||
"fieldname": "supplier_details",
|
||||
"fieldtype": "Text",
|
||||
"label": "Supplier Details",
|
||||
@@ -354,7 +342,6 @@
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Frozen suppliers block ledger entries until unfrozen. Use this to temporarily lock accounting activity without disabling the supplier.",
|
||||
"fieldname": "is_frozen",
|
||||
"fieldtype": "Check",
|
||||
"label": "Is Frozen"
|
||||
@@ -363,13 +350,13 @@
|
||||
"default": "0",
|
||||
"fieldname": "allow_purchase_invoice_creation_without_purchase_order",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow purchase invoice creation without purchase order"
|
||||
"label": "Allow Purchase Invoice Creation Without Purchase Order"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "allow_purchase_invoice_creation_without_purchase_receipt",
|
||||
"fieldtype": "Check",
|
||||
"label": "Allow purchase invoice creation without purchase receipt"
|
||||
"label": "Allow Purchase Invoice Creation Without Purchase Receipt"
|
||||
},
|
||||
{
|
||||
"fieldname": "primary_address_and_contact_detail_section",
|
||||
@@ -380,7 +367,7 @@
|
||||
"description": "Reselect, if the chosen contact is edited after save",
|
||||
"fieldname": "supplier_primary_contact",
|
||||
"fieldtype": "Link",
|
||||
"label": "Primary Contact",
|
||||
"label": "Supplier Primary Contact",
|
||||
"options": "Contact"
|
||||
},
|
||||
{
|
||||
@@ -393,19 +380,23 @@
|
||||
"fetch_from": "supplier_primary_contact.email_id",
|
||||
"fieldname": "email_id",
|
||||
"fieldtype": "Read Only",
|
||||
"label": "Email ID"
|
||||
"label": "Email Id"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_44",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "primary_address",
|
||||
"fieldtype": "Text Editor",
|
||||
"label": "Primary Address Preview",
|
||||
"label": "Primary Address",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"description": "Reselect, if the chosen address is edited after save",
|
||||
"fieldname": "supplier_primary_address",
|
||||
"fieldtype": "Link",
|
||||
"label": "Primary Address",
|
||||
"label": "Supplier Primary Address",
|
||||
"options": "Address"
|
||||
},
|
||||
{
|
||||
@@ -440,11 +431,10 @@
|
||||
"label": "Tax"
|
||||
},
|
||||
{
|
||||
"collapsible_depends_on": "is_internal_supplier",
|
||||
"collapsible": 1,
|
||||
"fieldname": "internal_supplier_section",
|
||||
"fieldtype": "Section Break",
|
||||
"hide_border": 1,
|
||||
"label": "Internal Supplier Details"
|
||||
"label": "Internal Supplier Accounting"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_16",
|
||||
@@ -463,6 +453,10 @@
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Block Supplier"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_59",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "default_accounts_section",
|
||||
"fieldtype": "Section Break",
|
||||
@@ -484,14 +478,12 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"description": "Account / customer numbers assigned to your companies by this supplier (for reconciliation on their statements)",
|
||||
"fieldname": "customer_numbers",
|
||||
"fieldtype": "Table",
|
||||
"label": "Customer Numbers",
|
||||
"options": "Customer Number At Supplier"
|
||||
},
|
||||
{
|
||||
"description": "Used to pick the correct rate row inside the Tax Withholding Category for this supplier (e.g. Company vs Individual rates)",
|
||||
"fieldname": "tax_withholding_group",
|
||||
"fieldtype": "Link",
|
||||
"label": "Tax Withholding Group",
|
||||
@@ -507,34 +499,11 @@
|
||||
{
|
||||
"fieldname": "rfq_and_purchase_order_settings_section",
|
||||
"fieldtype": "Section Break",
|
||||
"hidden": 1,
|
||||
"label": "RFQ and Purchase Order Settings"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_oxjw",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "taxation_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Tax Identification"
|
||||
},
|
||||
{
|
||||
"fieldname": "invoice_settings_section",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "more_info_tab",
|
||||
"fieldtype": "Tab Break",
|
||||
"label": "More Info"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_jqla",
|
||||
"fieldtype": "Section Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_pgad",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -548,7 +517,7 @@
|
||||
"link_fieldname": "party"
|
||||
}
|
||||
],
|
||||
"modified": "2026-05-29 13:03:41.864602",
|
||||
"modified": "2026-03-09 17:15:25.465759",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier",
|
||||
|
||||
@@ -50,7 +50,7 @@ class Supplier(TransactionBase):
|
||||
disabled: DF.Check
|
||||
email_id: DF.ReadOnly | None
|
||||
gender: DF.Link | None
|
||||
hold_type: DF.Literal["All", "Invoices", "Payments"]
|
||||
hold_type: DF.Literal["", "All", "Invoices", "Payments"]
|
||||
image: DF.AttachImage | None
|
||||
is_frozen: DF.Check
|
||||
is_internal_supplier: DF.Check
|
||||
@@ -88,6 +88,7 @@ class Supplier(TransactionBase):
|
||||
|
||||
def before_save(self):
|
||||
if not self.on_hold:
|
||||
self.hold_type = ""
|
||||
self.release_date = ""
|
||||
elif self.on_hold and not self.hold_type:
|
||||
self.hold_type = "All"
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
frappe.listview_settings["Supplier"] = {
|
||||
add_fields: ["supplier_name", "supplier_group", "image", "on_hold", "disabled", "is_frozen"],
|
||||
add_fields: ["supplier_name", "supplier_group", "image", "on_hold"],
|
||||
get_indicator: function (doc) {
|
||||
if (cint(doc.disabled)) {
|
||||
return [__("Disabled"), "gray", "disabled,=,1"];
|
||||
} else if (cint(doc.on_hold)) {
|
||||
return [__("On Hold"), "red", "on_hold,=,1"];
|
||||
} else if (cint(doc.is_frozen)) {
|
||||
return [__("Frozen"), "orange", "is_frozen,=,1"];
|
||||
} else {
|
||||
return [__("Active"), "green", "disabled,=,0|on_hold,=,0|is_frozen,=,0"];
|
||||
if (cint(doc.on_hold)) {
|
||||
return [__("On Hold"), "red"];
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
"quotation_number",
|
||||
"has_unit_price_items",
|
||||
"amended_from",
|
||||
"section_break_wwao",
|
||||
"title",
|
||||
"accounting_dimensions_section",
|
||||
"cost_center",
|
||||
"dimension_col_break",
|
||||
@@ -116,7 +118,6 @@
|
||||
"more_info",
|
||||
"is_subcontracted",
|
||||
"column_break_57",
|
||||
"title",
|
||||
"opportunity",
|
||||
"connections_tab"
|
||||
],
|
||||
@@ -939,6 +940,10 @@
|
||||
"fieldname": "auto_repeat_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Auto Repeat"
|
||||
},
|
||||
{
|
||||
"fieldname": "section_break_wwao",
|
||||
"fieldtype": "Section Break"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
@@ -947,7 +952,7 @@
|
||||
"index_web_pages_for_search": 1,
|
||||
"is_submittable": 1,
|
||||
"links": [],
|
||||
"modified": "2026-05-28 12:29:37.509487",
|
||||
"modified": "2026-03-30 12:18:35.777574",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Buying",
|
||||
"name": "Supplier Quotation",
|
||||
|
||||
@@ -73,16 +73,22 @@ def _get_variables(criteria):
|
||||
mylist = re.finditer(regex, criteria.formula, re.MULTILINE | re.DOTALL)
|
||||
for _dummy1, match in enumerate(mylist):
|
||||
for _dummy2 in range(0, len(match.groups())):
|
||||
param_name = match.group(1)
|
||||
|
||||
variables = frappe.get_all(
|
||||
"Supplier Scorecard Variable",
|
||||
fields=["variable_label", "description", "param_name", "path"],
|
||||
filters={"param_name": param_name},
|
||||
limit=1,
|
||||
)
|
||||
if not variables:
|
||||
frappe.throw(_("Unable to find variable: {0}").format(param_name), InvalidFormulaVariable)
|
||||
my_variables.append(variables[0])
|
||||
try:
|
||||
var = frappe.db.sql(
|
||||
"""
|
||||
SELECT
|
||||
scv.variable_label, scv.description, scv.param_name, scv.path
|
||||
FROM
|
||||
`tabSupplier Scorecard Variable` scv
|
||||
WHERE
|
||||
param_name=%(param)s""",
|
||||
{"param": match.group(1)},
|
||||
as_dict=1,
|
||||
)[0]
|
||||
my_variables.append(var)
|
||||
except Exception:
|
||||
frappe.throw(
|
||||
_("Unable to find variable:") + " " + str(match.group(1)), InvalidFormulaVariable
|
||||
)
|
||||
|
||||
return my_variables
|
||||
|
||||
@@ -215,9 +215,7 @@ def create_variant(item: str, args: dict | str, use_template_image: bool = False
|
||||
variant_attributes = []
|
||||
|
||||
for d in template.attributes:
|
||||
attribute_value = args.get(_(d.attribute)) or args.get(d.attribute)
|
||||
if attribute_value:
|
||||
variant_attributes.append({"attribute": d.attribute, "attribute_value": attribute_value})
|
||||
variant_attributes.append({"attribute": d.attribute, "attribute_value": args.get(_(d.attribute))})
|
||||
|
||||
variant.set("attributes", variant_attributes)
|
||||
copy_attributes_to_variant(template, variant)
|
||||
@@ -236,12 +234,6 @@ def enqueue_multiple_variant_creation(item: str, args: dict | str, use_template_
|
||||
# There can be innumerable attribute combinations, enqueue
|
||||
if isinstance(args, str):
|
||||
variants = json.loads(args)
|
||||
else:
|
||||
variants = args
|
||||
variants = {key: values for key, values in variants.items() if values}
|
||||
if not variants:
|
||||
frappe.throw(_("Please select at least one attribute value"))
|
||||
|
||||
total_variants = 1
|
||||
for key in variants:
|
||||
total_variants *= len(variants[key])
|
||||
@@ -265,7 +257,6 @@ def create_multiple_variants(item, args, use_template_image=False):
|
||||
count = 0
|
||||
if isinstance(args, str):
|
||||
args = json.loads(args)
|
||||
args = {key: values for key, values in args.items() if values}
|
||||
|
||||
template_item = frappe.get_doc("Item", item)
|
||||
args_set = generate_keyed_value_combinations(args)
|
||||
@@ -300,9 +291,6 @@ def generate_keyed_value_combinations(args):
|
||||
|
||||
"""
|
||||
# Return empty list if empty
|
||||
if not args:
|
||||
return []
|
||||
args = {key: values for key, values in args.items() if values}
|
||||
if not args:
|
||||
return []
|
||||
|
||||
|
||||
@@ -433,15 +433,10 @@ def get_delivery_notes_to_be_billed(
|
||||
.where((DeliveryNote.docstatus == 1) & (DeliveryNote.is_return == 0) & (DeliveryNote.per_billed > 0))
|
||||
)
|
||||
|
||||
query = frappe.qb.get_query(
|
||||
"Delivery Note",
|
||||
fields=fields,
|
||||
filters=filters,
|
||||
ignore_permissions=False,
|
||||
)
|
||||
|
||||
query = (
|
||||
query.where(
|
||||
frappe.qb.from_(DeliveryNote)
|
||||
.select(*[DeliveryNote[f] for f in fields])
|
||||
.where(
|
||||
(DeliveryNote.docstatus == 1)
|
||||
& (DeliveryNote.status.notin(["Stopped", "Closed"]))
|
||||
& (DeliveryNote[searchfield].like(f"%{txt}%"))
|
||||
@@ -455,11 +450,12 @@ def get_delivery_notes_to_be_billed(
|
||||
)
|
||||
)
|
||||
)
|
||||
.orderby(DeliveryNote[searchfield], order=Order.asc)
|
||||
.limit(page_len)
|
||||
.offset(start)
|
||||
)
|
||||
if filters and isinstance(filters, dict):
|
||||
for key, value in filters.items():
|
||||
query = query.where(DeliveryNote[key] == value)
|
||||
|
||||
query = query.orderby(DeliveryNote[searchfield], order=Order.asc).limit(page_len).offset(start)
|
||||
return query.run(as_dict=as_dict)
|
||||
|
||||
|
||||
@@ -548,7 +544,7 @@ def get_batches_from_stock_ledger_entries(searchfields, txt, filters, start=0, p
|
||||
if filters.get("posting_date") and filters.get("posting_time"):
|
||||
query = query.where(
|
||||
stock_ledger_entry.posting_datetime
|
||||
<= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time"))
|
||||
<= get_combine_datetime(filters.posting_date, filters.posting_time)
|
||||
)
|
||||
|
||||
if not filters.get("include_expired_batches"):
|
||||
@@ -608,7 +604,7 @@ def get_batches_from_serial_and_batch_bundle(searchfields, txt, filters, start=0
|
||||
if filters.get("posting_date") and filters.get("posting_time"):
|
||||
bundle_query = bundle_query.where(
|
||||
stock_ledger_entry.posting_datetime
|
||||
<= get_combine_datetime(filters.get("posting_date"), filters.get("posting_time"))
|
||||
<= get_combine_datetime(filters.posting_date, filters.posting_time)
|
||||
)
|
||||
|
||||
if not filters.get("include_expired_batches"):
|
||||
|
||||
@@ -262,17 +262,15 @@ class StatusUpdater(Document):
|
||||
|
||||
def validate_qty(self):
|
||||
"""Validates qty at row level"""
|
||||
self.item_allowance = {}
|
||||
self.global_qty_allowance = None
|
||||
self.global_amount_allowance = None
|
||||
|
||||
for args in self.status_updater:
|
||||
if "target_ref_field" not in args or args.get("validate_qty") is False:
|
||||
# if target_ref_field is not specified or validate_qty is explicitly set to False, skip validation
|
||||
continue
|
||||
|
||||
# Reset per-args so each config block uses its own allowance source without
|
||||
# leaking cached values from a previous config block.
|
||||
self.item_allowance = {}
|
||||
self.global_qty_allowance = None
|
||||
self.global_amount_allowance = None
|
||||
|
||||
items_to_validate = []
|
||||
selling_negative_rate_allowed = frappe.get_single_value(
|
||||
"Selling Settings", "allow_negative_rates_for_items"
|
||||
@@ -404,12 +402,9 @@ class StatusUpdater(Document):
|
||||
|
||||
def check_overflow_with_allowance(self, item, args):
|
||||
"""
|
||||
Checks if there is overflow considering a relaxation allowance.
|
||||
Checks if there is overflow condering a relaxation allowance
|
||||
"""
|
||||
qty_or_amount = "qty" if "qty" in args["target_ref_field"] else "amount"
|
||||
global_qty_allowance_field = args.get("global_allowance_field", "over_delivery_receipt_allowance")
|
||||
global_qty_allowance_doctype = args.get("global_allowance_doctype", "Stock Settings")
|
||||
item_qty_allowance_field = args.get("item_allowance_field", "over_delivery_receipt_allowance")
|
||||
|
||||
# check if overflow is within allowance
|
||||
(
|
||||
@@ -424,9 +419,6 @@ class StatusUpdater(Document):
|
||||
self.global_qty_allowance,
|
||||
self.global_amount_allowance,
|
||||
qty_or_amount,
|
||||
global_qty_allowance_field,
|
||||
global_qty_allowance_doctype,
|
||||
item_qty_allowance_field,
|
||||
)
|
||||
if args["source_dt"] != "Pick List Item"
|
||||
else (0, {}, None, None)
|
||||
@@ -471,9 +463,7 @@ class StatusUpdater(Document):
|
||||
"Quotation Item",
|
||||
"Packed Item",
|
||||
]:
|
||||
if args.get("target_dt") == "Material Request Item":
|
||||
action_msg = _('To allow over ordering, update "Over Order Allowance" in Buying Settings.')
|
||||
elif qty_or_amount == "qty":
|
||||
if qty_or_amount == "qty":
|
||||
action_msg = _(
|
||||
'To allow over receipt / delivery, update "Over Receipt/Delivery Allowance" in Stock Settings or the Item.'
|
||||
)
|
||||
@@ -734,28 +724,16 @@ class StatusUpdater(Document):
|
||||
ref_doc.set_status(update=True)
|
||||
|
||||
|
||||
@frappe.request_cache
|
||||
def get_allowance_for(
|
||||
item_code,
|
||||
item_allowance=None,
|
||||
global_qty_allowance=None,
|
||||
global_amount_allowance=None,
|
||||
qty_or_amount="qty",
|
||||
global_qty_allowance_field="over_delivery_receipt_allowance",
|
||||
global_qty_allowance_doctype="Stock Settings",
|
||||
item_qty_allowance_field="over_delivery_receipt_allowance",
|
||||
):
|
||||
"""
|
||||
Returns the allowance for the item, if not set, returns global allowance.
|
||||
|
||||
Args:
|
||||
item_code: The item to get allowance for.
|
||||
item_allowance: Cached per-item allowances from a previous call.
|
||||
global_qty_allowance: Cached global qty allowance from a previous call.
|
||||
global_amount_allowance: Cached global amount allowance from a previous call.
|
||||
qty_or_amount: Whether to return qty or amount allowance.
|
||||
global_qty_allowance_field: The field name on the settings doctype to use for the global qty allowance.
|
||||
global_qty_allowance_doctype: The settings doctype to read the global qty allowance from.
|
||||
item_qty_allowance_field: The field name on the Item doctype to use for the item-level qty allowance override.
|
||||
Returns the allowance for the item, if not set, returns global allowance
|
||||
"""
|
||||
if item_allowance is None:
|
||||
item_allowance = {}
|
||||
@@ -777,13 +755,13 @@ def get_allowance_for(
|
||||
)
|
||||
|
||||
qty_allowance, over_billing_allowance = frappe.get_cached_value(
|
||||
"Item", item_code, [item_qty_allowance_field, "over_billing_allowance"]
|
||||
"Item", item_code, ["over_delivery_receipt_allowance", "over_billing_allowance"]
|
||||
)
|
||||
|
||||
if qty_or_amount == "qty" and not qty_allowance:
|
||||
if global_qty_allowance is None:
|
||||
global_qty_allowance = flt(
|
||||
frappe.get_single_value(global_qty_allowance_doctype, global_qty_allowance_field)
|
||||
frappe.get_cached_value("Stock Settings", None, "over_delivery_receipt_allowance")
|
||||
)
|
||||
qty_allowance = global_qty_allowance
|
||||
elif qty_or_amount == "amount" and not over_billing_allowance:
|
||||
|
||||
@@ -1442,7 +1442,7 @@ class StockController(AccountsController):
|
||||
elif self.doctype == "Stock Entry" and row.t_warehouse:
|
||||
qi_required = True # inward stock needs inspection
|
||||
|
||||
if row.get("secondary_item_type") or row.get("is_legacy_scrap_item"):
|
||||
if row.get("type") or row.get("is_legacy_scrap_item"):
|
||||
continue
|
||||
|
||||
if qi_required: # validate row only if inspection is required on item level
|
||||
@@ -2113,7 +2113,7 @@ def repost_required_for_queue(doc: StockController) -> bool:
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str | list[dict]):
|
||||
def check_item_quality_inspection(doctype: str, items: str | list[dict]):
|
||||
if isinstance(items, str):
|
||||
items = json.loads(items)
|
||||
|
||||
@@ -2125,30 +2125,13 @@ def check_item_quality_inspection(doctype: str, docstatus: str | int, items: str
|
||||
"Delivery Note": "inspection_required_before_delivery",
|
||||
}
|
||||
|
||||
inspection_fieldname = inspection_fieldname_map.get(doctype)
|
||||
if inspection_fieldname is None:
|
||||
return []
|
||||
items_to_remove = []
|
||||
for item in items:
|
||||
if not frappe.db.get_value("Item", item.get("item_code"), inspection_fieldname_map.get(doctype)):
|
||||
items_to_remove.append(item)
|
||||
items = [item for item in items if item not in items_to_remove]
|
||||
|
||||
allow_after_transaction = cint(docstatus) == 1 and frappe.get_single_value(
|
||||
"Stock Settings", "allow_to_make_quality_inspection_after_purchase_or_delivery"
|
||||
)
|
||||
|
||||
if allow_after_transaction:
|
||||
return items
|
||||
|
||||
item_codes = list({item.get("item_code") for item in items})
|
||||
|
||||
Item = frappe.qb.DocType("Item")
|
||||
results = (
|
||||
frappe.qb.from_(Item)
|
||||
.select(Item.name)
|
||||
.where((Item.name.isin(item_codes)) & (Item[inspection_fieldname] == 1))
|
||||
.run(as_dict=True)
|
||||
)
|
||||
|
||||
inspection_required_items = {row.name for row in results}
|
||||
|
||||
return [item for item in items if item.get("item_code") in inspection_required_items]
|
||||
return items
|
||||
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -150,7 +150,7 @@ class SubcontractingController(StockController):
|
||||
).format(item.idx, get_link_to_form("Item", item.item_code))
|
||||
)
|
||||
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
if not is_sub_contracted_item:
|
||||
frappe.throw(
|
||||
_("Row {0}: Item {1} must be a subcontracted item.").format(item.idx, item.item_name)
|
||||
@@ -1243,10 +1243,10 @@ class SubcontractingController(StockController):
|
||||
total_amt = sum(
|
||||
flt(item.amount)
|
||||
for item in self.get("items")
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item")
|
||||
)
|
||||
for item in self.items:
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
item.additional_cost_per_qty = (
|
||||
(item.amount * self.total_additional_costs) / total_amt
|
||||
) / item.qty
|
||||
@@ -1254,15 +1254,15 @@ class SubcontractingController(StockController):
|
||||
total_qty = sum(
|
||||
flt(item.qty)
|
||||
for item in self.get("items")
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item")
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item")
|
||||
)
|
||||
additional_cost_per_qty = self.total_additional_costs / total_qty
|
||||
for item in self.items:
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
item.additional_cost_per_qty = additional_cost_per_qty
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.get("secondary_item_type") and not item.get("is_legacy_scrap_item"):
|
||||
if not item.get("type") and not item.get("is_legacy_scrap_item"):
|
||||
item.additional_cost_per_qty = 0
|
||||
|
||||
@frappe.whitelist()
|
||||
|
||||
@@ -241,7 +241,7 @@ class SubcontractingInwardController:
|
||||
item
|
||||
for item in self.get("items")
|
||||
if not item.is_finished_item
|
||||
and not item.secondary_item_type
|
||||
and not item.type
|
||||
and not item.is_legacy_scrap_item
|
||||
and frappe.get_cached_value("Item", item.item_code, "is_customer_provided_item")
|
||||
]
|
||||
@@ -372,7 +372,7 @@ class SubcontractingInwardController:
|
||||
if self.purpose in ["Subcontracting Delivery", "Subcontracting Return", "Manufacture"]:
|
||||
for item in self.items:
|
||||
if (
|
||||
item.is_finished_item or item.secondary_item_type or item.is_legacy_scrap_item
|
||||
item.is_finished_item or item.type or item.is_legacy_scrap_item
|
||||
) and item.valuation_rate == 0:
|
||||
item.allow_zero_valuation_rate = 1
|
||||
|
||||
@@ -472,7 +472,7 @@ class SubcontractingInwardController:
|
||||
self.validate_delivery_on_save()
|
||||
else:
|
||||
for item in self.items:
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item:
|
||||
if not item.type and not item.is_legacy_scrap_item:
|
||||
delivered_qty, returned_qty = frappe.get_value(
|
||||
"Subcontracting Inward Order Item",
|
||||
item.scio_detail,
|
||||
@@ -543,7 +543,7 @@ class SubcontractingInwardController:
|
||||
bold(
|
||||
frappe.get_cached_value(
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
if not item.type and not item.is_legacy_scrap_item
|
||||
else "Subcontracting Inward Order Secondary Item",
|
||||
item.scio_detail,
|
||||
"stock_uom",
|
||||
@@ -595,7 +595,7 @@ class SubcontractingInwardController:
|
||||
)
|
||||
|
||||
for item in [item for item in self.items if not item.is_finished_item]:
|
||||
if item.secondary_item_type or item.is_legacy_scrap_item:
|
||||
if item.type or item.is_legacy_scrap_item:
|
||||
scio_secondary_item = frappe.get_value(
|
||||
"Subcontracting Inward Order Secondary Item",
|
||||
{
|
||||
@@ -655,7 +655,7 @@ class SubcontractingInwardController:
|
||||
for item in self.items:
|
||||
doctype = (
|
||||
"Subcontracting Inward Order Item"
|
||||
if not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
if not item.type and not item.is_legacy_scrap_item
|
||||
else "Subcontracting Inward Order Secondary Item"
|
||||
)
|
||||
qty_map[doctype][item.scio_detail] += (
|
||||
@@ -781,7 +781,7 @@ class SubcontractingInwardController:
|
||||
items = [
|
||||
item
|
||||
for item in self.items
|
||||
if not item.is_finished_item and not item.secondary_item_type and not item.is_legacy_scrap_item
|
||||
if not item.is_finished_item and not item.type and not item.is_legacy_scrap_item
|
||||
]
|
||||
item_code_wh = frappe._dict(
|
||||
{
|
||||
@@ -884,9 +884,7 @@ class SubcontractingInwardController:
|
||||
|
||||
def update_inward_order_secondary_items(self):
|
||||
if (scio := self.subcontracting_inward_order) and self.purpose == "Manufacture":
|
||||
secondary_items_list = [
|
||||
item for item in self.items if item.secondary_item_type or item.is_legacy_scrap_item
|
||||
]
|
||||
secondary_items_list = [item for item in self.items if item.type or item.is_legacy_scrap_item]
|
||||
|
||||
secondary_items = defaultdict(float)
|
||||
for item in secondary_items_list:
|
||||
@@ -960,7 +958,7 @@ class SubcontractingInwardController:
|
||||
stock_uom=secondary_item.stock_uom,
|
||||
warehouse=secondary_item.t_warehouse,
|
||||
produced_qty=secondary_item.transfer_qty,
|
||||
secondary_item_type=secondary_item.secondary_item_type,
|
||||
type=secondary_item.type,
|
||||
delivered_qty=0,
|
||||
reference_name=frappe.get_value(
|
||||
"Work Order", self.work_order, "subcontracting_inward_order_item"
|
||||
|
||||
@@ -2,11 +2,7 @@ import json
|
||||
|
||||
import frappe
|
||||
|
||||
from erpnext.controllers.item_variant import (
|
||||
copy_attributes_to_variant,
|
||||
generate_keyed_value_combinations,
|
||||
make_variant_item_code,
|
||||
)
|
||||
from erpnext.controllers.item_variant import copy_attributes_to_variant, make_variant_item_code
|
||||
from erpnext.stock.doctype.item.test_item import set_item_variant_settings
|
||||
from erpnext.stock.doctype.quality_inspection.test_quality_inspection import (
|
||||
create_quality_inspection_parameter,
|
||||
@@ -21,19 +17,6 @@ class TestItemVariant(ERPNextTestSuite):
|
||||
variant = make_item_variant()
|
||||
self.assertEqual(variant.get("quality_inspection_template"), "_Test QC Template")
|
||||
|
||||
def test_generate_keyed_value_combinations_ignores_empty_attributes(self):
|
||||
combinations = generate_keyed_value_combinations(
|
||||
{"Test Colour": ["Red", "Blue"], "Test Size": ["Small", "Large"], "Test Fit": []}
|
||||
)
|
||||
|
||||
self.assertEqual(len(combinations), 4)
|
||||
self.assertNotIn("Test Fit", combinations[0])
|
||||
|
||||
single_attribute_combinations = generate_keyed_value_combinations(
|
||||
{"Test Colour": ["Red", "Blue"], "Test Size": []}
|
||||
)
|
||||
self.assertEqual(single_attribute_combinations, [{"Test Colour": "Red"}, {"Test Colour": "Blue"}])
|
||||
|
||||
|
||||
def create_variant_with_tables(item, args):
|
||||
if isinstance(args, str):
|
||||
|
||||
3036
erpnext/locale/ar.po
3036
erpnext/locale/ar.po
File diff suppressed because it is too large
Load Diff
3036
erpnext/locale/bs.po
3036
erpnext/locale/bs.po
File diff suppressed because it is too large
Load Diff
3154
erpnext/locale/cs.po
3154
erpnext/locale/cs.po
File diff suppressed because it is too large
Load Diff
3018
erpnext/locale/da.po
3018
erpnext/locale/da.po
File diff suppressed because it is too large
Load Diff
3036
erpnext/locale/de.po
3036
erpnext/locale/de.po
File diff suppressed because it is too large
Load Diff
3036
erpnext/locale/eo.po
3036
erpnext/locale/eo.po
File diff suppressed because it is too large
Load Diff
3038
erpnext/locale/es.po
3038
erpnext/locale/es.po
File diff suppressed because it is too large
Load Diff
3110
erpnext/locale/fa.po
3110
erpnext/locale/fa.po
File diff suppressed because it is too large
Load Diff
3026
erpnext/locale/fr.po
3026
erpnext/locale/fr.po
File diff suppressed because it is too large
Load Diff
3048
erpnext/locale/hr.po
3048
erpnext/locale/hr.po
File diff suppressed because it is too large
Load Diff
3032
erpnext/locale/hu.po
3032
erpnext/locale/hu.po
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user